From 1b993c674b30da83b19ef976fd073572aa6559b8 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sat, 26 Sep 2026 14:03:34 -0700 Subject: [PATCH] menuband: preserve performance actions and sources in mbtape archives --- pop/bin/mbtape.py | 82 +++++++++ pop/tests/test_mbtape.py | 66 +++++++ slab/menuband/Info.plist | 8 + slab/menuband/MBTAPE.md | 57 ++++++ .../Sources/MenuBand/AppDelegate.swift | 37 ++-- slab/menuband/Sources/MenuBand/MBTape.swift | 162 ++++++++++++++++++ .../Sources/MenuBand/MenuBandController.swift | 40 ++--- .../MenuBand/MenuBandFluoddityVoice.swift | 13 ++ .../MenuBand/MenuBandSampleVoice.swift | 13 ++ .../Sources/MenuBand/MenuBandSynth.swift | 147 +++++++++++++++- .../Sources/MenuBand/MenuBandTape.swift | 123 ++++++++----- slab/menuband/Sources/MenuBand/MidiFile.swift | 7 +- slab/menuband/Sources/MenuBand/TakeDMG.swift | 20 --- .../Tests/MenuBandTests/MBTapeTests.swift | 86 ++++++++++ .../TapeExportSnapshotTests.swift | 22 ++- 15 files changed, 775 insertions(+), 108 deletions(-) create mode 100644 pop/bin/mbtape.py create mode 100644 pop/tests/test_mbtape.py create mode 100644 slab/menuband/MBTAPE.md create mode 100644 slab/menuband/Sources/MenuBand/MBTape.swift create mode 100644 slab/menuband/Tests/MenuBandTests/MBTapeTests.swift diff --git a/pop/bin/mbtape.py b/pop/bin/mbtape.py new file mode 100644 index 0000000000..c9dfbc8fa4 --- /dev/null +++ b/pop/bin/mbtape.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Verify and unpack Menu Band performances for /pop; never execute their events.""" +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import shutil +import tempfile +import zipfile + + +def inspect(path): + with zipfile.ZipFile(path) as tape: + members = tape.infolist() + names = [m.filename for m in members] + if len(names) != len(set(names)): + raise ValueError('duplicate archive paths') + for member in members: + p = PurePosixPath(member.filename) + if p.is_absolute() or '..' in p.parts or '\\' in member.filename or member.is_dir(): + raise ValueError('invalid file path: ' + member.filename) + if member.file_size > 2_000_000_000 or (member.external_attr >> 16) & 0o170000 == 0o120000: + raise ValueError('oversized file or symlink') + if sum(m.file_size for m in members) > 4_000_000_000: + raise ValueError('archive exceeds 4 GB') + manifest = json.loads(tape.read('manifest.json')) + if manifest.get('format') != 'mbtape' or manifest.get('version') != 1: + raise ValueError('unsupported mbtape format/version') + entries = manifest['files'] + paths = [f['path'] for f in entries] + if len(paths) != len(set(paths)) or set(paths) != set(names) - {'manifest.json'}: + raise ValueError('manifest and archive members disagree') + required = {'master.wav', 'score.mbscore', 'events.json', 'state.json', 'stems/mix.json'} + if not required.issubset(paths): + raise ValueError('incomplete recording') + for entry in entries: + digest = hashlib.sha256() + size = 0 + with tape.open(entry['path']) as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + size += len(chunk) + digest.update(chunk) + if size != entry['bytes'] or digest.hexdigest() != entry['sha256']: + raise ValueError('checksum mismatch: ' + entry['path']) + score = json.loads(tape.read('score.mbscore')) + events = json.loads(tape.read('events.json')) + return {'path': str(path), 'id': manifest['id'], 'duration': manifest['duration'], + 'verifiedFiles': len(entries), 'notes': len(score.get('notes', [])), + 'actions': len(events.get('events', [])), + 'replayLimits': manifest.get('replayLimits', [])} + + +def extract(path, destination): + report = inspect(path) + if destination.exists(): + raise FileExistsError(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + work = Path(tempfile.mkdtemp(prefix='.mbtape-', dir=destination.parent)) + try: + with zipfile.ZipFile(path) as tape: + tape.extractall(work) + # Recheck after reading; never quietly overwrite an existing lane. + if destination.exists(): + raise FileExistsError(destination) + work.rename(destination) + finally: + if work.exists(): + shutil.rmtree(work) + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tape', type=Path) + parser.add_argument('--extract', type=Path, help='new working directory; originals stay in the tape') + args = parser.parse_args() + report = extract(args.tape, args.extract) if args.extract else inspect(args.tape) + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/pop/tests/test_mbtape.py b/pop/tests/test_mbtape.py new file mode 100644 index 0000000000..1cb9984735 --- /dev/null +++ b/pop/tests/test_mbtape.py @@ -0,0 +1,66 @@ +import hashlib +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +import zipfile + +spec = importlib.util.spec_from_file_location('mbtape', Path(__file__).parents[1] / 'bin/mbtape.py') +mbtape = importlib.util.module_from_spec(spec) +spec.loader.exec_module(mbtape) + + +class TapeImportTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + + def tape(self, corrupt=False, unsafe=False): + files = {'master.wav': b'audio fixture', 'score.mbscore': b'{"notes": []}', + 'events.json': b'{"events": []}', 'state.json': b'{}', 'stems/mix.json': b'{}'} + if unsafe: + files['../outside'] = b'no' + manifest = {'format': 'mbtape', 'version': 1, 'id': 'fixture', 'duration': 1, + 'files': [{'path': p, 'bytes': len(d), 'sha256': hashlib.sha256(d).hexdigest()} + for p, d in files.items()]} + if corrupt: + files['master.wav'] = b'changed fixture' + path = self.root / 'test.mbtape' + with zipfile.ZipFile(path, 'w') as archive: + for p, d in files.items(): + archive.writestr(p, d) + archive.writestr('manifest.json', json.dumps(manifest)) + return path + + def test_verified_extraction_preserves_source_and_rejects_overwrite(self): + path = self.tape() + original = path.read_bytes() + destination = self.root / 'source' + self.assertEqual(mbtape.extract(path, destination)['verifiedFiles'], 5) + self.assertEqual((destination / 'master.wav').read_bytes(), b'audio fixture') + self.assertEqual(path.read_bytes(), original) + with self.assertRaises(FileExistsError): + mbtape.extract(path, destination) + + def test_corrupt_recording_is_not_extracted(self): + destination = self.root / 'source' + with self.assertRaisesRegex(ValueError, 'checksum'): + mbtape.extract(self.tape(corrupt=True), destination) + self.assertFalse(destination.exists()) + + def test_path_escape_is_rejected(self): + with self.assertRaisesRegex(ValueError, 'invalid file path'): + mbtape.inspect(self.tape(unsafe=True)) + + def test_unlisted_member_is_rejected(self): + path = self.tape() + with zipfile.ZipFile(path, 'a') as archive: + archive.writestr('extra', b'unlisted') + with self.assertRaisesRegex(ValueError, 'disagree'): + mbtape.inspect(path) + + +if __name__ == '__main__': + unittest.main() diff --git a/slab/menuband/Info.plist b/slab/menuband/Info.plist index 1ea892a9ba..48e8e6a2e9 100644 --- a/slab/menuband/Info.plist +++ b/slab/menuband/Info.plist @@ -70,6 +70,14 @@ Menu Band transcribes your voice on-device so you can dictate text straight into whatever app is in front, hands on the keyboard. UTExportedTypeDeclarations + + UTTypeConformsTopublic.zip-archive + UTTypeDescriptionMenu Band Tape + UTTypeIdentifiercomputer.aestheticcomputer.menuband.tape + UTTypeTagSpecification + public.filename-extensionmbtape + + UTTypeConformsTo diff --git a/slab/menuband/MBTAPE.md b/slab/menuband/MBTAPE.md new file mode 100644 index 0000000000..78d23da526 --- /dev/null +++ b/slab/menuband/MBTAPE.md @@ -0,0 +1,57 @@ +# Menu Band Tape v1 + +A `.mbtape` is a stored ZIP of one recording. The direct-download app saves it +beside the listening MP3 and includes it when the cassette is dragged out. +Every stopped take is also retained under +`~/Library/Application Support/MenuBand/Tapes//` after background export. +The App Store export remains unchanged. + +| File | Meaning | +| --- | --- | +| `manifest.json` | Version, take UUID, date, duration, per-file SHA-256 hashes and replay limits | +| `master.wav` | Captured four-channel export: heard mix L/R, dry mic duplicated | +| `stems/*.wav` | Raw tones, percussion and voice; new takes retain float32 headroom | +| `stems/mix.json` | Initial faders, automation, routing and mix normalization | +| `score.mbscore` | Editable note gates in seconds, velocities, channels and pan; links to full events | +| `notes.mid` | DAW-compatible note projection, when the take contains notes | +| `events.json` | Ordered semantic commands, arguments and monotonic time from REC | +| `state.json` | Initial instrument, backend, controls, pan and Fluoddity state | +| `assets/*.wav` | Immutable versions of microphone samples used during the take | +| `preview.mp3` | Listening copy, when an encoder is available | + +`events.json` is the performance source. The score and MIDI are projections; +pedal messages, bends, effects, percussion and surface gestures remain events. +Captured actions use the corresponding synth method names. `noteOn`/`noteOff` +carry note, velocity, channel and pan; percussion on/off uses a string group ID; +surface events carry points, anchors, direction, speed and energy. `sampleBank` +links sample slots to packaged audio and reference pitch. `fluoddity.trigger` +includes the note's exact seed and 80-parameter rule. No raw typing, filesystem +paths, device IDs or account credentials are recorded. + +All event timestamps retain the original REC origin. For alignment with exported +audio, subtract `audioOffsetSeconds` (35 ms record-key suppression plus leading +silence trim). Keep negative-time events: they may establish controls or start a +note that carries into the audible interval. The score clips note intervals to +the exported audio; MIDI no longer rebases the first note to zero independently. +Sequence numbers preserve order when timestamps coincide. These are input-side +monotonic timestamps, not promises of sample-accurate render scheduling. + +The journal and sample assets are frozen with the stopped take. Export happens on +the serial utility queue; a new recording gets fresh storage. The package is staged +and renamed only when complete. Existing destinations are never overwritten. +The original audio remains authoritative. External plugins/patches, live streams, +reverse history from before REC, already-sounding voices and stochastic percussion +can prevent exact synthesis replay. This version captures data for reconstruction; +it does not include a universal action-timeline playback engine or cloud sync. + +For `/pop`, verify first, then unpack into a new working directory: + +```sh +python3 pop/bin/mbtape.py /path/to/take.mbtape +python3 pop/bin/mbtape.py /path/to/take.mbtape --extract pop/out/my-track/source +``` + +Keep the original tape. Put new scores, transformations and rendered tracks in the +track's workspace and refer back to the manifest take UUID and file hashes. +Older WAV/MIDI exports do not contain the full action history; packaging them +cannot recover missing controls, gestures, seeds or original pre-trim timing. diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 55b4d9dd0a..bd09dcc842 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -99,6 +99,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private struct ExportedTape { let file: URL let stems: URL? + let package: URL? let cover: NSImage? } // Main-thread state; encoding and file IO run on the serial utility queue. @@ -4217,7 +4218,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { do { try FileManager.default.copyItem(at: result.file, to: dest) reveal = dest - if let stems = result.stems { + if let package = result.package { + let target = self.uniqueDestinationOnDesktop(in: desktop, preferredName: package.lastPathComponent) + try FileManager.default.copyItem(at: package, to: target) + reveal = target + } else if let stems = result.stems { let target = self.uniqueDestinationOnDesktop(in: desktop, preferredName: stems.lastPathComponent) try FileManager.default.copyItem(at: stems, to: target) @@ -4290,7 +4295,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSWorkspace.shared.setIcon(cover, forFile: file.path, options: []) } #endif - result = ExportedTape(file: file, stems: take.stems, cover: take.cover) + var package: URL? + var complete = true +#if !MAC_APP_STORE + do { + package = try snapshot.package(preview: file.pathExtension == "mp3" ? file : nil) + guard package != nil else { throw CocoaError(.fileWriteUnknown) } + if let package, let cover = take.cover { + NSWorkspace.shared.setIcon(cover, forFile: package.path, options: []) + } + } catch { complete = false; NSLog("MenuBand: mbtape packaging failed: \(error)") } +#endif + if complete { result = ExportedTape(file: file, stems: take.stems, package: package, cover: take.cover) } } NSLog("MenuBand: tape export finished in \(ProcessInfo.processInfo.systemUptime - started)s off main") let exported = result @@ -4508,7 +4524,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ?? NSImage(named: NSImage.multipleDocumentsName) let audioItem = NSDraggingItem(pasteboardWriter: audio.provider(fileType: type)) audioItem.setDraggingFrame(frame, contents: preview) +#if MAC_APP_STORE let stemsItem = NSDraggingItem(pasteboardWriter: stems.provider(fileType: "public.folder")) +#else + let package = TapeFilePromise(name: snapshot.exportName + ".mbtape") { [self] done in + exportTapeMP3(snapshot: snapshot) { done($0?.package) } + } + let stemsItem = NSDraggingItem(pasteboardWriter: package.provider(fileType: "computer.aestheticcomputer.menuband.tape")) +#endif stemsItem.setDraggingFrame(frame.offsetBy(dx: 18, dy: -18), contents: NSImage(named: NSImage.folderName)) tapeDragSource = source button.beginDraggingSession(with: [audioItem, stemsItem], event: event, source: source) @@ -4880,10 +4903,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { DispatchQueue.main.asyncAfter(deadline: .now() + stopAfter) { [weak self] in guard let self else { return } self.menuBand.stopTapeNow() - DispatchQueue.global(qos: .userInitiated).async { - let saved = self.menuBand.saveStoppedTakeToDesktop() - NSLog("MenuBand mix-test take saved → \(saved?.path ?? "none")") - } + self.dropTapeOnDesktop() } } #endif @@ -6367,10 +6387,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { updateIcon() // keys/chip back to normal color // The WAV render + album-art icon + Desktop copy are heavy — do them // off the main thread so Escape never blocks. - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - let saved = self?.menuBand.saveStoppedTakeToDesktop() - NSLog("MenuBand: recording dumped → \(saved?.path ?? "nothing captured")") - } + dropTapeOnDesktop() } #endif diff --git a/slab/menuband/Sources/MenuBand/MBTape.swift b/slab/menuband/Sources/MenuBand/MBTape.swift new file mode 100644 index 0000000000..c359a0a972 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/MBTape.swift @@ -0,0 +1,162 @@ +import Foundation +import CryptoKit + +/// Performance commands, timestamped on one monotonic clock. Never called +/// from a render callback: the audio buffers remain the sound authority. +final class MBTapeJournal { + private let lock = NSLock() + private var rolling = false + private var epoch: Double = 0 + private var initial: [String: Any] = [:] + private var events: [[String: Any]] = [] + + func begin(state: [String: Any], at time: Double = ProcessInfo.processInfo.systemUptime) { + lock.lock(); defer { lock.unlock() } + initial = state; events = []; epoch = time; rolling = true + } + + func record(_ action: String, _ arguments: [String: Any] = [:], + at time: Double = ProcessInfo.processInfo.systemUptime) { + lock.lock(); defer { lock.unlock() } + guard rolling, time.isFinite, JSONSerialization.isValidJSONObject(arguments) else { return } + events.append(["sequence": events.count, "time": max(0, time - epoch), + "action": action, "arguments": arguments]) + } + + func end(at time: Double = ProcessInfo.processInfo.systemUptime) { + lock.lock(); defer { lock.unlock() } + guard rolling else { return } + events.append(["sequence": events.count, "time": max(0, time - epoch), + "action": "record.stop", "arguments": [:] as [String: Any]]) + rolling = false + } + + func snapshot() -> [String: Any] { + lock.lock(); defer { lock.unlock() } + return ["version": 1, "timeUnit": "seconds", "clock": "monotonic-record-start", + "initialState": initial, "events": events] + } +} + +enum MBTape { + /// A note projection, not a claim that notes alone reproduce every sound. + /// Preserve channel, velocity and pan; pair repeated pitches FIFO. + static func score(journal: [String: Any], name: String, + audioOffset: Double, duration: Double) -> [String: Any] { + var held: [String: [[String: Any]]] = [:] + var notes: [[String: Any]] = [] + func close(_ event: [String: Any], at end: Double) { + guard let a = event["arguments"] as? [String: Any], + let raw = event["time"] as? Double else { return } + let start = max(0, raw - audioOffset) + let stop = min(duration, end - audioOffset) + guard stop > start else { return } + notes.append(["midi": a["note"] ?? 0, "start": start, "dur": stop - start, + "velocity": a["velocity"] ?? 100, "channel": a["channel"] ?? 0, + "pan": a["pan"] ?? 64]) + } + for event in journal["events"] as? [[String: Any]] ?? [] { + guard let action = event["action"] as? String, + let a = event["arguments"] as? [String: Any], + let t = event["time"] as? Double else { continue } + let key = "\(a["channel"] ?? 0):\(a["note"] ?? 0)" + if action == "noteOn", (a["velocity"] as? Int ?? 0) > 0 { + held[key, default: []].append(event) + } else if action == "noteOff" || action == "noteOn" { + if var queue = held[key], !queue.isEmpty { + close(queue.removeFirst(), at: t); held[key] = queue + } + } else if action == "panic" || action == "record.stop" { + for queue in held.values { for on in queue { close(on, at: t) } } + held.removeAll() + } + } + for queue in held.values { for on in queue { close(on, at: audioOffset + duration) } } + notes.sort { ($0["start"] as? Double ?? 0) < ($1["start"] as? Double ?? 0) } + return ["version": 1, "name": name, "notes": notes, "tailSeconds": 0, + "duration": duration, "events": "events.json", "state": "state.json", + "audioOffsetSeconds": audioOffset, + "projection": "captured-note-gates; expression and non-note triggers remain in events.json"] + } + + static func writeJSON(_ object: [String: Any], to url: URL) throws { + try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) + .write(to: url, options: .atomic) + } + + /// Stores files without flattening directories, with a manifest of hashes. + /// Stage and archive on the utility export queue; publish by atomic rename. + static func package(master: URL, stems: URL, midi: URL?, preview: URL?, + journal: [String: Any], id: UUID, date: Date, + audioOffset: Double, duration: Double, destination: URL, assets: [URL] = []) throws -> URL { + let fm = FileManager.default + let work = destination.deletingLastPathComponent().appendingPathComponent(".mbtape-\(UUID())") + let stage = work.appendingPathComponent("content") + try fm.createDirectory(at: stage, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: work) } + func copy(_ from: URL, _ name: String) throws { + try fm.copyItem(at: from, to: stage.appendingPathComponent(name)) + } + if !assets.isEmpty { + try fm.createDirectory(at: stage.appendingPathComponent("assets"), withIntermediateDirectories: true) + for asset in assets { try copy(asset, "assets/" + asset.lastPathComponent) } + } + try copy(master, "master.wav") + try copy(stems, "stems") + if let midi { try copy(midi, "notes.mid") } + if let preview { try copy(preview, "preview.\(preview.pathExtension)") } + var timeline = journal + timeline["audioOffsetSeconds"] = audioOffset + try writeJSON(timeline, to: stage.appendingPathComponent("events.json")) + try writeJSON(journal["initialState"] as? [String: Any] ?? [:], + to: stage.appendingPathComponent("state.json")) + try writeJSON(score(journal: journal, name: master.deletingPathExtension().lastPathComponent, + audioOffset: audioOffset, duration: duration), + to: stage.appendingPathComponent("score.mbscore")) + // mix.json keeps relative references valid inside this package. + let mixURL = stage.appendingPathComponent("stems/mix.json") + guard var mix = try JSONSerialization.jsonObject(with: Data(contentsOf: mixURL)) as? [String: Any] else { + throw CocoaError(.fileReadCorruptFile) + } + var mixFiles = mix["files"] as? [String: String] ?? [:] + mixFiles["mix"] = "../master.wav"; mix["files"] = mixFiles + try writeJSON(mix, to: mixURL) + + var files: [[String: Any]] = [] + let enumerator = fm.enumerator(at: stage, includingPropertiesForKeys: [.isRegularFileKey])! + var paths: [String] = [] + for case let url as URL in enumerator { + guard try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else { continue } + let relative = String(url.resolvingSymlinksInPath().path.dropFirst(stage.resolvingSymlinksInPath().path.count + 1)) + // Finder resource files are decoration, not recording data. + if url.lastPathComponent == "Icon\r" || url.lastPathComponent.hasPrefix("._") { + try fm.removeItem(at: url); continue + } + let data = try Data(contentsOf: url, options: .mappedIfSafe) + files.append(["path": relative, "bytes": data.count, + "sha256": SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()]) + paths.append(relative) + } + try writeJSON(["format": "mbtape", "version": 1, "id": id.uuidString, + "createdAt": ISO8601DateFormatter().string(from: date), + "duration": duration, "audioOffsetSeconds": audioOffset, + "sampleRate": 44100, "files": files.sorted { ($0["path"] as! String) < ($1["path"] as! String) }, + "capture": "semantic-synth-commands", + "replayLimits": ["timestamps are input-side, not audio-render sample timestamps", + "external plugins, live streams, pre-record reverse history and stochastic percussion require audio stems for exact sound", + "raw keyboard events and OS input are intentionally not recorded"]], + to: stage.appendingPathComponent("manifest.json")) + paths.append("manifest.json") + let archive = work.appendingPathComponent("take.mbtape") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/zip") + process.currentDirectoryURL = stage + process.arguments = ["-0", "-q", archive.path, "--"] + paths.sorted() + process.qualityOfService = .utility + try process.run(); process.waitUntilExit() + guard process.terminationStatus == 0 else { throw CocoaError(.fileWriteUnknown) } + // No replacement: another take must never be overwritten. + try fm.moveItem(at: archive, to: destination) + return destination + } +} diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index 29c672aea3..9cc496ca8b 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -2173,7 +2173,8 @@ final class MenuBandController { // already lands in the buffer. synth.addWaveformTapPin(tapeWaveformPinReason) synth.pinHotMic(reason: tapeMicPinReason) - tape.record(micAlreadyInMix: inputMonitoringEnabled, mix: currentMixSnapshot()) + tape.record(micAlreadyInMix: inputMonitoringEnabled, mix: currentMixSnapshot(), performanceState: synth.performanceSnapshot()) + tape.captureSamples(synth.performanceSamples()) FocusCueBeep.shared.recordStart() } @@ -2209,11 +2210,12 @@ final class MenuBandController { func startSynthOnlyRecording() { guard tape.state != .recording else { return } synth.addWaveformTapPin(tapeWaveformPinReason) - tape.record() + tape.record(mix: currentMixSnapshot(), performanceState: synth.performanceSnapshot()) + tape.captureSamples(synth.performanceSamples()) } /// Instant transport stop — just flips the tape state. The heavy WAV - /// render happens later in `saveStoppedTakeToDesktop()` off the main + /// render happens later on the serial export queue off the main /// thread, so Escape feels immediate. func stopTapeNow() { if tape.state == .recording { tape.stop() } @@ -2229,31 +2231,6 @@ final class MenuBandController { } } -#if !MAC_APP_STORE - /// Render the stopped take and wrap it into a per-take **DMG "record - /// release"** on the Desktop: our logo as the volume icon, the take's WAV - /// (with its generative album-art icon) inside, and that album art on the - /// .dmg file itself. HEAVY (audio conversion + drawing + hdiutil) — call - /// OFF the main thread. Returns the .dmg URL, or nil if empty. - @discardableResult - func saveStoppedTakeToDesktop() -> URL? { - guard let take = tape.eject() else { return nil } - let src = take.file - // The generative album art eject() made → the artifact icon. Use the - // in-memory image directly; `icon(forFile:)` races the async icon write - // and hands back the stale generic .wav type icon (no cover art at all). - let cover = take.cover ?? NSWorkspace.shared.icon(forFile: src.path) - let name = src.deletingPathExtension().lastPathComponent - // The sidecar .mid (editable notes for Ableton) rides inside the DMG. - let extras = [take.midi].compactMap { $0 } - // DMG only — the .mbtape (zip) alternative is dropped. `buildMbtape` - // stays in TakeDMG if we ever want to flag it back on. - let dmg = TakeDMG.build(wav: src, extras: extras, name: name, coverIcon: cover) - if dmg != nil { ReadyChime.shared.play() } // cool "release" chime when the artifact lands - return dmg - } -#endif - /// State-change observer. Drops the pins whenever the tape goes /// back to idle so the synth engine can suspend on inactivity. private func handleTapeChange() { @@ -2322,7 +2299,14 @@ final class MenuBandController { } // Capture the note performance as MIDI alongside the audio, so a take // carries editable notes (a .mid drops onto an Ableton MIDI track). + synth.fluodVoice.onPerformanceEvent = { [weak self] action, arguments in + self?.tape.performance.record(action, arguments) + } + synth.onPerformanceEvent = { [weak self] action, arguments in + self?.tape.performance.record(action, arguments) + } synth.onNoteEvent = { [weak self] note, vel, on, ch, pan in + if on, let self, self.tape.state == .recording { self.tape.captureSamples(self.synth.performanceSamples()) } self?.tape.ingestNote(note, velocity: vel, on: on, channel: ch, pan: pan) if on { self?.mixAnalysis.mark("melody") } } diff --git a/slab/menuband/Sources/MenuBand/MenuBandFluoddityVoice.swift b/slab/menuband/Sources/MenuBand/MenuBandFluoddityVoice.swift index 54acceda84..6a68b3a8f9 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandFluoddityVoice.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandFluoddityVoice.swift @@ -256,12 +256,25 @@ final class MenuBandFluoddityVoice { return out } + var onPerformanceEvent: ((String, [String: Any]) -> Void)? + func performanceState() -> [String: Any] { + lock.lock(); defer { lock.unlock() } + var flat = [Float](repeating: 0, count: 80) + flat.withUnsafeMutableBufferPointer { fluod_rule_to_floats(&genome, $0.baseAddress!) } + return ["rule": flat, "seedCounter": seedCounter] + } + func noteOn(_ midi: UInt8, velocity: UInt8, channel: UInt8) { lock.lock() seedCounter = seedCounter &* 1_664_525 &+ 1_013_904_223 pending.append(.noteOn(midi: midi, channel: channel, velocity: velocity, rule: genome, seed: seedCounter)) + let seed = seedCounter + var flat = [Float](repeating: 0, count: 80) + flat.withUnsafeMutableBufferPointer { fluod_rule_to_floats(&genome, $0.baseAddress!) } lock.unlock() + onPerformanceEvent?("fluoddity.trigger", ["note": Int(midi), "velocity": Int(velocity), + "channel": Int(channel), "seed": seed, "rule": flat]) } func noteOff(_ midi: UInt8, channel: UInt8) { diff --git a/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift b/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift index bd01be0db0..9bee4594f0 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift @@ -61,6 +61,19 @@ final class MenuBandSampleVoice { private var recordedBuffer: AVAudioPCMBuffer? private let bufferLock = NSLock() + /// Published buffers are immutable; retaining them is cheap and safe while + /// a later sample capture replaces the live bank. Writing happens off-main. + func performanceSamples() -> [(String, AVAudioPCMBuffer, Double)] { + bufferLock.lock(); defer { bufferLock.unlock() } + var result: [(String, AVAudioPCMBuffer, Double)] = [] + if let recordedBuffer { result.append(("global", recordedBuffer, detectedFundamental)) } + for key in perKeyBuffers.keys.sorted() { + let anchor = perKeyAnchorMidi[key] ?? key + result.append(("key-\(key)", perKeyBuffers[key]!, 440 * pow(2, (Double(anchor) - 69) / 12))) + } + return result + } + /// Auto-detected fundamental pitch (Hz) of the current recording. Used /// as the chromatic playback reference so each key sounds at its true /// note frequency rather than assuming the sample was middle C. Falls diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift index afe0228f31..9973ea39fa 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -420,6 +420,8 @@ final class MenuBandSynth { /// (via notification). Silences any in-flight GM voices on disable so a /// held note doesn't hang on the now-bypassed node. func setUseACMIDI(_ on: Bool) { + capturePerformance("setUseACMIDI", ["enabled": on], stateful: true) + useACMIDI = on if gmSynthEnabled, !on { gmSynth.panic() } } @@ -430,6 +432,8 @@ final class MenuBandSynth { private(set) var usingFluoddityVoice = false func setFluoddityVoice(_ on: Bool) { + capturePerformance("setFluoddityVoice", ["enabled": on], stateful: true) + usingFluoddityVoice = on if !on { fluodVoice.panic() } } @@ -464,6 +468,8 @@ final class MenuBandSynth { } func setInputMonitoringEnabled(_ enabled: Bool) { + capturePerformance("setInputMonitoringEnabled", ["enabled": enabled], stateful: true) + inputMonitoringWanted = enabled let needsAttach = enabled && !inputMonitor.isAttached && duplexMonitorEligible() // Never detach on a plain toggle: closing and reopening the mic @@ -1225,6 +1231,8 @@ final class MenuBandSynth { /// convex curve preserves a precise dry area around center, then lets the /// room bloom as the finger travels outward. func setSpace(_ amount: Float) { + capturePerformance("setSpace", ["amount": amount], stateful: true) + let p = max(0, min(1, amount)) guard abs(p - spaceAmount) >= 0.0005 else { return } spaceAmount = p @@ -1235,6 +1243,8 @@ final class MenuBandSynth { /// only the rampable send gain moves, eliminating zipper pops while /// retaining an immediate response to the trackpad. func setEcho(_ amount: Float) { + capturePerformance("setEcho", ["amount": amount], stateful: true) + let a = max(0, min(1, amount)) guard abs(a - echoAmount) >= 0.0005 else { return } echoAmount = a @@ -1260,16 +1270,22 @@ final class MenuBandSynth { /// Speak a short phrase through the fx bus (About-window language /// easter egg). The voice rides whatever bend/space/echo is engaged. func speak(_ text: String, languageCode: String) { + capturePerformance("speak", ["text": text, "languageCode": languageCode]) + speechVoice.say(text, languageCode: languageCode) } /// Speak one key-cap letter without replacing the active instrument. func speakLetter(_ letter: String) { + capturePerformance("speakLetter", ["letter": letter]) + speechVoice.spell(letter) } /// Debounced number-row feedback from the bundled Jeffrey voice bank. func playSpokenNumber(_ number: Int) { + capturePerformance("playSpokenNumber", ["number": number]) + speechVoice.playNumber(number) } @@ -1279,6 +1295,7 @@ final class MenuBandSynth { /// wasn't enough captured audio to rewind (caller can cue a fallback). @discardableResult func playReverse() -> Bool { + capturePerformance("playReverse", ["preRecordHistoryMayBeRequired": true]) return rewindVoice.playReverse() } @@ -1287,6 +1304,8 @@ final class MenuBandSynth { /// note resets that anchor to the live head /// (see the `resetReverseAnchor` call in `noteOn`). func releaseReverse() { + capturePerformance("releaseReverse", [:]) + rewindVoice.release() } @@ -1304,13 +1323,19 @@ final class MenuBandSynth { /// Route the trackpad pitch-bend / echo onto the spacebar tape playback /// (its own DRY inserts — see MenuBandRewindVoice), so moving the mouse /// while space is held warps the reverse audio just like a live note. - func setRewindBend(amount: Float) { rewindVoice.setBend(amount: amount) } - func setRewindEcho(amount: Float) { rewindVoice.setEcho(amount: amount) } + func setRewindBend(amount: Float) { + capturePerformance("setRewindBend", ["amount": amount], stateful: true) + rewindVoice.setBend(amount: amount) } + func setRewindEcho(amount: Float) { + capturePerformance("setRewindEcho", ["amount": amount], stateful: true) + rewindVoice.setEcho(amount: amount) } /// Play one random drum hit — the About-window card-flip easter egg /// pairs this with the sparkle chord so repeated flips build a little /// beat. Uses the live percussion kit (already attached to the bus). func playEasterEggDrum() { + capturePerformance("playEasterEggDrum", [:]) + let kit: [MenuBandPercussion.Drum] = [.kick, .snare, .hatClosed, .clap, .tambo, .block, .cowbell, .snap, .ride] let drum = kit.randomElement() ?? .kick @@ -1322,6 +1347,8 @@ final class MenuBandSynth { /// down → left) so a fast swipe reads as a little rising/falling clave run. /// Dry (percussion bypasses the fx bus) so it stays crisp under any bend. func playOctaveTick(up: Bool) { + capturePerformance("playOctaveTick", ["up": up]) + percussion.play(.block, velocity: 88, pan: up ? 0.28 : -0.28) } @@ -1329,6 +1356,8 @@ final class MenuBandSynth { /// voice (AVAudioUnitTimePitch — ignores MIDI bend), so the TTS slides /// in pitch alongside every other voice. func setSpeechPitchBend(amount: Float) { + capturePerformance("setSpeechPitchBend", ["amount": amount], stateful: true) + speechVoice.setBend(amount: amount) singerVoice.setBend(amount: amount) simSingers.values.forEach { $0.setBend(amount: amount) } @@ -1337,21 +1366,29 @@ final class MenuBandSynth { /// Keep the surface cues speaking at the sounding pitch — fed from the /// controller's single bend funnel alongside the other bus voices. func setSurfaceCueBend(amount: Float) { + capturePerformance("setSurfaceCueBend", ["amount": amount], stateful: true) + surfaceCue.setBend(amount: amount) airVoice.setBend(amount: amount) } /// ⇧Space down: woosh air in around these pitches. func pushAir(pitches: [UInt8]) { + capturePerformance("pushAir", ["pitches": pitches.map { Int($0) }]) + airVoice.push(pitches: pitches) } /// ⇧Space up: let the air drift out. func releaseAir() { + capturePerformance("releaseAir", [:]) + airVoice.release() } func stopAir() { + capturePerformance("stopAir", [:]) + airVoice.stop() } @@ -1364,10 +1401,14 @@ final class MenuBandSynth { /// One movement's worth of idle-page friction, 0…1. func rubSurface(intensity: Float) { + capturePerformance("rubSurface", ["intensity": intensity]) + surfaceCue.rub(intensity: intensity) } func stopSurfaceRub() { + capturePerformance("stopSurfaceRub", [:]) + surfaceCue.stopRub() } @@ -2235,6 +2276,8 @@ final class MenuBandSynth { /// so every backend scales together. Idempotent — safe to call /// before or after `start()`. Clamped to [0, 1]. func setMasterVolume(_ value: Float) { + capturePerformance("setMasterVolume", ["value": value], stateful: true) + let clamped = max(0, min(1, value)) masterVolume = clamped // outputVolume is realtime-safe — AVAudioEngine ramps it to @@ -2247,11 +2290,15 @@ final class MenuBandSynth { /// `percFader`, AFTER the raw stem tap, so the tape's percussion stem /// is unity while the mix follows the slider. func setPercussionVolume(_ value: Float) { + capturePerformance("setPercussionVolume", ["value": value], stateful: true) + percFader.outputVolume = max(0, min(1, value)) } /// Tones trim (every instrument) — same pre-tap/post-fader split. func setTonesVolume(_ value: Float) { + capturePerformance("setTonesVolume", ["value": value], stateful: true) + tonesFader.outputVolume = max(0, min(1, value)) } @@ -2260,6 +2307,8 @@ final class MenuBandSynth { /// back. The graph stays wired and the tape keeps its dry stem. private var monitorFocusMuted = false // audible at launch; Esc (defocus) mutes, focus unmutes func setMonitorFocusMute(_ muted: Bool) { + capturePerformance("setMonitorFocusMute", ["muted": muted], stateful: true) + monitorFocusMuted = muted inputMonitor.setEnabled(inputMonitoringWanted && !muted) NSLog("MenuBand monitor: focus \(muted ? "lost → muted" : "gained → audible") (wanted=\(inputMonitoringWanted) attached=\(inputMonitor.isAttached))") @@ -2268,6 +2317,8 @@ final class MenuBandSynth { /// Mic monitor gain, 0…2 (unity = 1). Only what the duplex monitor /// feeds into the mix; the tape's voice stem stays the dry input. func setMonitorGain(_ value: Float) { + capturePerformance("setMonitorGain", ["value": value], stateful: true) + inputMonitor.setGain(max(0, min(2, value))) } @@ -2326,6 +2377,8 @@ final class MenuBandSynth { /// (sub-millisecond MIDI Program Change); blocks ~100 ms otherwise /// (sampler bank reload). func setMelodicProgram(_ program: UInt8) { + capturePerformance("setMelodicProgram", ["program": Int(program)], stateful: true) + currentMelodicProgram = program // Keep the AC GM node's program in sync so a note-on routed there // synthesizes the selected instrument. (Sounding voices keep their @@ -2376,6 +2429,8 @@ final class MenuBandSynth { /// set) so we don't bother with a delayed-playback dance. @discardableResult func setGarageBandPatch(at url: URL) -> Bool { + capturePerformance("setGarageBandPatch", ["assetName": url.lastPathComponent, "externalAssetRequired": true], stateful: true) + guard started else { return false } do { try melodic.loadInstrument(at: url) @@ -2413,6 +2468,8 @@ final class MenuBandSynth { } func stopCDJInternetRadio() { + capturePerformance("stopCDJInternetRadio", [:]) + radio.setOutputEnabled(false) radio.stopStreaming() } @@ -2427,8 +2484,12 @@ final class MenuBandSynth { spotifyDeck.start(processID: processID) } - func stopCDJSpotify() { spotifyDeck.stop() } - func silenceCDJSpotify() { spotifyDeck.setOutputEnabled(false) } + func stopCDJSpotify() { + capturePerformance("stopCDJSpotify", [:]) + spotifyDeck.stop() } + func silenceCDJSpotify() { + capturePerformance("silenceCDJSpotify", [:]) + spotifyDeck.setOutputEnabled(false) } /// Lift the live deck off the bus and put the gesture grain voice on its /// rolling audio. Returns false until enough current-CD audio exists. @@ -2452,6 +2513,8 @@ final class MenuBandSynth { } func scratchCDJ(deltaPoints: Double, velocity: Double) { + capturePerformance("scratchCDJ", ["deltaPoints": deltaPoints, "velocity": velocity]) + cdjScratch.scrub(deltaPoints: deltaPoints, velocity: velocity) } @@ -2526,6 +2589,8 @@ final class MenuBandSynth { /// Compatibility shim for older controller builds. Radio is now a /// continuous CDJ deck and never becomes the melodic key backend. func setRadioBackend(_ enabled: Bool) { + capturePerformance("setRadioBackend", ["enabled": enabled], stateful: true) + if enabled { if started { _ = resumeAudioEngineIfNeeded() @@ -2553,6 +2618,8 @@ final class MenuBandSynth { /// Mutually exclusive with radio/sample/GB — picking a plugin exits /// those, identical to `setMelodicProgram`'s mutex semantics. func setPluginInstrument(_ avUnit: AVAudioUnit?) { + capturePerformance("setPluginInstrument", ["name": avUnit?.name ?? "none", "externalAssetRequired": avUnit != nil], stateful: true) + // Tear down any previously-loaded plugin first. if let prev = pluginUnit { if pluginConnected { @@ -2615,7 +2682,9 @@ final class MenuBandSynth { } /// Clear all per-key custom samples (the ` "Home" gesture). - func clearPerKeySamples() { sampleVoice.clearPerKeySamples() } + func clearPerKeySamples() { + capturePerformance("clearPerKeySamples", [:]) + sampleVoice.clearPerKeySamples() } /// Stop recording. Returns true iff a usable buffer (≥100 ms) was /// captured; the caller flips the active backend to `.sample` only @@ -2675,6 +2744,8 @@ final class MenuBandSynth { /// MIDISynth / sampler notes so we don't double-trigger; disabling /// closes the master gate immediately. func setSampleBackend(_ enabled: Bool) { + capturePerformance("setSampleBackend", ["enabled": enabled], stateful: true) + if enabled { usingSampleBackend = true usingGarageBandPatch = false @@ -2751,6 +2822,8 @@ final class MenuBandSynth { /// no-op the sampler fallback rather than yanking the entire /// melodic mix. func setPan(_ pan: UInt8, channel: UInt8 = 0) { + capturePerformance("setPan", ["pan": Int(pan), "channel": Int(channel)], stateful: true) + // Mirror the pan into per-channel state regardless of the audible path, // so the tape's note hook can stamp CC10 onto captured MIDI note-ons. channelPan[Int(channel & 0x0F)] = pan & 0x7F @@ -2763,6 +2836,8 @@ final class MenuBandSynth { /// keyboard uses (64 = center); converted to the −1…1 the percussion /// node wants. func playPercussion(_ drum: MenuBandPercussion.Drum, velocity: UInt8, pan: UInt8) { + capturePerformance("playPercussion", ["drum": drum.rawValue, "velocity": Int(velocity), "pan": Int(pan)]) + guard started else { return } // Drums don't register in `activeNotes`, so the idle-suspend logic // would otherwise leave the engine paused and the hit silent. Wake @@ -2793,6 +2868,8 @@ final class MenuBandSynth { /// A rising, suction-like bass-drum envelope for the trackpad's /// two-finger upper-half gesture. func playReverseKick(velocity: UInt8, pan: UInt8) { + capturePerformance("playReverseKick", ["velocity": Int(velocity), "pan": Int(pan)]) + guard started else { return } _ = resumeAudioEngineIfNeeded() rewindVoice.resetReverseAnchor() @@ -2803,6 +2880,8 @@ final class MenuBandSynth { } func playDrumSkin(strike: CGPoint, anchors: [CGPoint], velocity: UInt8) { + capturePerformance("playDrumSkin", ["point": [strike.x, strike.y], "anchors": anchors.map { [$0.x, $0.y] }, "velocity": Int(velocity)]) + guard started else { return } _ = resumeAudioEngineIfNeeded() rewindVoice.resetReverseAnchor() @@ -2816,6 +2895,8 @@ final class MenuBandSynth { } func playSynthSurface(strike: CGPoint, anchors: [CGPoint], velocity: UInt8) { + capturePerformance("playSynthSurface", ["point": [strike.x, strike.y], "anchors": anchors.map { [$0.x, $0.y] }, "velocity": Int(velocity)]) + guard started else { return } _ = resumeAudioEngineIfNeeded() rewindVoice.resetReverseAnchor() @@ -2828,6 +2909,8 @@ final class MenuBandSynth { func playSurfaceLift(at point: CGPoint, anchors: [CGPoint], velocity: UInt8, synthetic: Bool) { + capturePerformance("playSurfaceLift", ["point": [point.x, point.y], "anchors": anchors.map { [$0.x, $0.y] }, "velocity": Int(velocity), "synthetic": synthetic]) + guard started else { return } _ = resumeAudioEngineIfNeeded() rewindVoice.resetReverseAnchor() @@ -2842,6 +2925,8 @@ final class MenuBandSynth { direction: CGVector = .zero, surfaceEnergy: Double = 0, synthetic: Bool = false) { + capturePerformance("setDrumSkinScratch", ["point": [point.x, point.y], "speed": speed, "anchors": anchors.map { [$0.x, $0.y] }, "direction": [direction.dx, direction.dy], "energy": surfaceEnergy, "synthetic": synthetic]) + guard started else { return } percussion.setDrumSkinScratch(at: point, speed: speed, anchors: anchors, @@ -2851,6 +2936,8 @@ final class MenuBandSynth { } func stopDrumSkinScratch() { + capturePerformance("stopDrumSkinScratch", [:]) + percussion.stopDrumSkinScratch() } @@ -2875,6 +2962,7 @@ final class MenuBandSynth { triggerMelodicDuck(depth: depth, velocity: velocity, hold: drum == .kick ? 0.105 : 0.055) let g = percussion.noteOn(drum, velocity: velocity, pan: p, accent: accent) + capturePerformance("percussionNoteOn", ["drum": drum.rawValue, "velocity": Int(velocity), "pan": Int(pan), "accent": accent, "group": String(g)]) // Roomward: a TrackDrum hit flashes its own pars (no-op without a // widget) — routed apart from the keys, see DMXOut.fixtures. DMXOut.shared.drum(drum, velocity: Int(velocity)) @@ -2903,6 +2991,8 @@ final class MenuBandSynth { /// Key-up for a split drum — damps held voices (open hat) and fires the /// release burst (closed hat). Harmless for one-shot drums. func percussionNoteOff(_ group: UInt64) { + capturePerformance("percussionNoteOff", ["group": String(group)]) + guard started, group != 0 else { return } percussion.noteOff(group) if !keepEngineWarm { scheduleIdleSuspendAfterPercussion() } @@ -2929,6 +3019,33 @@ final class MenuBandSynth { /// Fires on every note-on/off (note, velocity, on, channel, pan) — the tape /// subscribes to capture a MIDI performance (incl. CC10 pan) alongside the /// audio. `pan` is the channel's current CC10 value (64 = center). + var onPerformanceEvent: ((String, [String: Any]) -> Void)? + private let performanceLock = NSLock() + private var performanceControls: [String: [String: Any]] = [:] + + /// State and semantic commands, never raw keystrokes or device identifiers. + private func capturePerformance(_ action: String, _ arguments: [String: Any] = [:], stateful: Bool = false) { + if stateful { + let key = action + ":" + String(describing: arguments["channel"] ?? "global") + + ":" + String(describing: arguments["controller"] ?? "") + performanceLock.lock(); performanceControls[key] = arguments; performanceLock.unlock() + } + onPerformanceEvent?(action, arguments) + } + + func performanceSamples() -> [(String, AVAudioPCMBuffer, Double)] { sampleVoice.performanceSamples() } + + func performanceSnapshot() -> [String: Any] { + performanceLock.lock(); let controls = performanceControls; performanceLock.unlock() + return ["program": Int(currentMelodicProgram), "useACMIDI": useACMIDI, + "sampleBackend": usingSampleBackend, "radioBackend": usingRadioBackend, + "fluoddityBackend": usingFluoddityVoice, "garageBandPatch": usingGarageBandPatch, + "pan": channelPan.map { Int($0) }, "controls": controls, + "echo": echoAmount, "space": spaceAmount, "master": masterVolume, + "fluoddity": fluodVoice.performanceState(), + "appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "development"] + } + var onNoteEvent: ((UInt8, UInt8, Bool, UInt8, UInt8) -> Void)? /// Per-channel CC10 pan (0–127, 64 = center), mirrored from `setPan` so the @@ -3018,6 +3135,8 @@ final class MenuBandSynth { /// pitch-bend natively; the AVAudioUnitSampler fallback is a /// no-op (and the trackpad gesture caller checks midiMode). func sendPitchBend(value: Int16, channel: UInt8 = 0) { + capturePerformance("sendPitchBend", ["value": Int(value), "channel": Int(channel)], stateful: true) + guard started else { return } let v = max(-8192, min(8191, Int(value))) + 8192 // 0…16383 let lsb = UInt8(v & 0x7F) @@ -3037,6 +3156,8 @@ final class MenuBandSynth { /// implemented in MenuBandController so it also works for sample/radio/GM /// backends that do not consume CC64 themselves. func sendControlChange(_ controller: UInt8, value: UInt8, channel: UInt8 = 0) { + capturePerformance("sendControlChange", ["controller": Int(controller), "value": Int(value), "channel": Int(channel)], stateful: true) + guard started else { return } let status: UInt8 = 0xB0 | (channel & 0x0F) if usingPluginInstrument && channel != 9, let au = pluginUnit?.audioUnit { @@ -3057,6 +3178,8 @@ final class MenuBandSynth { /// `.pitch` (cents). AVAudio's time-pitch doesn't respond to MIDI /// pitch-bend so we have to route this signal in-process. func setSamplePitchBend(amount: Float) { + capturePerformance("setSamplePitchBend", ["amount": amount], stateful: true) + sampleVoice.setBend(amount: amount) } @@ -3065,6 +3188,8 @@ final class MenuBandSynth { /// sample, so (like the varispeed backends) it doesn't see MIDI /// pitch-bend — we route the signed amount in-process instead. func setGMPitchBend(amount: Float) { + capturePerformance("setGMPitchBend", ["amount": amount], stateful: true) + if gmSynthEnabled { gmSynth.setPitchBend(amount: amount) } // The Fluoddity scanner re-derives phase from the live per-sample // frequency exactly like gm_synth, so it rides the same in-process @@ -3077,6 +3202,8 @@ final class MenuBandSynth { /// ignores MIDI pitch-bend, so the signed amount is routed in-process /// — the live stream slides in pitch alongside every other voice. func setRadioPitchBend(amount: Float) { + capturePerformance("setRadioPitchBend", ["amount": amount], stateful: true) + radio.setBend(amount: amount) spotifyDeck.setPitch(semitones: amount * 12) } @@ -3096,6 +3223,8 @@ final class MenuBandSynth { } func setMonitorPitchBend(amount: Float) { + capturePerformance("setMonitorPitchBend", ["amount": amount], stateful: true) + inputMonitor.setPitchBend(amount: amount) let now = ProcessInfo.processInfo.systemUptime if now - lastMonitorBendLog > 0.5 { @@ -3109,6 +3238,8 @@ final class MenuBandSynth { /// audibly over the tail instead of holding at full volume and /// then snapping silent at the cleanup noteOff. func sendExpression(value: UInt8, channel: UInt8 = 0) { + capturePerformance("sendExpression", ["value": Int(value), "channel": Int(channel)], stateful: true) + guard started, midiSynthReady, let au = midiSynth?.audioUnit else { return } sendMIDIEvent(au, status: 0xB0 | (channel & 0x0F), data1: 11, data2: value & 0x7F) @@ -3118,12 +3249,16 @@ final class MenuBandSynth { /// linger fade — CC 11 doesn't reach AVAudioPlayerNode, so we /// ramp the per-note player volume directly. func setSampleNoteVolume(midi: UInt8, channel: UInt8, value: UInt8) { + capturePerformance("setSampleNoteVolume", ["note": Int(midi), "channel": Int(channel), "value": Int(value)]) + sampleVoice.setNoteVolume(midi: midi, channel: channel, value: value) } /// Snap every sample voice on `channel` back to full volume so a /// new noteOn isn't dragged under by a leftover linger fade. func resetSampleChannelVolumes(channel: UInt8) { + capturePerformance("resetSampleChannelVolumes", ["channel": Int(channel)]) + sampleVoice.resetChannelVolumes(channel: channel) } @@ -3175,6 +3310,8 @@ final class MenuBandSynth { } func panic() { + capturePerformance("panic", [:]) + guard started else { return } DMXOut.shared.blackout() activeNotes.removeAll() diff --git a/slab/menuband/Sources/MenuBand/MenuBandTape.swift b/slab/menuband/Sources/MenuBand/MenuBandTape.swift index e1862ddfbd..cd4c1369cd 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandTape.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandTape.swift @@ -220,6 +220,10 @@ final class MenuBandTape { recordStartDate = source.recordStartDate takeID = source.takeID source.midiLock.lock() + source.sampleLock.lock() + sampleAssets = source.sampleAssets + source.sampleLock.unlock() + frozenPerformance = source.performance.snapshot() midiEvents = source.midiEvents source.midiLock.unlock() isExportSnapshot = true @@ -262,7 +266,7 @@ final class MenuBandTape { // MARK: - Transport - func record(micAlreadyInMix: Bool = false, mix: [String: Float] = [:]) { + func record(micAlreadyInMix: Bool = false, mix: [String: Float] = [:], performanceState: [String: Any] = [:]) { switch state { case .recording: return case .playing, .paused: stop() @@ -312,6 +316,11 @@ final class MenuBandTape { midiLock.lock() midiEvents.removeAll() midiStart = ProcessInfo.processInfo.systemUptime + performance.begin(state: performanceState, at: midiStart) + frozenPerformance = nil + sampleLock.lock() + sampleAssets = [:]; sampleAssetIDs = [:]; sampleBindings = [:] + sampleLock.unlock() midiLock.unlock() state = .recording } @@ -319,6 +328,7 @@ final class MenuBandTape { func stop() { switch state { case .recording: + performance.end() state = .idle postChange() case .playing, .paused: @@ -416,6 +426,13 @@ final class MenuBandTape { // MIDI performance capture — note events (relative to record start) so a // take carries editable notes for Ableton alongside the rendered audio. + private let sampleLock = NSLock() + private var sampleAssets: [String: AVAudioPCMBuffer] = [:] + private var sampleAssetIDs: [ObjectIdentifier: String] = [:] + private var sampleBindings: [String: String] = [:] + let performance = MBTapeJournal() + private var frozenPerformance: [String: Any]? + private var audioOffsetSeconds: Double = 0 private var midiEvents: [MidiFile.Event] = [] private var midiStart: TimeInterval = 0 private var midiLock = NSLock() @@ -427,6 +444,7 @@ final class MenuBandTape { bufferLock.lock() let t = Double(synthWriteFrame) / Self.sampleRate mixEvents.append(MixEvent(time: t, param: param, value: value)) + performance.record("mix", ["parameter": param, "value": value]) bufferLock.unlock() } @@ -434,13 +452,59 @@ final class MenuBandTape { /// note-on/off. Records only while the tape is rolling. func ingestNote(_ note: UInt8, velocity: UInt8, on: Bool, channel: UInt8, pan: UInt8) { guard state == .recording else { return } - let t = ProcessInfo.processInfo.systemUptime - midiStart + let now = ProcessInfo.processInfo.systemUptime + let t = now - midiStart - Double(Self.leadTrimFrames) / Self.sampleRate + performance.record(on ? "noteOn" : "noteOff", + ["note": Int(note), "velocity": Int(velocity), "channel": Int(channel), "pan": Int(pan)], at: now) midiLock.lock() midiEvents.append(MidiFile.Event(time: t, note: note, velocity: velocity, on: on, channel: channel, pan: pan)) midiLock.unlock() } + func captureSamples(_ samples: [(String, AVAudioPCMBuffer, Double)]) { + guard state == .recording else { return } + sampleLock.lock(); defer { sampleLock.unlock() } + var bindings: [String: String] = [:] + var entries: [[String: Any]] = [] + for (slot, buffer, fundamental) in samples { + let identity = ObjectIdentifier(buffer) + let name = sampleAssetIDs[identity] ?? "sample-\(UUID().uuidString).wav" + sampleAssetIDs[identity] = name; sampleAssets[name] = buffer + bindings[slot] = name + entries.append(["slot": slot, "file": "assets/" + name, "fundamentalHz": fundamental]) + } + if bindings != sampleBindings { + performance.record("sampleBank", ["samples": entries]) + sampleBindings = bindings + } + } + + func package(preview: URL?) throws -> URL? { + guard let take = eject(), let stems = take.stems else { return nil } + let fm = FileManager.default + let library = (fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fm.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")) + .appendingPathComponent("MenuBand/Tapes/\(takeID.uuidString)", isDirectory: true) + try fm.createDirectory(at: library, withIntermediateDirectories: true) + let url = library.appendingPathComponent(take.file.deletingPathExtension().lastPathComponent + ".mbtape") + if FileManager.default.fileExists(atPath: url.path) { return url } + let assetsDir = take.file.deletingLastPathComponent().appendingPathComponent("mbassets-\(takeID)") + try FileManager.default.createDirectory(at: assetsDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: assetsDir) } + var assets: [URL] = [] + for (name, buffer) in sampleAssets { + let path = assetsDir.appendingPathComponent(name) + let file = try AVAudioFile(forWriting: path, settings: buffer.format.settings) + try file.write(from: buffer) + assets.append(path) + } + return try MBTape.package(master: take.file, stems: stems, midi: take.midi, + preview: preview, journal: frozenPerformance ?? performance.snapshot(), + id: takeID, date: take.date, audioOffset: audioOffsetSeconds, + duration: take.duration, destination: url, assets: assets) + } + /// Render the recording into a single 4-channel WAV: /// /// Channel 1 — synth L @@ -538,6 +602,7 @@ final class MenuBandTape { NSLog("MenuBandTape: eject raw=\(rawFrames) offset=\(offset) end=\(endFrame) frames=\(frames)") guard frames > 0 else { return nil } let duration = Double(frames) / Self.sampleRate + audioOffsetSeconds = Double(offset + Self.leadTrimFrames) / Self.sampleRate let date = recordStartDate // No mastering on the way out: the take leaves at the level it was // heard at (the limiter already holds it under 0 dBFS). The old @@ -694,7 +759,7 @@ final class MenuBandTape { pan: $0.pan) } let mURL = url.deletingPathExtension().appendingPathExtension("mid") - if (try? MidiFile.data(events: shifted, trackName: baseName).write(to: mURL)) != nil { + if (try? MidiFile.data(events: shifted, trackName: baseName, normalizeStart: false).write(to: mURL)) != nil { midiURL = mURL } } @@ -836,45 +901,25 @@ final class MenuBandTape { catch { NSLog("MenuBandTape: stems dir failed: \(error)"); return nil } func writeWav(_ name: String, channels: [UnsafePointer]) -> Bool { - let url = dir.appendingPathComponent(name) - guard let out = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: Self.sampleRate, - channels: AVAudioChannelCount(channels.count), interleaved: true), - let flt = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: Self.sampleRate, - channels: AVAudioChannelCount(channels.count), interleaved: false), - let converter = AVAudioConverter(from: flt, to: out) else { return false } - var file: AVAudioFile? + guard let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: Self.sampleRate, + channels: AVAudioChannelCount(channels.count), interleaved: false) else { return false } do { - file = try AVAudioFile(forWriting: url, settings: out.settings, - commonFormat: .pcmFormatInt16, interleaved: true) - } catch { NSLog("MenuBandTape: stem \(name) open failed: \(error)"); return false } - let chunk = 8192 - var cursor = 0 - while cursor < frames { - let take = min(chunk, frames - cursor) - guard let src = AVAudioPCMBuffer(pcmFormat: flt, frameCapacity: AVAudioFrameCount(take)), - let dst = AVAudioPCMBuffer(pcmFormat: out, frameCapacity: AVAudioFrameCount(take)) - else { return false } - src.frameLength = AVAudioFrameCount(take) - bufferLock.lock() - for (i, ch) in channels.enumerated() { - if let d = src.floatChannelData?[i] { - memcpy(d, ch.advanced(by: offset + cursor), take * MemoryLayout.size) + let file = try AVAudioFile(forWriting: dir.appendingPathComponent(name), settings: format.settings) + var cursor = 0 + while cursor < frames { + let count = min(8192, frames - cursor) + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(count)) else { return false } + buffer.frameLength = AVAudioFrameCount(count) + bufferLock.lock() + for (i, source) in channels.enumerated() { + memcpy(buffer.floatChannelData![i], source.advanced(by: offset + cursor), count * MemoryLayout.size) } + bufferLock.unlock() + try file.write(from: buffer) + cursor += count } - bufferLock.unlock() - var supplied = false - var error: NSError? - let status = converter.convert(to: dst, error: &error) { _, outStatus in - if supplied { outStatus.pointee = .endOfStream; return nil } - supplied = true; outStatus.pointee = .haveData - return src - } - if status == .error { return false } - do { try file?.write(from: dst) } catch { return false } - cursor += take - } - file = nil - return true + return true + } catch { NSLog("MenuBandTape: stem write failed: \(error)"); return false } } guard let tl = tonesStem.buffer.floatChannelData?[0], let tr = tonesStem.buffer.floatChannelData?[1], diff --git a/slab/menuband/Sources/MenuBand/MidiFile.swift b/slab/menuband/Sources/MenuBand/MidiFile.swift index 90c131732b..b5682b844a 100644 --- a/slab/menuband/Sources/MenuBand/MidiFile.swift +++ b/slab/menuband/Sources/MenuBand/MidiFile.swift @@ -17,11 +17,12 @@ enum MidiFile { private static let ppq = 480 private static let ticksPerSecond = Double(ppq) * 2.0 // 120 BPM → 0.5 s/qn - /// Build the `.mid` bytes. Times are normalized so the first event is 0. + /// Build the `.mid` bytes. Standalone scores normalize their first event; + /// tape exports pass normalizeStart=false to retain audio alignment. /// `trackName` (if given) is written as an FF 03 track-name meta event so /// the take is labeled on import. Each note-on emits a CC10 (pan) first when /// its channel's pan changed, reproducing Menu Band's keyboard-position pan. - static func data(events: [Event], trackName: String? = nil) -> Data { + static func data(events: [Event], trackName: String? = nil, normalizeStart: Bool = true) -> Data { var track = Data() // Tempo meta (500000 µs/quarter = 120 BPM) at delta 0. @@ -36,7 +37,7 @@ enum MidiFile { } let sorted = events.sorted { $0.time < $1.time } - let base = sorted.first?.time ?? 0 + let base = normalizeStart ? (sorted.first?.time ?? 0) : 0 var lastTick = 0 var lastPan = [UInt8](repeating: 255, count: 16) // force first CC10 per channel for e in sorted { diff --git a/slab/menuband/Sources/MenuBand/TakeDMG.swift b/slab/menuband/Sources/MenuBand/TakeDMG.swift index 1f4676ab57..223d1bc6a2 100644 --- a/slab/menuband/Sources/MenuBand/TakeDMG.swift +++ b/slab/menuband/Sources/MenuBand/TakeDMG.swift @@ -78,26 +78,6 @@ enum TakeDMG { } } - /// Instant alternative to the DMG: a `.mbtape` — a stored (uncompressed) - /// zip of the take's files, written in milliseconds (no filesystem image, - /// no mount). Drags into Messages the same way; just not mountable. The - /// album-art cover goes on the .mbtape file. - @discardableResult - static func buildMbtape(files: [URL], name: String, coverIcon: NSImage?) -> URL? { - let fm = FileManager.default - let desktop = fm.urls(for: .desktopDirectory, in: .userDomainMask).first - ?? fm.homeDirectoryForCurrentUser.appendingPathComponent("Desktop") - let out = uniqueURL(desktop.appendingPathComponent("\(name).mbtape")) - try? fm.removeItem(at: out) - // zip -0 store (audio won't compress anyway), -j flat, -q quiet. - guard run("/usr/bin/zip", ["-0", "-j", "-q", out.path] + files.map { $0.path }) else { return nil } - if let cover = coverIcon { - NSWorkspace.shared.setIcon(cover, forFile: out.path, options: []) - } - NSLog("MenuBand mbtape: \(out.path)") - return out - } - /// Block (off the main thread) until a custom-icon resource fork of at least /// `minBytes` has been written — `NSWorkspace.setIcon` flushes it /// asynchronously, so this prevents moving a file whose icon is half-written. diff --git a/slab/menuband/Tests/MenuBandTests/MBTapeTests.swift b/slab/menuband/Tests/MenuBandTests/MBTapeTests.swift new file mode 100644 index 0000000000..6f08009ce0 --- /dev/null +++ b/slab/menuband/Tests/MenuBandTests/MBTapeTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import MenuBand + +final class MBTapeTests: XCTestCase { + func testSnapshotSurvivesNextRecordingAndExcludesStoppedActions() throws { + let log = MBTapeJournal() + log.begin(state: ["program": 7], at: 100) + log.record("setSpace", ["amount": 0.4], at: 100.25) + log.end(at: 101) + log.record("noteOn", ["note": 60], at: 102) + let first = log.snapshot() + log.begin(state: ["program": 9], at: 200) + XCTAssertEqual((first["events"] as? [[String: Any]])?.count, 2) + XCTAssertEqual((first["initialState"] as? [String: Int])?["program"], 7) + XCTAssertEqual((log.snapshot()["events"] as? [[String: Any]])?.count, 0) + } + + func testScoreKeepsDelayChannelsAndClosesHeldNotes() throws { + let log = MBTapeJournal() + log.begin(state: [:], at: 100) + log.record("noteOn", ["note": 60, "velocity": 83, "channel": 2, "pan": 20], at: 101) + log.record("noteOn", ["note": 60, "velocity": 77, "channel": 3, "pan": 90], at: 101.5) + log.record("noteOff", ["note": 60, "channel": 2], at: 102) + log.end(at: 103) + let score = MBTape.score(journal: log.snapshot(), name: "take", audioOffset: 0.5, duration: 2.5) + let notes = try XCTUnwrap(score["notes"] as? [[String: Any]]) + XCTAssertEqual(notes.count, 2) + XCTAssertEqual(notes[0]["start"] as? Double, 0.5) + XCTAssertEqual(notes[0]["dur"] as? Double, 1) + XCTAssertEqual(notes[1]["channel"] as? Int, 3) + XCTAssertEqual(notes[1]["dur"] as? Double, 1.5) + XCTAssertEqual(notes[1]["velocity"] as? Int, 77) + let data = try JSONSerialization.data(withJSONObject: score) + XCTAssertEqual(try JSONDecoder().decode(MBScore.self, from: data).notes.count, 2) + } + + func testRepeatedPitchAndTrimmedNote() throws { + let log = MBTapeJournal() + log.begin(state: [:], at: 0) + log.record("noteOn", ["note": 64, "velocity": 90, "channel": 0], at: 0.1) + log.record("noteOn", ["note": 64, "velocity": 80, "channel": 0], at: 0.8) + log.record("noteOff", ["note": 64, "channel": 0], at: 1) + log.record("panic", at: 1.2) + let score = MBTape.score(journal: log.snapshot(), name: "take", audioOffset: 0.5, duration: 2) + let notes = try XCTUnwrap(score["notes"] as? [[String: Any]]) + XCTAssertEqual(notes.count, 2) + XCTAssertEqual(notes[0]["start"] as? Double, 0) + XCTAssertEqual(notes[0]["dur"] as? Double, 0.5) + XCTAssertEqual(notes[1]["dur"] as! Double, 0.4, accuracy: 0.00001) + } + + func testArchiveRoundTripAndNoOverwrite() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try fm.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + let master = root.appendingPathComponent("take.wav"), stems = root.appendingPathComponent("stems") + try Data("master fixture".utf8).write(to: master) + try fm.createDirectory(at: stems, withIntermediateDirectories: true) + try MBTape.writeJSON(["files": ["mix": "../take.wav"]], to: stems.appendingPathComponent("mix.json")) + try Data("stem fixture".utf8).write(to: stems.appendingPathComponent("tones.wav")) + let out = root.appendingPathComponent("take.mbtape") + let log = MBTapeJournal(); log.begin(state: [:], at: 0); log.end(at: 1) + func pack() throws -> URL { + try MBTape.package(master: master, stems: stems, midi: nil, preview: nil, + journal: log.snapshot(), id: UUID(), date: Date(), audioOffset: 0, + duration: 1, destination: out) + } + XCTAssertEqual(try pack(), out) + let original = try Data(contentsOf: out) + XCTAssertThrowsError(try pack()) + XCTAssertEqual(try Data(contentsOf: out), original) + let pipe = Pipe(), process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-p", out.path, "manifest.json"] + process.standardOutput = pipe + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile(); process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 0) + let manifest = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(manifest["format"] as? String, "mbtape") + let files = try XCTUnwrap(manifest["files"] as? [[String: Any]]) + XCTAssertTrue(files.contains { $0["path"] as? String == "stems/tones.wav" }) + XCTAssertTrue(files.allSatisfy { ($0["sha256"] as? String)?.count == 64 }) + } +} diff --git a/slab/menuband/Tests/MenuBandTests/TapeExportSnapshotTests.swift b/slab/menuband/Tests/MenuBandTests/TapeExportSnapshotTests.swift index 2914aedaf4..0ab53e5da4 100644 --- a/slab/menuband/Tests/MenuBandTests/TapeExportSnapshotTests.swift +++ b/slab/menuband/Tests/MenuBandTests/TapeExportSnapshotTests.swift @@ -13,9 +13,9 @@ final class TapeExportSnapshotTests: XCTestCase { func testNextRecordingCannotChangeAnExportingTake() throws { let tape = MenuBandTape() - tape.record(micAlreadyInMix: true, mix: ["mic": 0.5]) + tape.record(micAlreadyInMix: true, mix: ["mic": 0.5], performanceState: ["program": 42]) tape.ingestSynth(signal(0.25)) - tape.ingestTones(signal(0.125)) + tape.ingestTones(signal(1.25)) tape.stop() let snapshot = try XCTUnwrap(tape.snapshotForExport()) XCTAssertTrue(snapshot === tape.snapshotForExport()) @@ -45,9 +45,25 @@ final class TapeExportSnapshotTests: XCTestCase { let stems = try XCTUnwrap(take.stems) let mix = try JSONSerialization.jsonObject(with: Data(contentsOf: stems.appendingPathComponent("mix.json"))) as! [String: Any] XCTAssertEqual((mix["start"] as? [String: Double])?["mic"], 0.5) + let toneFile = try AVAudioFile(forReading: stems.appendingPathComponent("tones.wav")) + let toneAudio = AVAudioPCMBuffer(pcmFormat: toneFile.processingFormat, + frameCapacity: AVAudioFrameCount(toneFile.length))! + try toneFile.read(into: toneAudio) + XCTAssertEqual(toneAudio.floatChannelData![0][100], 1.25, accuracy: 0.00001) + let archive = try XCTUnwrap(snapshot.package(preview: nil)) + defer { try? FileManager.default.removeItem(at: archive.deletingLastPathComponent()) } + let process = Process(), pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-p", archive.path, "state.json"] + process.standardOutput = pipe + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile(); process.waitUntilExit() + let state = try JSONSerialization.jsonObject(with: data) as! [String: Any] + XCTAssertEqual(state["program"] as? Int, 42) + } catch { XCTFail("Export failed: \(error)") } } - wait(for: [finished], timeout: 20) + wait(for: [finished], timeout: 30) } func testLiveRecordingCannotBeSharedWithAnExporter() { -- 2.51.2