From 337e77350c2f37789579cb1577eac3297a4ea276 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 7 Jun 2026 19:53:58 -0700 Subject: [PATCH] menuband: fix audio on rate-switch + rewind to notepat semantics + scrolling waveform strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rewind voice: connect with engine-derived (nil) formats and build the reversed clip in the player's actual output format, so a 96 kHz interface or hot device-swap no longer breaks the graph / kills all audio (was the hardcoded 44.1k connection asserting in scheduleBuffer) - rewind behavior now matches native notepat: each Space press reverses the audio SINCE THE LAST PRESS (floored 0.5s, capped 12s); hold-to-reverse with capture resuming on Space RELEASE (not a fixed clip timer) — fixes the laggy / "adding again" second run where new notes weren't being captured - popover: live waveform strip above the instrument name — a scrolling min/max envelope (one column per frame, newest on the right, ~8s history) with forward ▶ / reverse ◀+orange directionality, so spacebar-rewind is debuggable; lightweight Core Graphics, polled ~30fps only while on screen Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/MenuBand/MenuBandController.swift | 22 +++- .../MenuBand/MenuBandRewindVoice.swift | 104 ++++++++------- .../Sources/MenuBand/MenuBandSynth.swift | 6 + .../CollapsedPianoWaveformView.swift | 19 ++- .../Sources/MenuBand/WaveformStripView.swift | 120 ++++++++++++++++++ 5 files changed, 223 insertions(+), 48 deletions(-) create mode 100644 slab/menuband/Sources/MenuBand/WaveformStripView.swift diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index 0ce3f65974..46d0c66bdc 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -885,12 +885,25 @@ final class MenuBandController { /// Bounced to the main thread because `playKeyEvent` runs on the /// KeyEventTap background thread and reverse playback mutates the engine /// graph + schedules a player buffer (both main-thread-only). + /// True while the spacebar reverse-replay is sounding — drives the + /// popover waveform strip's direction indicator (◀ reverse vs ▶ live). + private(set) var isRewinding = false + func rewind() { + isRewinding = true DispatchQueue.main.async { [weak self] in self?.synth.playReverse() } } + /// Spacebar released — stop reverse playback and resume capture. + func rewindRelease() { + isRewinding = false + DispatchQueue.main.async { [weak self] in + self?.synth.releaseReverse() + } + } + func setBend(amount: Float, allChannels: Bool = false) { // No clamp here — the trackpad accumulator can swing past // ±1 and the sample voice can vari-speed an arbitrary @@ -2266,7 +2279,14 @@ final class MenuBandController { // note or a literal space character. Trigger on the first key-down // (ignore auto-repeat); consume key-up too so it stays swallowed. if keyCode == 49 /* kVK_Space */ { - if isDown && !isRepeat { rewind() } + // Hold-to-reverse (notepat model): press starts reverse playback + // of the audio since the last press; release stops it and resumes + // live capture so the next press reverses whatever you play next. + if isDown { + if !isRepeat { rewind() } + } else { + rewindRelease() + } return true } diff --git a/slab/menuband/Sources/MenuBand/MenuBandRewindVoice.swift b/slab/menuband/Sources/MenuBand/MenuBandRewindVoice.swift index 7a29bebfa3..752d189aff 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandRewindVoice.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandRewindVoice.swift @@ -31,14 +31,18 @@ final class MenuBandRewindVoice { /// single reverse window at REVERSE_MAX_AGE_MS = 12 s; we keep that as /// the ring ceiling so the buffer is generously sized. private let bufferSeconds: Double = 12.0 - /// Window actually reversed per spacebar press. Notepat grabs "audio - /// since the previous press" (floored to a 500 ms minimum); Menu Band's - /// spacebar is a simple one-shot (no press-pair state), so we reverse a - /// fixed recent window. ~4 s is long enough to hear a phrase rewind but - /// short enough to feel like an instant replay. - private let captureSeconds: Double = 4.0 /// Floor so a brief history still yields an audible clip. private let minCaptureSeconds: Double = 0.5 + /// `CACurrentMediaTime()` of the previous Space press. The reverse window + /// is "audio SINCE the last press" (notepat semantics) — so rapid taps + /// reverse successive short chunks (a stutter), and a press after a long + /// phrase reverses the whole phrase. Floored to `minCaptureSeconds`, + /// capped at `bufferSeconds`. Set on attach so the first press measures + /// from launch. + private var lastPressTime: Double = 0 + /// Bumped each press so a stale backstop timer from an earlier press + /// can't resume capture during a newer press's playback. + private var pressGeneration = 0 private let player = AVAudioPlayerNode() private let mixer = AVAudioMixerNode() @@ -68,12 +72,6 @@ final class MenuBandRewindVoice { /// Bool is fine (single-word, set-then-read, no torn state that matters). private var recording = true - /// Fixed render format for the player. The captured ring is mono; we - /// connect the player as mono and let the engine up-mix to the device. - private let renderFormat = AVAudioFormat( - commonFormat: .pcmFormatFloat32, sampleRate: 44_100, - channels: 1, interleaved: false)! - // MARK: Attach /// Wire the rewind player into the engine. Called from @@ -84,9 +82,15 @@ final class MenuBandRewindVoice { self.engine = engine engine.attach(player) engine.attach(mixer) - engine.connect(player, to: mixer, format: renderFormat) - engine.connect(mixer, to: engine.mainMixerNode, format: renderFormat) + // Connect with engine-derived (nil) formats so the rewind graph + // tracks the device's sample rate — a hardcoded 44.1k connection + // breaks (and `scheduleBuffer` asserts) when the user is on a 96 kHz + // interface or hot-swaps devices. The reversed clip is built in the + // player's *actual* output format at play time (see `playReverse`). + engine.connect(player, to: mixer, format: nil) + engine.connect(mixer, to: engine.mainMixerNode, format: nil) mixer.outputVolume = 1.0 + lastPressTime = CACurrentMediaTime() attached = true } @@ -159,10 +163,14 @@ final class MenuBandRewindVoice { // How many valid frames exist: the whole ring once it has wrapped, // otherwise only what's been written so far. let valid = Swift.min(framesWritten, cap) - // Capture window: the most-recent `captureSeconds`, floored to the - // minimum so a quiet/short history still yields an audible clip. - let want = Swift.max(Int(minCaptureSeconds * rate), - Int(captureSeconds * rate)) + // Capture window = audio SINCE the last press (notepat), floored to + // `minCaptureSeconds` and capped at the ring length. Rapid taps grab + // short successive chunks; a press after a phrase grabs the phrase. + let now = CACurrentMediaTime() + let sinceLast = lastPressTime > 0 ? (now - lastPressTime) : bufferSeconds + lastPressTime = now + let windowSec = Swift.min(bufferSeconds, Swift.max(minCaptureSeconds, sinceLast)) + let want = Int(windowSec * rate) let count = Swift.min(valid, Swift.min(want, cap)) guard count >= 256 else { ringLock.unlock(); return false } // Walk backward from the write head, emitting samples in REVERSE @@ -177,48 +185,54 @@ final class MenuBandRewindVoice { } ringLock.unlock() - // --- Build a mono PCM buffer at the captured rate --- - guard let clipFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, - sampleRate: rate, - channels: 1, interleaved: false), - let clip = AVAudioPCMBuffer(pcmFormat: clipFormat, + // --- Build the clip in the player's ACTUAL output format --- + // (nil-connected, so it tracks the device). Building to match the + // player's format means `scheduleBuffer` never asserts on a sample- + // rate/channel mismatch — the bug that broke audio on a 96 kHz + // device. The ring is captured at the same device rate, so the + // reversed mono samples are copied into every output channel at the + // right speed. + let outFormat = player.outputFormat(forBus: 0) + guard outFormat.channelCount >= 1, outFormat.sampleRate > 0, + let clip = AVAudioPCMBuffer(pcmFormat: outFormat, frameCapacity: AVAudioFrameCount(count)), - let dst = clip.floatChannelData?[0] + let chData = clip.floatChannelData else { return false } + let chCount = Int(outFormat.channelCount) reversed.withUnsafeBufferPointer { src in - dst.update(from: src.baseAddress!, count: count) + guard let base = src.baseAddress else { return } + for c in 0.. mx { mx = s } + } + // Push one new column on the right, drop the oldest on the left. + colMin.removeFirst(); colMin.append(mn) + colMax.removeFirst(); colMax.append(mx) + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + let r = bounds + guard r.width > 4, r.height > 4 else { return } + let reversing = menuBand?.isRewinding ?? false + let accent = NSColor.controlAccentColor + let color = reversing ? NSColor.systemOrange : accent + + // Recessed screen — opaque enough to read over the glass + when silent. + let plate = NSBezierPath(roundedRect: r.insetBy(dx: 0.5, dy: 0.5), + xRadius: 4, yRadius: 4) + NSColor.black.withAlphaComponent(0.55).setFill(); plate.fill() + color.withAlphaComponent(0.6).setStroke(); plate.lineWidth = 1.0; plate.stroke() + + let mid = r.midY + let zero = NSBezierPath() + zero.move(to: NSPoint(x: 3, y: mid)); zero.line(to: NSPoint(x: r.width - 3, y: mid)) + color.withAlphaComponent(0.25).setStroke(); zero.lineWidth = 0.5; zero.stroke() + + let n = Self.columnCount + let inset: CGFloat = 4 + let usableW = r.width - inset * 2 + let amp = r.height * 0.44 + // Scrolling min/max bars. Newest column on the RIGHT in live mode; in + // reverse the column order is mirrored so the trace runs the other + // way (you see the buffer scrub backward). + color.withAlphaComponent(0.95).setStroke() + let bars = NSBezierPath() + bars.lineWidth = 1.0 + for i in 0..