diff --git a/slab/bin/analysis-layer.mjs b/slab/bin/analysis-layer.mjs index 550c0b02f..377bef3d0 100644 --- a/slab/bin/analysis-layer.mjs +++ b/slab/bin/analysis-layer.mjs @@ -115,7 +115,7 @@ export function overlayClearExpr() { // the agent is observing. Pixel-accurate (browser viewport space, unlike // whole-screen Vision OCR). `ttl` ms auto-clears the scan (0 = persist). // Returns {text, targets} counts. -export function scanExpr(ttl = 2600) { +export function scanExpr(ttl = 7000) { return `(()=>{ const NS="http://www.w3.org/2000/svg"; let s=document.getElementById("__analysis_overlay"); diff --git a/slab/bin/puppet.mjs b/slab/bin/puppet.mjs index 3d2f79985..7cce35610 100755 --- a/slab/bin/puppet.mjs +++ b/slab/bin/puppet.mjs @@ -716,7 +716,7 @@ async function handleRequest(req, sock) { // scan: draw the observation overlay (text + interactive boxes) on the // page so a watcher sees what the agent is reading. Returns {text,targets}. case "scan": - return one(machine).eval(scanExpr(args.ttl ?? 2600), args.target); + return one(machine).eval(scanExpr(args.ttl ?? 7000), args.target); case "analysis": { const want = args.on !== false && args.on !== "off"; const names = !machine || machine === "all" ? [...machines.keys()] : [machine]; diff --git a/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift b/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift index 034d00610..e5d210bdd 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift @@ -26,6 +26,19 @@ final class FrameCapture { private var timer: DispatchSourceTimer? private let fm = FileManager.default + // Transient overlay windows we draw (capture flash, OCR boxes). We exclude + // them from the screen capture by windowID so they never appear in a frame + // — that's the "doesn't interfere" guarantee. (The badge etc. still show.) + private let overlayLock = NSLock() + private var overlayWindowIDs = Set() + private var ocrOverlayWindow: NSWindow? + private func registerOverlay(_ w: NSWindow) { + overlayLock.lock(); overlayWindowIDs.insert(w.windowNumber); overlayLock.unlock() + } + private func unregisterOverlay(_ w: NSWindow) { + overlayLock.lock(); overlayWindowIDs.remove(w.windowNumber); overlayLock.unlock() + } + func start() { let dir = (Paths.frameReq as NSString).deletingLastPathComponent try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true) @@ -46,6 +59,92 @@ final class FrameCapture { fm.createFile(atPath: Paths.frameDone, contents: nil) } + // A subtle whole-display flash, fired AFTER the pixels are grabbed so it + // never lands in the capture — just end-user awareness that a frame was + // snapped. Runs on the main thread, click-through, brief and low-alpha; the + // capture/OCR pipeline keeps going on its own queue meanwhile. + private func flashCaptureIndicator() { + DispatchQueue.main.async { + for screen in NSScreen.screens { + let win = NSWindow(contentRect: screen.frame, styleMask: .borderless, + backing: .buffered, defer: false) + win.isOpaque = false + win.backgroundColor = .clear + win.level = .screenSaver + win.ignoresMouseEvents = true + win.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary] + let view = NSView(frame: NSRect(origin: .zero, size: screen.frame.size)) + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.white.cgColor + win.contentView = view + win.alphaValue = 0.0 + win.orderFrontRegardless() + self.registerOverlay(win) + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.06 + win.animator().alphaValue = 0.22 + }, completionHandler: { + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.34 + win.animator().alphaValue = 0.0 + }, completionHandler: { self.unregisterOverlay(win); win.orderOut(nil) }) + }) + } + } + } + + // Draw the whole-screen OCR boxes as a brief screen-wide overlay, so a + // watcher sees what was read across the ENTIRE display — not just inside a + // browser window (puppet's page-side scan). Click-through, excluded from + // captures by windowID, holds ~7s then fades. Boxes are points/top-left + // (from ocr()); CALayer is bottom-left, so Y flips against screen height. + private func showOcrOverlay(_ boxes: [[String: Any]]) { + guard !boxes.isEmpty else { return } + DispatchQueue.main.async { + guard let screen = NSScreen.main else { return } + if let old = self.ocrOverlayWindow { self.unregisterOverlay(old); old.orderOut(nil) } + let H = screen.frame.height + let win = NSWindow(contentRect: screen.frame, styleMask: .borderless, + backing: .buffered, defer: false) + win.isOpaque = false + win.backgroundColor = .clear + win.level = .screenSaver + win.ignoresMouseEvents = true + win.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary] + let view = NSView(frame: NSRect(origin: .zero, size: screen.frame.size)) + view.wantsLayer = true + if let root = view.layer { + for b in boxes { + guard let r = b["r"] as? [Int], r.count == 4 else { continue } + let box = CALayer() + box.frame = CGRect(x: CGFloat(r[0]), y: H - CGFloat(r[1]) - CGFloat(r[3]), + width: CGFloat(r[2]), height: CGFloat(r[3])) + // A random hue per box, semi-transparent fill — so the whole + // read is vivid and every box stands out against the others. + let c = NSColor(hue: .random(in: 0...1), saturation: 0.8, brightness: 1.0, alpha: 1.0) + box.backgroundColor = c.withAlphaComponent(0.28).cgColor + box.borderColor = c.withAlphaComponent(0.95).cgColor + box.borderWidth = 1.2 + box.cornerRadius = 2 + root.addSublayer(box) + } + } + win.contentView = view + win.orderFrontRegardless() + self.registerOverlay(win) + self.ocrOverlayWindow = win + DispatchQueue.main.asyncAfter(deadline: .now() + 7.0) { + guard self.ocrOverlayWindow === win else { return } + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.4 + win.animator().alphaValue = 0.0 + }, completionHandler: { + self.unregisterOverlay(win); win.orderOut(nil); self.ocrOverlayWindow = nil + }) + } + } + } + // MARK: - capture (in-process; no screencapture subprocess → no launchd throttle) private func captureDisplay() -> CGImage? { @@ -57,7 +156,18 @@ final class FrameCapture { guard let content = try? await SCShareableContent.excludingDesktopWindows( false, onScreenWindowsOnly: true), let display = content.displays.first else { return } - let filter = SCContentFilter(display: display, excludingWindows: []) + // GUARANTEE we capture UNDER everything this app draws. Belt: any + // window we own (flash, OCR overlay, badge, previews) by bundle id. + // Suspenders: the explicitly-tracked overlay window ids, in case a + // window's owning app is momentarily unresolved. A frame is always + // the machine's real content beneath our overlays — never them. + let myBundle = Bundle.main.bundleIdentifier + let exclude = content.windows.filter { w in + if w.owningApplication?.bundleIdentifier == myBundle { return true } + self.overlayLock.lock(); defer { self.overlayLock.unlock() } + return self.overlayWindowIDs.contains(Int(w.windowID)) + } + let filter = SCContentFilter(display: display, excludingWindows: exclude) let cfg = SCStreamConfiguration() cfg.width = display.width cfg.height = display.height @@ -75,6 +185,8 @@ final class FrameCapture { req.recognitionLevel = fast ? .fast : .accurate req.usesLanguageCorrection = false req.recognitionLanguages = ["en-US"] + req.minimumTextHeight = 0 // don't skip small/dense text (e.g. terminal monospace) + if #available(macOS 13.0, *) { req.revision = VNRecognizeTextRequestRevision3 } try? VNImageRequestHandler(cgImage: cg, options: [:]).perform([req]) let W = Double(cg.width), H = Double(cg.height) var out: [[String: Any]] = [] @@ -230,6 +342,7 @@ final class FrameCapture { env["meta"] = mt let scale = ((mt["screen"] as? [String: Any])?["scale"] as? CGFloat).map(Double.init) ?? 1.0 t = nowNs(); let cg = captureDisplay(); tm["capture"] = msSince(t) + if cg != nil { flashCaptureIndicator() } // subtle post-capture awareness flash // The JPEG ships as RAW BYTES in a sidecar file (frame.out.jpg), not // base64 in the JSON — base64 inflates the payload +33% and burns // encode/decode CPU. The transport length-prefixes the two. Written @@ -239,7 +352,9 @@ final class FrameCapture { if noOCR { env["ocr"] = [] } else { - t = nowNs(); env["ocr"] = ocr(cg, scale: scale, fast: fast); tm["ocr"] = msSince(t) + t = nowNs(); let boxes = ocr(cg, scale: scale, fast: fast); tm["ocr"] = msSince(t) + env["ocr"] = boxes + showOcrOverlay(boxes) } t = nowNs() let jpg = thumbJPEG(cg, maxWidth: 1568) ?? Data() -- 2.51.2 From fc3ef596fcfa780c879bbd3dfeedf1942f8bb621 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 23 Jun 2026 22:39:13 -0700 Subject: [PATCH 02/11] slab/frame: capture-scale knob + fix Retina OCR coord scaling Introduces a captureScale constant (1x) used for both the capture resolution and the OCR->points conversion, replacing the backingScaleFactor- derived ocr scale that halved box coords on Retina displays. Leaves the knob for future higher-res / tiled OCR work. --- .../Sources/SlabMenubar/FrameCapture.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift b/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift index e5d210bdd..dd6f943c4 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift @@ -29,6 +29,11 @@ final class FrameCapture { // Transient overlay windows we draw (capture flash, OCR boxes). We exclude // them from the screen capture by windowID so they never appear in a frame // — that's the "doesn't interfere" guarantee. (The badge etc. still show.) + // Capture at this multiple of the display's point size — 2x makes small, + // dense text (terminals) physically larger in the buffer, pushing it over + // Vision's recognition threshold. The OCR scale uses the same factor so + // box coords still map back to screen points. + private let captureScale: Double = 1.0 private let overlayLock = NSLock() private var overlayWindowIDs = Set() private var ocrOverlayWindow: NSWindow? @@ -169,8 +174,8 @@ final class FrameCapture { } let filter = SCContentFilter(display: display, excludingWindows: exclude) let cfg = SCStreamConfiguration() - cfg.width = display.width - cfg.height = display.height + cfg.width = Int(Double(display.width) * self.captureScale) + cfg.height = Int(Double(display.height) * self.captureScale) cfg.showsCursor = true img = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: cfg) } @@ -340,7 +345,6 @@ final class FrameCapture { var t = nowNs(); let mt = meta(); tm["meta"] = msSince(t) env["meta"] = mt - let scale = ((mt["screen"] as? [String: Any])?["scale"] as? CGFloat).map(Double.init) ?? 1.0 t = nowNs(); let cg = captureDisplay(); tm["capture"] = msSince(t) if cg != nil { flashCaptureIndicator() } // subtle post-capture awareness flash // The JPEG ships as RAW BYTES in a sidecar file (frame.out.jpg), not @@ -352,7 +356,7 @@ final class FrameCapture { if noOCR { env["ocr"] = [] } else { - t = nowNs(); let boxes = ocr(cg, scale: scale, fast: fast); tm["ocr"] = msSince(t) + t = nowNs(); let boxes = ocr(cg, scale: captureScale, fast: fast); tm["ocr"] = msSince(t) env["ocr"] = boxes showOcrOverlay(boxes) } -- 2.51.2 From f9abe63d063c2491565b48e2bebb114954a8dd78 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 24 Jun 2026 10:21:41 -0700 Subject: [PATCH 03/11] =?UTF-8?q?notepat/menuband:=20chromatic=20sample=20?= =?UTF-8?q?mode=20=E2=80=94=20pitch=20from=20recorded=20f0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sample playback now resamples from the sample's actual detected fundamental instead of assuming the recording is C4/A4, so each key sounds at its true note frequency. - native notepat: windowed-autocorrelation detectFundamental(); store as per-sample base (global + per-key banks); play/pitch-bend use it, with a C4 fallback for untuned/quiet captures. - menuband: mirror detector in Swift; cents = 1200*log2(targetHz/f0) into AVAudioUnitTimePitch; detect in stopRecording under bufferLock. --- fedac/native/pieces/notepat.mjs | 84 +++++++++++++++-- .../MenuBand/MenuBandSampleVoice.swift | 93 ++++++++++++++++++- 2 files changed, 164 insertions(+), 13 deletions(-) diff --git a/fedac/native/pieces/notepat.mjs b/fedac/native/pieces/notepat.mjs index 9770bf873..d280fabea 100644 --- a/fedac/native/pieces/notepat.mjs +++ b/fedac/native/pieces/notepat.mjs @@ -55,7 +55,69 @@ let recording = false; // true while holding REC let recPointerId = null; // touch pointer currently holding REC button let recStartTime = 0; // Date.now() when recording started const MAX_REC_SECS = 10; // matches AUDIO_MAX_SAMPLE_SECS -const SAMPLE_BASE_FREQ = 261.63; // C4 — base pitch for sample playback +const SAMPLE_BASE_FREQ = 261.63; // C4 — fallback base when a sample isn't tonal + +// Estimate the fundamental frequency (Hz) of a recorded mono sample via +// windowed autocorrelation. Sample playback resamples by `freq / base`, so +// feeding the note the user *actually* recorded as `base` makes every key +// sound at its true note frequency — a chromatic sampler — instead of +// assuming the recording was C4. Returns null when the sample is too quiet +// or untuned (a drum hit, noise) so callers fall back to SAMPLE_BASE_FREQ. +function detectFundamental(data, rate, fMin = 50, fMax = 1500) { + if (!data || !rate || data.length < 2048) return null; + // Window around the loudest region so silent lead-in doesn't poison the + // estimate. Subsample the scan — we only need the rough peak location. + const N = Math.min(4096, data.length); + const scanStep = Math.max(1, Math.floor(data.length / 2048)); + let peakIdx = 0, peakAmp = 0; + for (let i = 0; i < data.length; i += scanStep) { + const a = data[i] < 0 ? -data[i] : data[i]; + if (a > peakAmp) { peakAmp = a; peakIdx = i; } + } + if (peakAmp < 0.01) return null; // effectively silent + let start = peakIdx - (N >> 1); + if (start < 0) start = 0; + if (start + N > data.length) start = data.length - N; + // De-mean + Hann window into a working buffer. + const buf = new Float32Array(N); + let mean = 0; + for (let i = 0; i < N; i++) mean += data[start + i]; + mean /= N; + let energy0 = 0; + for (let i = 0; i < N; i++) { + const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (N - 1)); + const v = (data[start + i] - mean) * w; + buf[i] = v; + energy0 += v * v; + } + if (energy0 <= 1e-9) return null; + const minLag = Math.max(2, Math.floor(rate / fMax)); + const maxLag = Math.min(N - 1, Math.floor(rate / fMin)); + if (maxLag <= minLag) return null; + // Normalized autocorrelation; take the first strong local maximum so we + // lock onto the fundamental rather than a louder higher harmonic. + const nac = new Float32Array(maxLag + 1); + for (let lag = minLag; lag <= maxLag; lag++) { + let corr = 0; + for (let i = 0; i < N - lag; i++) corr += buf[i] * buf[i + lag]; + nac[lag] = corr / energy0; + } + let bestLag = -1, bestVal = 0; + for (let lag = minLag + 1; lag < maxLag; lag++) { + if (nac[lag] > nac[lag - 1] && nac[lag] >= nac[lag + 1]) { + if (nac[lag] > bestVal) { bestVal = nac[lag]; bestLag = lag; } + if (bestVal > 0.6) break; // confident enough — first solid peak wins + } + } + if (bestLag < 0 || bestVal < 0.3) return null; // not tonal enough to trust + // Parabolic interpolation for sub-sample lag precision. + const a = nac[bestLag - 1], b = nac[bestLag], c = nac[bestLag + 1]; + const denom = a - 2 * b + c; + const shift = denom !== 0 ? (0.5 * (a - c)) / denom : 0; + const refinedLag = bestLag + shift; + const f0 = rate / refinedLag; + return f0 >= fMin && f0 <= fMax ? f0 : null; +} // Per-key sample bank: End key arms, tone key records to that key only let sampleBank = {}; // key -> { data: Float32Array, len: number, rate: number } @@ -245,7 +307,7 @@ function applyPitchShiftToActiveSounds(force = false) { for (const k of Object.keys(sounds)) { const s = sounds[k]; if (s && s.synth && s.baseFreq) { - if (s.isSample) s.synth.update({ tone: s.baseFreq * factor, base: SAMPLE_BASE_FREQ }); + if (s.isSample) s.synth.update({ tone: s.baseFreq * factor, base: s.sampleBase || SAMPLE_BASE_FREQ }); else s.synth.update({ tone: s.baseFreq * factor }); } } @@ -3124,11 +3186,12 @@ function act({ event: e, sound, wifi, system }) { sound.sample.loadData(targetSample.data, targetSample.rate); lastLoadedSample = targetSample; } + const sampleBase = targetSample?.base || SAMPLE_BASE_FREQ; const smp = sound.sample.play({ - tone: playFreq, base: SAMPLE_BASE_FREQ, volume: vol, pan, loop: true, + tone: playFreq, base: sampleBase, volume: vol, pan, loop: true, }); if (smp) { - rememberSound(key, { synth: smp, note: letter, octave: noteOctave, baseFreq: freq, isSample: true, gridOffset: offset, baseVol }, system, velocity); + rememberSound(key, { synth: smp, note: letter, octave: noteOctave, baseFreq: freq, isSample: true, sampleBase, gridOffset: offset, baseVol }, system, velocity); } else { const synth = sound.synth({ type: "sine", tone: playFreq, duration: Infinity, @@ -3189,9 +3252,12 @@ function act({ event: e, sound, wifi, system }) { // Save global sample data for bank restore const data = sound.sample.getData?.(); if (data && data.length > 0) { - globalSample = { data: new Float32Array(data), len: data.length, rate: sound.microphone?.sampleRate || 48000 }; + const buf = new Float32Array(data); + const rate = sound.microphone?.sampleRate || 48000; + const base = detectFundamental(buf, rate) || SAMPLE_BASE_FREQ; + globalSample = { data: buf, len: data.length, rate, base }; lastLoadedSample = null; // force reload on next key press - console.log(`[sample-bank] global sample saved (${data.length} samples)`); + console.log(`[sample-bank] global sample saved (${data.length} samples, base ${base.toFixed(1)}Hz)`); } return; } @@ -3217,8 +3283,10 @@ function act({ event: e, sound, wifi, system }) { percussionSampleBank[recDrum] = { data: new Float32Array(data), len: data.length, rate }; console.log(`[perc-bank] saved ${data.length} samples to drum '${recDrum}'`); } else { - sampleBank[key] = { data: new Float32Array(data), len: data.length, rate }; - console.log(`[sample-bank] saved ${data.length} samples to key '${key}'`); + const buf = new Float32Array(data); + const base = detectFundamental(buf, rate) || SAMPLE_BASE_FREQ; + sampleBank[key] = { data: buf, len: data.length, rate, base }; + console.log(`[sample-bank] saved ${data.length} samples to key '${key}' (base ${base.toFixed(1)}Hz)`); } sampleLoaded = true; // Confirmation beep diff --git a/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift b/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift index a15055230..0ae02a3ef 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift @@ -59,6 +59,13 @@ final class MenuBandSampleVoice { private var recordedBuffer: AVAudioPCMBuffer? private let bufferLock = NSLock() + /// 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 + /// back to C4 when the capture isn't tonal enough to trust. Written + /// under `bufferLock` alongside `recordedBuffer`. + private var detectedFundamental: Double = 261.63 + /// Active recording state. We tap the engine's input node into a /// scratch buffer; on `stopRecording` we trim to actual length and /// promote to `recordedBuffer`. @@ -502,14 +509,17 @@ final class MenuBandSampleVoice { return false } out.frameLength = AVAudioFrameCount(frames) + var f0: Double? = nil if let src = scratch.floatChannelData?[0], let dst = out.floatChannelData?[0] { memcpy(dst, src.advanced(by: startFrame), frames * MemoryLayout.size) let stats = shapeCapturedSample(dst, frames: frames) - NSLog("MenuBand SampleVoice: sample shaped peak \(stats.peakBefore) -> \(stats.peakAfter), rms \(stats.rmsBefore) -> \(stats.rmsAfter), gain=\(stats.gain)") + f0 = detectFundamental(dst, frames: frames, rate: sampleRate) + NSLog("MenuBand SampleVoice: sample shaped peak \(stats.peakBefore) -> \(stats.peakAfter), rms \(stats.rmsBefore) -> \(stats.rmsAfter), gain=\(stats.gain), f0=\(f0.map { String(format: "%.1fHz", $0) } ?? "untuned→C4")") } bufferLock.lock() recordedBuffer = out + detectedFundamental = f0 ?? 261.63 bufferLock.unlock() NSLog("MenuBand SampleVoice: recording captured \(frames) frames (\(Double(frames) / sampleRate) s), trimmed \(startFrame) leading frames") return true @@ -562,6 +572,74 @@ final class MenuBandSampleVoice { return (peakBefore, peakAfter, rmsBefore, rmsAfter, gain) } + /// Estimate the fundamental frequency (Hz) of the shaped mono sample via + /// windowed autocorrelation, so chromatic playback can pitch from the + /// note actually recorded instead of a fixed C4. Mirrors the native + /// notepat detector. Returns nil when the capture is too quiet or + /// untuned (a drum hit, noise) to trust — callers fall back to C4. + private func detectFundamental(_ data: UnsafeMutablePointer, frames: Int, + rate: Double, fMin: Double = 50, fMax: Double = 1500) -> Double? { + guard frames >= 2048, rate > 0 else { return nil } + let n = min(4096, frames) + // Window around the loudest region so silent lead-in/out doesn't + // poison the estimate. + let scanStep = max(1, frames / 2048) + var peakIdx = 0 + var peakAmp: Float = 0 + var i = 0 + while i < frames { + let a = abs(data[i]) + if a > peakAmp { peakAmp = a; peakIdx = i } + i += scanStep + } + if peakAmp < 0.01 { return nil } // effectively silent + var start = peakIdx - (n / 2) + if start < 0 { start = 0 } + if start + n > frames { start = frames - n } + // De-mean + Hann window into a working buffer. + var buf = [Float](repeating: 0, count: n) + var mean: Float = 0 + for j in 0.. nac[lag - 1] && nac[lag] >= nac[lag + 1] { + if nac[lag] > bestVal { bestVal = nac[lag]; bestLag = lag } + if bestVal > 0.6 { break } // confident enough — first solid peak + } + lag += 1 + } + if bestLag < 0 || bestVal < 0.3 { return nil } // not tonal enough + // Parabolic interpolation for sub-sample lag precision. + let a = nac[bestLag - 1], b = nac[bestLag], c = nac[bestLag + 1] + let denom = a - 2 * b + c + let shift: Float = denom != 0 ? 0.5 * (a - c) / denom : 0 + let refinedLag = Double(bestLag) + Double(shift) + let f0 = rate / refinedLag + return (f0 >= fMin && f0 <= fMax) ? f0 : nil + } + private func trimmedStartFrame(scratch: AVAudioPCMBuffer, frames: Int) -> Int { guard let data = scratch.floatChannelData?[0], frames > 0 else { return 0 } let window = 256 @@ -842,12 +920,17 @@ final class MenuBandSampleVoice { return v } - /// Pitch shift in CENTS for `midi` relative to middle C (60). 60 → 0. - /// 100 cents per semitone. Drives `AVAudioUnitTimePitch.pitch`, which - /// shifts pitch without touching duration/speed. + /// Pitch shift in CENTS for `midi`, chromatic from the sample's actual + /// recorded fundamental: shift = 1200·log2(targetHz / f0). So each key + /// sounds at its true note frequency regardless of what pitch was hummed + /// into the mic. Falls back to C4 (261.63 Hz) when detection failed, which + /// reduces to the old `(midi-60)·100` behavior for a C4-pitched sample. + /// Drives `AVAudioUnitTimePitch.pitch` (shifts pitch, not duration/speed). @inline(__always) private func cents(forNote midi: UInt8) -> Float { - Float(Int(midi) - 60) * 100.0 + let targetHz = 440.0 * pow(2.0, (Double(midi) - 69.0) / 12.0) + let base = detectedFundamental > 0 ? detectedFundamental : 261.63 + return Float(1200.0 * log2(targetHz / base)) } /// Combined note + trackpad-bend pitch in cents, clamped to the -- 2.51.2 From 381ff6624b30377a32a61b7fa37c6e002d3c6a5f Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 24 Jun 2026 10:42:43 -0700 Subject: [PATCH 04/11] ac-native: flash-time preset greeting city (ac-inscribe --city) Boot greeting (splash subtitle + TTS) already geolocates via /mnt/last-city.txt after the first wifi connect. Add a config.json "city" field, baked by `ac-inscribe --city `, so a freshly-flashed device greets from wherever it's shipped (e.g. Ridgewood) on first boot, before any IP lookup. Falls back to that preset, then "Los Angeles", when the live cache is absent. --- fedac/native/ac-inscribe | 19 +++++++++++++++++-- fedac/native/scripts/inscribe-lib.sh | 5 +++-- fedac/native/src/ac-native.c | 22 +++++++++++++++++++--- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/fedac/native/ac-inscribe b/fedac/native/ac-inscribe index c645c381c..c8b8288da 100755 --- a/fedac/native/ac-inscribe +++ b/fedac/native/ac-inscribe @@ -14,6 +14,8 @@ # ac-inscribe --no-claude # skip baking local Claude session # ac-inscribe --inspect # print summary of an existing inscription # ac-inscribe --for-handle # admin: inscribe for another user +# ac-inscribe --mood # override boot splash subtitle (else uses the handle's AC mood) +# ac-inscribe --city # preset first-boot greeting city until the device geolocates (e.g. "Ridgewood") # # Exit codes: # 0 inscription written and valid @@ -49,6 +51,9 @@ INSPECT="" FOR_HANDLE="" MOOD_OVERRIDE="" MOOD_OVERRIDE_SET=0 +CITY_OVERRIDE="" +CITY_OVERRIDE_SET=0 +ACI_CITY="" # config "city"; only set via --city (no account fetch), so default empty under `set -u` while [ $# -gt 0 ]; do case "$1" in @@ -59,6 +64,7 @@ while [ $# -gt 0 ]; do --inspect) INSPECT="$2"; shift ;; --for-handle) FOR_HANDLE="${2#@}"; shift ;; --mood) MOOD_OVERRIDE="$2"; MOOD_OVERRIDE_SET=1; shift ;; + --city) CITY_OVERRIDE="$2"; CITY_OVERRIDE_SET=1; shift ;; --help|-h) sed -n '2,28p' "$0" | sed 's/^# *//' exit 0 @@ -157,14 +163,19 @@ if [ -n "${FOR_HANDLE}" ]; then ACI_MOOD="${MOOD_OVERRIDE}" aci_ok "mood override: ${ACI_C_BOLD}${ACI_MOOD:-(cleared)}${ACI_C_RESET}" fi + if [ "${CITY_OVERRIDE_SET}" -eq 1 ]; then + ACI_CITY="${CITY_OVERRIDE}" + aci_ok "city override: ${ACI_C_BOLD}${ACI_CITY:-(cleared)}${ACI_C_RESET}" + fi aci_step 3 4 "Assembling inscription bundle for @${ACI_HANDLE}" BUNDLE=$(aci_build_inscription_json) || aci_die "bundle assembly failed" BUNDLE_BYTES=$(printf '%s' "${BUNDLE}" | wc -c | tr -d ' ') aci_ok "version 1 envelope, ${BUNDLE_BYTES} bytes" - aci_ok "fields: handle, sub, email(empty), token(empty)$([ -n "${ACI_CLAUDE_TOKEN}" ] && printf ', claudeToken')$([ -n "${ACI_GITHUB_PAT}" ] && printf ', githubPat')$([ -n "${ACI_HANDLE_COLORS_JSON}" ] && printf ', colors')$([ -n "${ACI_MOOD}" ] && printf ', mood')" + aci_ok "fields: handle, sub, email(empty), token(empty)$([ -n "${ACI_CLAUDE_TOKEN}" ] && printf ', claudeToken')$([ -n "${ACI_GITHUB_PAT}" ] && printf ', githubPat')$([ -n "${ACI_HANDLE_COLORS_JSON}" ] && printf ', colors')$([ -n "${ACI_MOOD}" ] && printf ', mood')$([ -n "${ACI_CITY}" ] && printf ', city')" [ -n "${ACI_HANDLE_COLORS_JSON}" ] && aci_ok "handle-colors: ${ACI_C_BOLD}per-character palette baked${ACI_C_RESET}" || aci_info "handle-colors: none stored — boot uses theme color" [ -n "${ACI_MOOD}" ] && aci_ok "mood: ${ACI_C_BOLD}${ACI_MOOD}${ACI_C_RESET}" || aci_info "mood: none yet — boot subtitle uses default" + [ -n "${ACI_CITY}" ] && aci_ok "city: ${ACI_C_BOLD}${ACI_CITY}${ACI_C_RESET} (greeting until geolocated)" || aci_info "city: none — first-boot greeting uses default" aci_warn "access_token is empty — recipient runs ac-login on device once for tape uploads" if [ "${YES}" -eq 0 ] && [ -t 0 ]; then @@ -255,13 +266,17 @@ if [ "${MOOD_OVERRIDE_SET}" -eq 1 ]; then ACI_MOOD="${MOOD_OVERRIDE}" aci_ok "mood override: ${ACI_C_BOLD}${ACI_MOOD:-(cleared)}${ACI_C_RESET}" fi +if [ "${CITY_OVERRIDE_SET}" -eq 1 ]; then + ACI_CITY="${CITY_OVERRIDE}" + aci_ok "city override: ${ACI_C_BOLD}${ACI_CITY:-(cleared)}${ACI_C_RESET}" +fi # Step 4 — assemble bundle aci_step 4 5 "Assembling inscription bundle" BUNDLE=$(aci_build_inscription_json) || aci_die "bundle assembly failed" BUNDLE_BYTES=$(printf '%s' "${BUNDLE}" | wc -c | tr -d ' ') aci_ok "version 1 envelope, ${BUNDLE_BYTES} bytes" -aci_ok "fields: handle, sub, email, token$([ -n "${ACI_CLAUDE_TOKEN}" ] && printf ', claudeToken')$([ -n "${ACI_GITHUB_PAT}" ] && printf ', githubPat')$([ -n "${ACI_CLAUDE_CREDS}" ] && printf ', claudeCreds')$([ -n "${ACI_CLAUDE_STATE}" ] && printf ', claudeState')$([ -n "${ACI_HANDLE_COLORS_JSON}" ] && printf ', colors')$([ -n "${ACI_MOOD}" ] && printf ', mood')" +aci_ok "fields: handle, sub, email, token$([ -n "${ACI_CLAUDE_TOKEN}" ] && printf ', claudeToken')$([ -n "${ACI_GITHUB_PAT}" ] && printf ', githubPat')$([ -n "${ACI_CLAUDE_CREDS}" ] && printf ', claudeCreds')$([ -n "${ACI_CLAUDE_STATE}" ] && printf ', claudeState')$([ -n "${ACI_HANDLE_COLORS_JSON}" ] && printf ', colors')$([ -n "${ACI_MOOD}" ] && printf ', mood')$([ -n "${ACI_CITY}" ] && printf ', city')" [ -n "${ACI_HANDLE_COLORS_JSON}" ] && aci_ok "handle-colors: ${ACI_C_BOLD}per-character palette baked${ACI_C_RESET}" || aci_info "handle-colors: none stored — boot uses theme color" [ -n "${ACI_MOOD}" ] && aci_ok "mood: ${ACI_C_BOLD}${ACI_MOOD}${ACI_C_RESET}" || aci_info "mood: none yet — boot subtitle uses default" diff --git a/fedac/native/scripts/inscribe-lib.sh b/fedac/native/scripts/inscribe-lib.sh index 05af6f9da..42e7322e5 100644 --- a/fedac/native/scripts/inscribe-lib.sh +++ b/fedac/native/scripts/inscribe-lib.sh @@ -256,7 +256,7 @@ aci_collect_local_claude() { # and ACI_MOOD (plain string). aci_build_usb_config_json() { node -e " - const [handle, sub, email, token, claudeToken, githubPat, claudeCreds, claudeState, colorsJson, mood] = process.argv.slice(1); + const [handle, sub, email, token, claudeToken, githubPat, claudeCreds, claudeState, colorsJson, mood, city] = process.argv.slice(1); const cfg = { handle, sub, email, token }; if (claudeToken) cfg.claudeToken = claudeToken; if (githubPat) cfg.githubPat = githubPat; @@ -269,11 +269,12 @@ aci_build_usb_config_json() { } } catch (e) {} if (mood) cfg.mood = mood; + if (city) cfg.city = city; process.stdout.write(JSON.stringify(cfg)); " "${ACI_HANDLE:-}" "${ACI_SUB:-}" "${ACI_EMAIL:-}" "${ACI_ACCESS_TOKEN:-}" \ "${ACI_CLAUDE_TOKEN:-}" "${ACI_GITHUB_PAT:-}" \ "${ACI_CLAUDE_CREDS:-}" "${ACI_CLAUDE_STATE:-}" \ - "${ACI_HANDLE_COLORS_JSON:-}" "${ACI_MOOD:-}" + "${ACI_HANDLE_COLORS_JSON:-}" "${ACI_MOOD:-}" "${ACI_CITY:-}" } # aci_build_inscription_json diff --git a/fedac/native/src/ac-native.c b/fedac/native/src/ac-native.c index 7669f2c2b..dcbd8023f 100644 --- a/fedac/native/src/ac-native.c +++ b/fedac/native/src/ac-native.c @@ -740,6 +740,13 @@ static int boot_title_colors_len = 0; // and falls back to the original "enjoy !" rendering. static char boot_mood[256] = ""; +// Flash-time preset city — baked into config.json "city" by +// `ac-inscribe --city` so a device greets from wherever it's being shipped +// (e.g. "Ridgewood") before it has ever geolocated. read_cached_city() uses +// this as the fallback when /mnt/last-city.txt (the live IP-lookup cache) +// doesn't exist yet; once the device geolocates, that cache wins. +static char preset_city[96] = ""; + // (Hardware device identity globals are defined further up — before the // compute_device_fingerprint() helper that needs them in file order.) @@ -878,6 +885,10 @@ static void load_boot_visual_config(void) { parse_config_string(json, "\"mood\"", boot_mood, sizeof(boot_mood)); } if (boot_mood[0]) ac_log("[ac-native] Boot mood: %s\n", boot_mood); + + // Flash-time preset greeting city (used until the device geolocates). + parse_config_string(json, "\"city\"", preset_city, sizeof(preset_city)); + if (preset_city[0]) ac_log("[ac-native] Preset city: %s\n", preset_city); } // Read wifi flag (default: enabled) @@ -2283,8 +2294,10 @@ static int get_la_hour(void) { // Fill buf with the city name for the boot greeting. The geo piece writes // /mnt/last-city.txt after a successful IP lookup, so this reads whatever -// was cached on a previous boot. Falls back to "Los Angeles" on first boot -// or when the cache is missing/empty — matches the pre-cache greeting. +// was cached on a previous boot. When that cache is missing/empty (first +// boot, before any IP lookup) it falls back to the flash-time preset city +// (config.json "city", baked by `ac-inscribe --city`) so a device greets +// from wherever it's shipped, then to "Los Angeles" as a last resort. static void read_cached_city(char *buf, size_t len) { if (!buf || len == 0) return; buf[0] = 0; @@ -2300,7 +2313,10 @@ static void read_cached_city(char *buf, size_t len) { fclose(f); } if (buf[0] == 0) { - strncpy(buf, "Los Angeles", len - 1); + // No live geolocation cache yet — fall back to the flash-time preset + // city (where the device was shipped), then to "Los Angeles". + const char *fallback = preset_city[0] ? preset_city : "Los Angeles"; + strncpy(buf, fallback, len - 1); buf[len - 1] = 0; } } -- 2.51.2 From ba8843deba43564fcca57d3446cc39c1f16fd092 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 24 Jun 2026 11:28:29 -0700 Subject: [PATCH 05/11] flash-mac: carry city/colors/mood from inscription into config.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS flasher wrote config.json from a hardcoded printf (handle/piece/ sub/email only), silently dropping the inscription's boot-personalization fields — so `ac-inscribe --city/--mood` and baked handle-colors never reached the device. Replace with a node writer that merges city/colors/mood from the inscription's usbConfig. (Host-side script — no OTA rebuild needed.) --- fedac/native/scripts/flash-mac.sh | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/fedac/native/scripts/flash-mac.sh b/fedac/native/scripts/flash-mac.sh index c7f2b62a0..c72926ffb 100755 --- a/fedac/native/scripts/flash-mac.sh +++ b/fedac/native/scripts/flash-mac.sh @@ -455,8 +455,29 @@ cp "${INITRAMFS}" "${M1}/initramfs.cpio.gz" # (e.g. AC_BOOT_PIECE=babypat flash-mac.sh ...). Kernel resolves the # name to /pieces/.mjs at boot — see ac-native.c:3853. BOOT_PIECE="${AC_BOOT_PIECE:-notepat}" -printf '{"handle":"%s","piece":"%s","sub":"%s","email":"%s","udpMidiBroadcast":true}\n' \ - "${USER_HANDLE}" "${BOOT_PIECE}" "${USER_SUB}" "${USER_EMAIL}" | tee "${M1}/config.json" >/dev/null + +# Write a device config.json. Base identity fields come from the shell vars +# (set from the inscription OR the legacy API path); the boot-personalization +# fields (city / colors / mood) are pulled straight from the inscription's +# usbConfig so `ac-inscribe --city/--mood` + handle-colors actually reach the +# device — the old hardcoded printf silently dropped them. +write_device_config() { # $1=dest $2=udp(1=include udpMidiBroadcast) + node -e ' + const fs = require("fs"); + const [dest, handle, piece, sub, email, udp, insc] = process.argv.slice(1); + const cfg = { handle, piece, sub, email }; + if (udp === "1") cfg.udpMidiBroadcast = true; + try { + const c = (JSON.parse(fs.readFileSync(insc, "utf8")).usbConfig) || {}; + if (c.city) cfg.city = c.city; + if (Array.isArray(c.colors) && c.colors.length) cfg.colors = c.colors; + if (c.mood) cfg.mood = c.mood; + } catch (e) { /* no inscription (anon/legacy) — base fields only */ } + fs.writeFileSync(dest, JSON.stringify(cfg) + "\n"); + ' "$1" "${USER_HANDLE}" "${BOOT_PIECE}" "${USER_SUB}" "${USER_EMAIL}" "$2" "${INSCRIPTION_FILE}" +} +write_device_config "${M1}/config.json" 1 +log " config.json: $(cat "${M1}/config.json")" # Build merged wifi_creds.json (presets + preserved + optional override) # once, reuse for both partitions. @@ -510,8 +531,7 @@ linux /EFI/BOOT/KERNEL.EFI initrd /initramfs.cpio.gz options console=tty0 quiet loglevel=3 vt.global_cursor_default=0 init=/init nomodeset efi=noruntime EOF -printf '{"handle":"%s","piece":"%s","sub":"%s","email":"%s"}\n' \ - "${USER_HANDLE}" "${BOOT_PIECE}" "${USER_SUB}" "${USER_EMAIL}" | tee "${M2}/config.json" >/dev/null +write_device_config "${M2}/config.json" 0 [ -f "${WIFI_MERGED}" ] && cp "${WIFI_MERGED}" "${M2}/wifi_creds.json" # --- verify (sha256 round-trip on every kernel + initramfs copy) --- -- 2.51.2 From b01c2efdcc6e2eef7ded59d16fbbb187a654a9ce Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 24 Jun 2026 12:34:56 -0700 Subject: [PATCH 06/11] =?UTF-8?q?notepat=20(native):=20chromatic=20sampler?= =?UTF-8?q?=20v2=20=E2=80=94=20clamp=20pitch,=20anchor=20per-key,=20record?= =?UTF-8?q?=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clamp sample resample to ±1 octave (clampSampleTone) on play + pitch-bend so a mis-detected fundamental can't scream/rumble ('way too high/low'). - Per-key samples anchor to the pitch of the key they were recorded on (noteToFreq), so that key plays at 1.0x and neighbours pitch relative — deterministic, no detection guesswork. Global keeps detected f0. - Home: silently switch into sample voice, clear the per-key bank, and record a fresh global sample immediately (NO click — capture starts on press). - End: silently switch into sample voice, arm per-key, emit a click (safe — pressed before the record key). - Recording feedback: pads glow pulsing red while capturing (Home=all melodic pads, per-key=that pad). - Per-pad waveform: keys carrying a custom sample render its peak envelope (precomputed at record time via sampleWaveform). --- fedac/native/pieces/notepat.mjs | 108 +++++++++++++++++++++++++++----- 1 file changed, 92 insertions(+), 16 deletions(-) diff --git a/fedac/native/pieces/notepat.mjs b/fedac/native/pieces/notepat.mjs index d280fabea..60df89f45 100644 --- a/fedac/native/pieces/notepat.mjs +++ b/fedac/native/pieces/notepat.mjs @@ -119,6 +119,35 @@ function detectFundamental(data, rate, fMin = 50, fMax = 1500) { return f0 >= fMin && f0 <= fMax ? f0 : null; } +// Clamp a sample's playback tone so the resample interval stays within ±1 +// octave of its base pitch. speed = tone/base in the engine, so this caps the +// ratio at 0.5×–2× — a wrong f0 estimate can shift the timbre but never scream +// or rumble off the keyboard. +function clampSampleTone(targetFreq, base) { + const b = base > 0 ? base : SAMPLE_BASE_FREQ; + const ratio = Math.max(0.5, Math.min(2.0, targetFreq / b)); + return b * ratio; +} + +// Downsample a recorded buffer to a small peak envelope for cheap per-pad +// waveform rendering (computed once at record time, not per frame). +function sampleWaveform(data, bins = 20) { + if (!data || !data.length) return null; + const out = new Array(bins).fill(0); + const step = data.length / bins; + for (let i = 0; i < bins; i++) { + const start = Math.floor(i * step); + const end = Math.min(data.length, Math.floor((i + 1) * step)); + let peak = 0; + for (let j = start; j < end; j++) { + const a = data[j] < 0 ? -data[j] : data[j]; + if (a > peak) peak = a; + } + out[i] = peak; + } + return out; +} + // Per-key sample bank: End key arms, tone key records to that key only let sampleBank = {}; // key -> { data: Float32Array, len: number, rate: number } let globalSample = null; // { data: Float32Array, len: number, rate: number } — Home recording @@ -307,7 +336,7 @@ function applyPitchShiftToActiveSounds(force = false) { for (const k of Object.keys(sounds)) { const s = sounds[k]; if (s && s.synth && s.baseFreq) { - if (s.isSample) s.synth.update({ tone: s.baseFreq * factor, base: s.sampleBase || SAMPLE_BASE_FREQ }); + if (s.isSample) { const sb = s.sampleBase || SAMPLE_BASE_FREQ; s.synth.update({ tone: clampSampleTone(s.baseFreq * factor, sb), base: sb }); } else s.synth.update({ tone: s.baseFreq * factor }); } } @@ -2410,7 +2439,7 @@ function stopSampleRecording(sound, reason = "stop") { return len; } -function setWave(nextWave, sound) { +function setWave(nextWave, sound, { silent = false } = {}) { if (!nextWave) return; // Picking a basic wave (sine/triangle/.../sample) reroutes OFF any GM // instrument — the GM program overrides `wave` when set, so clear it so @@ -2427,8 +2456,9 @@ function setWave(nextWave, sound) { wave = nextWave; waveIndex = wavetypes.indexOf(nextWave); if (waveIndex < 0) waveIndex = 0; - // Announce wave type - sound?.speak?.(nextWave); + // Announce wave type (skipped when silent — e.g. Home jumps in and records + // immediately, so a spoken "sample" or blip would land in the take). + if (!silent) sound?.speak?.(nextWave); if (wave === "sample") { const mic = sound?.microphone || {}; @@ -2436,12 +2466,12 @@ function setWave(nextWave, sound) { // Open hot-mic so device stays ready — recording is instant after this. // Always call open(); C side is idempotent if already hot. sound?.microphone?.open?.(); - playWaveSound(sound, wave); + if (!silent) playWaveSound(sound, wave); console.log(`[sample] wave-enter: loaded=${sampleLoaded} len=${mic.sampleLength || 0} rate=${mic.sampleRate || 0} connected=${!!mic.connected} hot=${!!mic.hot} device=${mic.device || "none"} err=${mic.lastError || ""}`); } else { // Close hot-mic when leaving sample mode to free the device if (prev === "sample") sound?.microphone?.close?.(); - playWaveSound(sound, wave); + if (!silent) playWaveSound(sound, wave); } syncVoiceIndex(); // keep the global voice chooser in lockstep } @@ -2931,18 +2961,28 @@ function act({ event: e, sound, wifi, system }) { } return; } - // Home key: hold to record GLOBAL sample - if (key === "home" && wave === "sample" && !recording && !perKeyRecording) { + // Home key: jump straight into sample mode (silently — recording starts + // on this same press, so NO click/speak that would land in the take), + // clear all custom per-key samples, and record a fresh GLOBAL sample + // while held. + if (key === "home" && !recording && !perKeyRecording) { + if (wave !== "sample") setWave("sample", sound, { silent: true }); + sampleBank = {}; // clear custom per-key samples + lastLoadedSample = null; // force reload on next play const ok = !!sound?.microphone?.rec?.(); recording = ok; recPointerId = null; if (ok) recStartTime = Date.now(); - console.log(`[mic] rec-home: ok=${ok}`); + console.log(`[mic] rec-home: ok=${ok} (cleared per-key bank)`); return; } - // End key: arm per-key recording mode - if (key === "end" && wave === "sample") { + // End key: jump into sample mode and arm per-key recording. A click is OK + // here — End is pressed BEFORE the note/record key, so it won't be + // captured (unlike Home, which records on its own press). + if (key === "end") { + if (wave !== "sample") setWave("sample", sound, { silent: true }); endArmed = true; + sound?.synth?.({ type: "square", tone: 1760, duration: 0.012, volume: 0.4, attack: 0.0004, decay: 0.01 }); console.log(`[sample-bank] armed for per-key recording`); return; } @@ -3187,8 +3227,12 @@ function act({ event: e, sound, wifi, system }) { lastLoadedSample = targetSample; } const sampleBase = targetSample?.base || SAMPLE_BASE_FREQ; + // Clamp the resample ratio to ±1 octave (0.5×–2×). The engine plays + // at speed = tone/base, so a mis-detected base could otherwise scream + // or rumble; clamping the musical interval keeps every key sane. + const sampleTone = clampSampleTone(playFreq, sampleBase); const smp = sound.sample.play({ - tone: playFreq, base: sampleBase, volume: vol, pan, loop: true, + tone: sampleTone, base: sampleBase, volume: vol, pan, loop: true, }); if (smp) { rememberSound(key, { synth: smp, note: letter, octave: noteOctave, baseFreq: freq, isSample: true, sampleBase, gridOffset: offset, baseVol }, system, velocity); @@ -3255,7 +3299,8 @@ function act({ event: e, sound, wifi, system }) { const buf = new Float32Array(data); const rate = sound.microphone?.sampleRate || 48000; const base = detectFundamental(buf, rate) || SAMPLE_BASE_FREQ; - globalSample = { data: buf, len: data.length, rate, base }; + const waveform = sampleWaveform(buf); + globalSample = { data: buf, len: data.length, rate, base, waveform }; lastLoadedSample = null; // force reload on next key press console.log(`[sample-bank] global sample saved (${data.length} samples, base ${base.toFixed(1)}Hz)`); } @@ -3284,9 +3329,15 @@ function act({ event: e, sound, wifi, system }) { console.log(`[perc-bank] saved ${data.length} samples to drum '${recDrum}'`); } else { const buf = new Float32Array(data); - const base = detectFundamental(buf, rate) || SAMPLE_BASE_FREQ; - sampleBank[key] = { data: buf, len: data.length, rate, base }; - console.log(`[sample-bank] saved ${data.length} samples to key '${key}' (base ${base.toFixed(1)}Hz)`); + // Anchor a per-key sample to the pitch of the key it was recorded + // on: pressing that key plays it at 1.0× (its natural pitch), + // neighbours pitch relative to it — deterministic, no detection + // guesswork. The ±1-octave clamp at play bounds any octave shift. + const recOctave = octave + recOffset; + const base = noteToFreq(recLetter, recOctave) || SAMPLE_BASE_FREQ; + const waveform = sampleWaveform(buf); + sampleBank[key] = { data: buf, len: data.length, rate, base, waveform }; + console.log(`[sample-bank] saved ${data.length} samples to key '${key}' (anchor ${base.toFixed(1)}Hz)`); } sampleLoaded = true; // Confirmation beep @@ -7188,6 +7239,31 @@ function paint({ wipe, ink, box, line, write, screen, sound, system, trackpad, p ink(fg, fg, fg); } + // Recording overlay: the pad being recorded glows pulsing red. Home + // records globally → every melodic pad reads red; per-key → just that + // pad. Drum pads are excluded (sampling is melodic). + if (!isKit && (recording || perKeyRecording === key)) { + const pulse = 110 + Math.floor(90 * Math.abs(Math.sin(frame * 0.25))); + ink(225, 30, 30, pulse); + box(x, y, btnW, btnH, true); + ink(255, 90, 90); + box(x, y, btnW, btnH, "outline"); + ink(255, 255, 255); // white label reads on red + } else if (!isKit && wave === "sample" && key && sampleBank[key]?.waveform && btnH > 10) { + // Per-pad waveform: a key carrying a custom sample shows its + // recorded envelope (precomputed peaks — cheap, drawn once/frame). + const wf = sampleBank[key].waveform; + const midY = y + btnH / 2; + const stepX = btnW / wf.length; + const amp = btnH * 0.38; + ink(dark ? 130 : 70, dark ? 225 : 150, dark ? 255 : 210, 150); + for (let i = 0; i < wf.length; i++) { + const h = Math.max(1, Math.floor(wf[i] * amp)); + box(Math.floor(x + i * stepX), Math.floor(midY - h), Math.max(1, Math.ceil(stepX)), h * 2, true); + } + ink(dark ? 200 : 50, dark ? 200 : 50, dark ? 210 : 60); // restore label ink + } + // Caps follow shift: lowercase by default, uppercase only while // SHIFT is physically held. Sharp/black-key labels stay lower- // case regardless of shift (matches the system fork at -- 2.51.2 From 1c6e504d6a8242cbf6845f542119c422507efcc7 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 24 Jun 2026 14:41:11 -0700 Subject: [PATCH 07/11] menuband: per-key sampler + hybrid GM fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the native notepat sampler model to Menuband: - Per-key sample buffers (perKeyBuffers/perKeyAnchorMidi) anchored to the key recorded on (that key = natural pitch, neighbours relative); noteOn returns Bool and the synth falls through to the GM instrument when a key has no sample — a hybrid kit where instruments shift per key. - ` (backtick) = 'Home': clear all per-key samples + record the global sample. - ~ (Shift+`) held + a note key = record a per-key sample into that key. - noteOff now releases the sample voice AND falls through to a GM note-off so GM-fallback keys never stick. Per-voice baseCents keeps pitch-bend correct for both per-key and global voices. Compiles clean on default + -DMAC_APP_STORE. Palette 'Sample' entry still TODO. --- ...attabop360.mjs => render-fluttabap360.mjs} | 0 ...abop360.illy.txt => fluttabap360.illy.txt} | 0 .../Sources/MenuBand/MenuBandController.swift | 41 ++++++++ .../MenuBand/MenuBandSampleVoice.swift | 98 +++++++++++++++---- .../Sources/MenuBand/MenuBandSynth.swift | 23 +++-- 5 files changed, 135 insertions(+), 27 deletions(-) rename pop/marimba/bin/{render-fattabop360.mjs => render-fluttabap360.mjs} (100%) rename pop/marimba/{fattabop360.illy.txt => fluttabap360.illy.txt} (100%) diff --git a/pop/marimba/bin/render-fattabop360.mjs b/pop/marimba/bin/render-fluttabap360.mjs similarity index 100% rename from pop/marimba/bin/render-fattabop360.mjs rename to pop/marimba/bin/render-fluttabap360.mjs diff --git a/pop/marimba/fattabop360.illy.txt b/pop/marimba/fluttabap360.illy.txt similarity index 100% rename from pop/marimba/fattabop360.illy.txt rename to pop/marimba/fluttabap360.illy.txt diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index f2bddb97e..2c3be2089 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -138,6 +138,12 @@ final class MenuBandController { private let midiModeKey = "notepat.midiMode" private let typeModeKey = "notepat.typeMode" + + /// True while ~ (Shift+`) is held — the next note key records a per-key + /// sample into that key instead of playing it. + private var perKeySampleArmed = false + /// The MIDI note currently capturing a per-key sample (nil = none). + private var perKeySampleRecordingMidi: UInt8? = nil private let octaveShiftKey = "notepat.octaveShift" private let melodicProgramKey = "notepat.melodicProgram" private let keymapKey = "notepat.keymap" @@ -1598,6 +1604,9 @@ final class MenuBandController { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if isDown { + // "Home" gesture: clear all per-key custom samples, then + // record a fresh global sample while held. + self.synth.clearPerKeySamples() self.synth.startSampleRecording() // Nudge the AppDelegate so the menubar icon immediately // picks up the red "REC" tint on the chip. @@ -2674,9 +2683,41 @@ final class MenuBandController { // (TimePitch, cents = (midi−60)×100). Pressing any number key flips back to a // GM voice (`setMelodicProgram` exits sample mode internally). if keyCode == 50 { + // ~ (Shift+`) ARMS per-key recording while held — it does NOT + // record the global sample. Plain ` records the global sample + // (and clears per-key customs) — the "Home" gesture. + if lingerSide != .none { + perKeySampleArmed = isDown + if !isDown, let m = perKeySampleRecordingMidi { + // ~ released mid per-key capture — finalize it. + if synth.stopSampleRecording() { setSampleBackend(true) } + perKeySampleRecordingMidi = nil + _ = m + } + onInstrumentVisualChange?() + return true + } return handleSampleRecordKey(isDown: isDown, isRepeat: isRepeat, source: typeMode ? "type" : "local") } + // ~ held + a note key = record a per-key sample into that key + // (anchored to its pitch), instead of playing the note. + if perKeySampleArmed, + let note = MenuBandLayout.midiNote(forKeyCode: keyCode, + octaveShift: octaveShift, + keymap: keymap) { + if isDown && !isRepeat { + perKeySampleRecordingMidi = note + synth.startSampleRecording(forKey: note) + onInstrumentVisualChange?() + } else if !isDown && perKeySampleRecordingMidi == note { + if synth.stopSampleRecording() { setSampleBackend(true) } + perKeySampleRecordingMidi = nil + onInstrumentVisualChange?() + } + return true + } + // Number-row digits 0–9 select a voice using the chooser // grid's 1-based numbering: 0 / 00 / 000 is the MIDI // passthrough slot, "1" picks GM program 0 (Acoustic Grand, diff --git a/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift b/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift index 0ae02a3ef..074d37410 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift @@ -66,6 +66,17 @@ final class MenuBandSampleVoice { /// under `bufferLock` alongside `recordedBuffer`. private var detectedFundamental: Double = 261.63 + /// Per-key custom samples (hybrid kit). A key with an entry here plays its + /// own buffer anchored to the key it was recorded on (that key = natural + /// pitch, neighbours relative); keys with no per-key sample fall back to + /// the global recording, and the synth falls back to GM if neither exists. + /// All guarded by `bufferLock`. + private var perKeyBuffers: [UInt8: AVAudioPCMBuffer] = [:] + private var perKeyAnchorMidi: [UInt8: UInt8] = [:] + /// When set, the next stopRecording() commits into this key's slot instead + /// of the global buffer (driven by the ~+key gesture). + private var pendingPerKeyMidi: UInt8? = nil + /// Active recording state. We tap the engine's input node into a /// scratch buffer; on `stopRecording` we trim to actual length and /// promote to `recordedBuffer`. @@ -155,6 +166,10 @@ final class MenuBandSampleVoice { // `.pitch` (cents) moves. let timePitch = AVAudioUnitTimePitch() var midi: UInt8 = 60 + // Note pitch in cents WITHOUT the live trackpad bend. Per-key samples + // anchor to their recorded key; the global sample is chromatic from + // the detected fundamental. setBend re-adds the bend on top of this. + var baseCents: Float = 0 var releaseWork: DispatchWorkItem? } @@ -484,6 +499,10 @@ final class MenuBandSampleVoice { return false } recording = false + // Capture + clear the per-key target up front so a discarded (too + // short) take can't leak it into the next record. + let perKeyTarget = pendingPerKeyMidi + pendingPerKeyMidi = nil scheduleHotMicStop() guard let scratch = recordScratch else { recordScratch = nil @@ -518,10 +537,16 @@ final class MenuBandSampleVoice { NSLog("MenuBand SampleVoice: sample shaped peak \(stats.peakBefore) -> \(stats.peakAfter), rms \(stats.rmsBefore) -> \(stats.rmsAfter), gain=\(stats.gain), f0=\(f0.map { String(format: "%.1fHz", $0) } ?? "untuned→C4")") } bufferLock.lock() - recordedBuffer = out - detectedFundamental = f0 ?? 261.63 + if let km = perKeyTarget { + // Per-key commit: anchor this sample to the key it was recorded on. + perKeyBuffers[km] = out + perKeyAnchorMidi[km] = km + } else { + recordedBuffer = out + detectedFundamental = f0 ?? 261.63 + } bufferLock.unlock() - NSLog("MenuBand SampleVoice: recording captured \(frames) frames (\(Double(frames) / sampleRate) s), trimmed \(startFrame) leading frames") + NSLog("MenuBand SampleVoice: captured \(frames) frames (\(Double(frames) / sampleRate) s)\(perKeyTarget.map { " → per-key midi \($0)" } ?? " → global")") return true } @@ -981,25 +1006,57 @@ final class MenuBandSampleVoice { bendSemitones = amount * 12.0 for (_, v) in voices { if v.node.isPlaying { - v.timePitch.pitch = pitchCents(forNote: v.midi) + v.timePitch.pitch = min(max(v.baseCents + bendSemitones * 100.0, + -Self.maxPitchCents), Self.maxPitchCents) } } } - func noteOn(_ midi: UInt8, velocity: UInt8 = 100, channel: UInt8 = 0) { + /// Arm the next recording to commit into a specific key's slot (the ~+key + /// gesture) rather than the global buffer. Pass nil for a global record. + func startRecording(forKey midi: UInt8?) { + pendingPerKeyMidi = midi + startRecording() + } + + /// True if THIS key has a sample (per-key or global) that would sound. + func hasSample(forKey midi: UInt8) -> Bool { + bufferLock.lock(); defer { bufferLock.unlock() } + return perKeyBuffers[midi] != nil || recordedBuffer != nil + } + + /// Clear every per-key custom sample (the ` "Home" gesture). The global + /// recording is left alone — backtick re-records it right after. + func clearPerKeySamples() { bufferLock.lock() - let buf = recordedBuffer + perKeyBuffers.removeAll() + perKeyAnchorMidi.removeAll() bufferLock.unlock() - guard let buf = buf else { - // No recording yet — silent noteOn. The synth shouldn't - // route to this backend in that state, but defend anyway. - NSLog("MenuBand SampleVoice: noteOn ignored — no recorded buffer") - return - } + NSLog("MenuBand SampleVoice: cleared all per-key samples") + } + + /// Returns true if a sample played for this key, false if there's nothing + /// to play (no per-key sample AND no global recording) — in which case the + /// synth falls back to the GM instrument for this note (hybrid kit). + @discardableResult + func noteOn(_ midi: UInt8, velocity: UInt8 = 100, channel: UInt8 = 0) -> Bool { + bufferLock.lock() + let perKey = perKeyBuffers[midi] + let anchor = perKeyAnchorMidi[midi] + let global = recordedBuffer + bufferLock.unlock() + // Per-key sample wins; else the global recording; else GM fallback. + guard let buf = perKey ?? global else { return false } guard attached, engine != nil else { NSLog("MenuBand SampleVoice: noteOn ignored — voice not attached") - return + return false } + // Per-key samples are anchored to the key they were recorded on (that + // key plays at 0 cents); the global sample is chromatic from its + // detected fundamental. + let baseCents: Float = (perKey != nil) + ? Float(Int(midi) - Int(anchor ?? midi)) * 100.0 + : cents(forNote: midi) // The controller rotates `nextMelodicChannel()` 0..3 on every // press, so the same midi can land on a fresh channel while // the previous channel's slot is still mid-release (~80ms @@ -1013,7 +1070,7 @@ final class MenuBandSampleVoice { let slot = nextSlot(channel: channel, midi: midi) guard let voice = ensureVoice(channel: channel, slot: slot) else { NSLog("MenuBand SampleVoice: noteOn ignored — failed to allocate voice") - return + return false } // Cancel any pending release-fade — we're retriggering the @@ -1022,12 +1079,12 @@ final class MenuBandSampleVoice { voice.releaseWork = nil voice.midi = midi - // Compose the pitch (cents) from the note AND the current - // trackpad pitch bend so dragging the cursor while a sample - // voice rings shifts pitch in real time — mirror of the - // MIDISynth pitch-bend path. `.rate` stays at its 1.0 default, + voice.baseCents = baseCents + // Compose pitch from the note's base cents AND the live trackpad bend + // so dragging the cursor shifts pitch in real time. `.rate` stays 1.0, // so duration/speed never changes with pitch. - voice.timePitch.pitch = pitchCents(forNote: midi) + voice.timePitch.pitch = min(max(baseCents + bendSemitones * 100.0, + -Self.maxPitchCents), Self.maxPitchCents) voice.node.volume = Float(velocity) / 127.0 if voice.node.isPlaying { @@ -1042,6 +1099,7 @@ final class MenuBandSampleVoice { voice.node.scheduleBuffer(buf, at: nil, options: [.interrupts, .loops]) { /* no-op */ } voice.node.play() + return true } /// Immediately silence every Voice currently playing `midi` @@ -1118,7 +1176,7 @@ final class MenuBandSampleVoice { /// True if a recording exists and is long enough to be playable. var hasRecording: Bool { bufferLock.lock(); defer { bufferLock.unlock() } - return recordedBuffer != nil + return recordedBuffer != nil || !perKeyBuffers.isEmpty } /// Show an NSAlert explaining how to enable microphone access for diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift index 89b45ca37..3d96f3c05 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -1797,16 +1797,23 @@ final class MenuBandSynth { /// Begin recording into the sample voice's buffer. Wakes the audio /// engine first if it was suspended for idle, since the input-node /// tap won't deliver frames against a paused graph. - func startSampleRecording() { + func startSampleRecording() { startSampleRecording(forKey: nil) } + + /// Start recording. `forKey` non-nil commits into that key's per-key slot + /// (the ~+key gesture); nil records the global sample (the ` gesture). + func startSampleRecording(forKey midi: UInt8?) { guard started else { return } - NSLog("MenuBand SampleVoice: synth startSampleRecording (playbackEngineRunning=\(engine.isRunning))") + NSLog("MenuBand SampleVoice: synth startSampleRecording forKey=\(midi.map(String.init) ?? "global") (playbackEngineRunning=\(engine.isRunning))") _ = resumeAudioEngineIfNeeded() sampleRecordingActive = true sampleVoice.setOutputEnabled(false) sampleVoice.panic() - sampleVoice.startRecording() + sampleVoice.startRecording(forKey: midi) } + /// Clear all per-key custom samples (the ` "Home" gesture). + func clearPerKeySamples() { sampleVoice.clearPerKeySamples() } + /// Stop recording. Returns true iff a usable buffer (≥100 ms) was /// captured; the caller flips the active backend to `.sample` only /// in that case. @@ -2015,9 +2022,9 @@ final class MenuBandSynth { // Sample backend — same melodic-only routing semantics as // radio. Drums always continue down to the GM path. if usingSampleBackend && channel != 9 { - NSLog("MenuBand SampleVoice: routing noteOn to sample midi=\(midi) channel=\(channel)") - sampleVoice.noteOn(midi, velocity: velocity, channel: channel) - return + // Per-key/global sample plays it; if this key has no sample, fall + // through to the GM instrument (hybrid kit — instruments per key). + if sampleVoice.noteOn(midi, velocity: velocity, channel: channel) { return } } // Drums (channel 9) always route through MIDISynth/drums sampler // — drum kits are GM regardless of melodic backend choice. @@ -2151,8 +2158,10 @@ final class MenuBandSynth { return } if usingSampleBackend && channel != 9 { + // Release the sample voice, then DON'T return — fall through to + // also send a GM note-off, so keys that fell back to GM (no sample) + // don't get stuck. Both are no-ops for notes they don't own. sampleVoice.noteOff(midi, channel: channel) - return } if channel == 9 { if midiSynthReady, let au = midiSynth?.audioUnit { -- 2.51.2 From 0c48167ec97a54e5e210bf07d01d24127e45dbc2 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 24 Jun 2026 14:53:32 -0700 Subject: [PATCH 08/11] menuband: selectable Sample Voice entry in the popover Adds a Sample Voice button to the popover voice-routing stack that calls setSampleBackend(true) so the mic-sampler is re-selectable from the UI, not only via the backtick record gesture. With nothing recorded yet it falls back to the last GM instrument (hybrid kit). The 128-cell GM grid is geometry-fixed with no spare cell, so a button is the clean insertion point. --- .../Sources/MenuBand/MenuBandPopover.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift index 16fc5c6ff..fc6403771 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -587,6 +587,25 @@ final class MenuBandPopoverViewController: NSViewController { modeButtons.append(b) modeStack.addArrangedSubview(b) } + // Sample Voice — selectable mic-sampler backend. Picking it switches + // the active voice to the recorded sample(s); record with ` (global, + // clears per-key) or ~+key (per-key). Un-sampled keys fall back to the + // last GM instrument, so it doubles as a hybrid kit. + let sampleVoiceBtn = NSButton(title: "Sample Voice", target: self, + action: #selector(sampleVoiceButtonClicked(_:))) + sampleVoiceBtn.bezelStyle = .recessed + sampleVoiceBtn.setButtonType(.momentaryPushIn) + sampleVoiceBtn.controlSize = .regular + sampleVoiceBtn.alignment = .left + sampleVoiceBtn.imagePosition = .imageLeading + sampleVoiceBtn.imageHugsTitle = true + sampleVoiceBtn.image = NSImage(systemSymbolName: "mic.fill", + accessibilityDescription: "Sample Voice") + sampleVoiceBtn.translatesAutoresizingMaskIntoConstraints = false + sampleVoiceBtn.widthAnchor.constraint( + equalToConstant: InstrumentListView.preferredWidth + ).isActive = true + modeStack.addArrangedSubview(sampleVoiceBtn) modeStack.widthAnchor.constraint( equalToConstant: InstrumentListView.preferredWidth ).isActive = true @@ -2054,6 +2073,15 @@ final class MenuBandPopoverViewController: NSViewController { } } + @objc private func sampleVoiceButtonClicked(_ sender: NSButton) { + guard let m = menuBand else { return } + // Activate the mic-sampler backend. If nothing's recorded yet, keys + // fall back to the last GM instrument until you record (` / ~+key). + m.setSampleBackend(true) + applyPopoverRootChrome() + updateInstrumentReadout() + } + @objc private func focusShortcutButtonClicked(_ sender: NSButton) { if isRecordingFocusShortcut { stopFocusShortcutRecording(status: nil) -- 2.51.2 From d3116db22fac2ecae010c798939c8c6cd7d17374 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 24 Jun 2026 15:07:03 -0700 Subject: [PATCH 09/11] menuband: Sample Voice in the real picker + Gamepad behind a toggle - The Sample Voice button was added to a modeStack the popover discards (_ = layoutBlock), so it never showed. Move it into the visible cluster picker (CollapsedPianoWaveformView, the Notepat/Ableton row) and revert the dead popover button. - Gamepad config cluster no longer clutters the full-screen keymap overlay: hidden by default, toggled by a new "Gamepad" button next to Conventional in ExpandedPianoWaveformView. --- .../Sources/MenuBand/MenuBandPopover.swift | 28 ------------------- .../CollapsedPianoWaveformView.swift | 22 +++++++++++++++ .../ExpandedPianoWaveformView.swift | 25 +++++++++++++++++ 3 files changed, 47 insertions(+), 28 deletions(-) diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift index fc6403771..16fc5c6ff 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -587,25 +587,6 @@ final class MenuBandPopoverViewController: NSViewController { modeButtons.append(b) modeStack.addArrangedSubview(b) } - // Sample Voice — selectable mic-sampler backend. Picking it switches - // the active voice to the recorded sample(s); record with ` (global, - // clears per-key) or ~+key (per-key). Un-sampled keys fall back to the - // last GM instrument, so it doubles as a hybrid kit. - let sampleVoiceBtn = NSButton(title: "Sample Voice", target: self, - action: #selector(sampleVoiceButtonClicked(_:))) - sampleVoiceBtn.bezelStyle = .recessed - sampleVoiceBtn.setButtonType(.momentaryPushIn) - sampleVoiceBtn.controlSize = .regular - sampleVoiceBtn.alignment = .left - sampleVoiceBtn.imagePosition = .imageLeading - sampleVoiceBtn.imageHugsTitle = true - sampleVoiceBtn.image = NSImage(systemSymbolName: "mic.fill", - accessibilityDescription: "Sample Voice") - sampleVoiceBtn.translatesAutoresizingMaskIntoConstraints = false - sampleVoiceBtn.widthAnchor.constraint( - equalToConstant: InstrumentListView.preferredWidth - ).isActive = true - modeStack.addArrangedSubview(sampleVoiceBtn) modeStack.widthAnchor.constraint( equalToConstant: InstrumentListView.preferredWidth ).isActive = true @@ -2073,15 +2054,6 @@ final class MenuBandPopoverViewController: NSViewController { } } - @objc private func sampleVoiceButtonClicked(_ sender: NSButton) { - guard let m = menuBand else { return } - // Activate the mic-sampler backend. If nothing's recorded yet, keys - // fall back to the last GM instrument until you record (` / ~+key). - m.setSampleBackend(true) - applyPopoverRootChrome() - updateInstrumentReadout() - } - @objc private func focusShortcutButtonClicked(_ sender: NSButton) { if isRecordingFocusShortcut { stopFocusShortcutRecording(status: nil) diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift index 588323bfb..ebfdcf5fc 100644 --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift @@ -220,6 +220,22 @@ final class CollapsedPianoWaveformView: NSView { modeStack.addArrangedSubview(help) } } + // Sample Voice — selects the mic-sampler backend. Record with ` (global, + // clears per-key) or ~+key (per-key); un-sampled keys fall back to the + // last GM instrument, so it doubles as a hybrid kit. + let sampleBtn = NSButton(title: "Sample", + target: self, + action: #selector(sampleVoiceModeClicked(_:))) + sampleBtn.bezelStyle = .recessed + sampleBtn.setButtonType(.momentaryPushIn) + sampleBtn.controlSize = .small + sampleBtn.imagePosition = .imageLeading + sampleBtn.imageHugsTitle = true + sampleBtn.image = NSImage(systemSymbolName: "mic.fill", + accessibilityDescription: "Sample Voice")? + .withSymbolConfiguration(modeSymbolConfig) + sampleBtn.translatesAutoresizingMaskIntoConstraints = false + modeStack.addArrangedSubview(sampleBtn) arrowsCluster.translatesAutoresizingMaskIntoConstraints = false arrowsCluster.displayMode = .cluster @@ -578,6 +594,12 @@ final class CollapsedPianoWaveformView: NSView { } } + @objc private func sampleVoiceModeClicked(_ sender: NSButton) { + // Switch the active voice to the mic sampler. With nothing recorded + // yet, keys fall back to the last GM instrument until you record. + menuBand?.setSampleBackend(true) + } + @objc private func modeButtonClicked(_ sender: NSButton) { guard let menuBand else { return } let next: Keymap = (sender.tag == 1) ? .ableton : .notepat diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift index 32f006ef5..f2b2f53ee 100644 --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/ExpandedPianoWaveformView.swift @@ -63,6 +63,9 @@ final class ExpandedPianoWaveformView: NSView { /// (moved here from the popover). Scheme picker + connected-controller name. private let gamepadSchemePopUp = NSPopUpButton(frame: .zero, pullsDown: false) private let gamepadStatusLabel = NSTextField(labelWithString: "No controller connected") + /// The bottom-right gamepad config cluster — hidden until the "Gamepad" + /// toggle (next to Conventional) is switched on. + private var gamepadCluster: NSView? private var outlineBorderColor: NSColor = .separatorColor.withAlphaComponent(0.55) @@ -287,6 +290,21 @@ final class ExpandedPianoWaveformView: NSView { modeButtons.append(b) modeStack.addArrangedSubview(b) } + // Gamepad — toggles the controller-config cluster, which is hidden by + // default so it doesn't clutter the full-screen keymap view. + let gamepadToggle = NSButton(title: "Gamepad", target: self, + action: #selector(toggleGamepadCluster(_:))) + gamepadToggle.tag = 2 + gamepadToggle.bezelStyle = .recessed + gamepadToggle.setButtonType(.pushOnPushOff) + gamepadToggle.controlSize = .regular + gamepadToggle.imagePosition = .imageLeading + gamepadToggle.imageHugsTitle = true + gamepadToggle.image = NSImage(systemSymbolName: "gamecontroller", + accessibilityDescription: "Gamepad")? + .withSymbolConfiguration(modeSymbol) + gamepadToggle.translatesAutoresizingMaskIntoConstraints = false + modeStack.addArrangedSubview(gamepadToggle) contentStack.addArrangedSubview(modeStack) for label in [focusHintLabel, octaveHintLabel, layoutHintLabel] { label.font = NSFont.systemFont(ofSize: 10, weight: .bold) @@ -448,6 +466,8 @@ final class ExpandedPianoWaveformView: NSView { cluster.alignment = .centerY cluster.spacing = 10 cluster.translatesAutoresizingMaskIntoConstraints = false + cluster.isHidden = true // shown only when the Gamepad button is on + gamepadCluster = cluster addSubview(cluster) NSLayoutConstraint.activate([ cluster.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -(inset + 8)), @@ -462,6 +482,11 @@ final class ExpandedPianoWaveformView: NSView { refreshGamepadStatus() } + @objc private func toggleGamepadCluster(_ sender: NSButton) { + // Button is pushOnPushOff; mirror its state onto the cluster. + gamepadCluster?.isHidden = (sender.state != .on) + } + @objc private func gamepadSchemeChanged(_ sender: NSPopUpButton) { guard let raw = sender.selectedItem?.representedObject as? String, let scheme = GamepadControlScheme(rawValue: raw) else { return } -- 2.51.2 From a36c4fc147e1126c0884594adb2ebb4288acbf7a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 24 Jun 2026 15:15:42 -0700 Subject: [PATCH 10/11] menuband: SAMPLE cell in the instrument grid, right of MIDI OUT The Sample selector now lives where it belongs: a SAMPLE cell carved off the right end of the top MIDI-OUT row in the bee-vision instrument grid (the actual visible picker), mirroring the MIDI OUT / radio special-cell pattern (dedicated onSampleCommit callback, hit-test, red fill when sampleBackendActive). Removed the earlier buttons that were added to built-but-never-shown mode stacks. --- .../Sources/MenuBand/InstrumentMapView.swift | 51 ++++++++++++++++++- .../CollapsedPianoWaveformView.swift | 28 +++------- 2 files changed, 56 insertions(+), 23 deletions(-) diff --git a/slab/menuband/Sources/MenuBand/InstrumentMapView.swift b/slab/menuband/Sources/MenuBand/InstrumentMapView.swift index f1c46896e..85d3a6c4f 100644 --- a/slab/menuband/Sources/MenuBand/InstrumentMapView.swift +++ b/slab/menuband/Sources/MenuBand/InstrumentMapView.swift @@ -49,6 +49,12 @@ final class InstrumentListView: NSView { /// Fires when the user clicks the MIDI-OUT cell at the top of the /// grid. The popover wires this to `menuBand.toggleMIDIMode()`. var onMidiOutCommit: (() -> Void)? + /// Fires when the SAMPLE cell (right end of the MIDI-OUT row) is clicked. + /// The popover wires this to `menuBand.setSampleBackend(true)`. + var onSampleCommit: (() -> Void)? + /// True while the mic-sampler backend is active — fills the SAMPLE cell + /// the same way `midiModeActive` fills MIDI OUT. + var sampleBackendActive: Bool = false { didSet { needsDisplay = true } } /// Fires whenever the hovered cell changes (including transitions to /// "no hover" → nil). Drives the controller's hover-preview note for /// sonic browsing. @@ -174,8 +180,21 @@ final class InstrumentListView: NSView { /// "0 MIDI OUT" cell — a full-width row at the TOP of the board, above /// the patch grid. Hit-test is exclusive of the patch grid below and /// the radio strip at the bottom. + /// Width of the SAMPLE cell carved off the right end of the top row. + private var sampleCellW: CGFloat { min(86, bounds.width * 0.32) } + private var midiOutRect: NSRect { - NSRect(x: 0, y: 0, width: bounds.width, height: Self.midiOutH) + NSRect(x: 0, y: 0, width: bounds.width - sampleCellW, height: Self.midiOutH) + } + + /// SAMPLE cell — sits to the right of MIDI OUT on the top row. + private var sampleRect: NSRect { + NSRect(x: bounds.width - sampleCellW, y: 0, + width: sampleCellW, height: Self.midiOutH) + } + + private func isSampleHit(_ point: NSPoint) -> Bool { + sampleRect.contains(point) } private func program(at point: NSPoint) -> Int? { @@ -302,6 +321,31 @@ final class InstrumentListView: NSView { y: midiR.midY - size.height / 2)) } + // SAMPLE cell — right end of the top row. Mirrors MIDI OUT: filled + // when the sampler backend is active, outlined when inactive. + let sampleR = sampleRect + if sampleR.intersects(dirtyRect) { + let tint = NSColor.systemRed + let cap = NSBezierPath(roundedRect: sampleR.insetBy(dx: 1.75, dy: 1.5), + xRadius: 3, yRadius: 3) + if sampleBackendActive { + tint.withAlphaComponent(0.85).setFill(); cap.fill() + tint.setStroke(); cap.lineWidth = 1.4; cap.stroke() + } else { + tint.withAlphaComponent(0.10).setFill(); cap.fill() + tint.withAlphaComponent(0.55).setStroke(); cap.lineWidth = 0.8; cap.stroke() + } + let labelColor: NSColor = sampleBackendActive ? .white : .labelColor + let str = NSAttributedString(string: "SAMPLE", attributes: [ + .font: NSFont.systemFont(ofSize: 10.5, weight: .semibold), + .foregroundColor: labelColor.withAlphaComponent(sampleBackendActive ? 1.0 : 0.85), + .kern: 0.4, + ]) + let size = str.size() + str.draw(at: NSPoint(x: sampleR.midX - size.width / 2, + y: sampleR.midY - size.height / 2)) + } + // Radio-station cells in the full-width strip at the BOTTOM, below // the patch grid. Teal (vs. the MIDI cell's accent) so the radio // strip reads distinctly; the tuned station fills solid while the @@ -471,6 +515,11 @@ final class InstrumentListView: NSView { onMidiOutCommit?() return } + // SAMPLE cell — switch to the mic-sampler backend. No audible preview. + if isSampleHit(pt) { + onSampleCommit?() + return + } dragging = true if let p = program(at: pt) { // Treat the press as a hover-into-this-cell so the preview note diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift index ebfdcf5fc..53a23fffd 100644 --- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift +++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift @@ -149,6 +149,11 @@ final class CollapsedPianoWaveformView: NSView { self?.menuBand?.toggleMIDIMode() self?.refresh() } + // SAMPLE cell (right of MIDI OUT) — switch to the mic-sampler backend. + instrumentList.onSampleCommit = { [weak self] in + self?.menuBand?.setSampleBackend(true) + self?.refresh() + } instrumentList.onHover = { [weak self] prog in self?.menuBand?.setInstrumentPreview(prog.map { UInt8($0) }) self?.refresh() @@ -220,22 +225,6 @@ final class CollapsedPianoWaveformView: NSView { modeStack.addArrangedSubview(help) } } - // Sample Voice — selects the mic-sampler backend. Record with ` (global, - // clears per-key) or ~+key (per-key); un-sampled keys fall back to the - // last GM instrument, so it doubles as a hybrid kit. - let sampleBtn = NSButton(title: "Sample", - target: self, - action: #selector(sampleVoiceModeClicked(_:))) - sampleBtn.bezelStyle = .recessed - sampleBtn.setButtonType(.momentaryPushIn) - sampleBtn.controlSize = .small - sampleBtn.imagePosition = .imageLeading - sampleBtn.imageHugsTitle = true - sampleBtn.image = NSImage(systemSymbolName: "mic.fill", - accessibilityDescription: "Sample Voice")? - .withSymbolConfiguration(modeSymbolConfig) - sampleBtn.translatesAutoresizingMaskIntoConstraints = false - modeStack.addArrangedSubview(sampleBtn) arrowsCluster.translatesAutoresizingMaskIntoConstraints = false arrowsCluster.displayMode = .cluster @@ -448,6 +437,7 @@ final class CollapsedPianoWaveformView: NSView { instrumentList.selectedProgram = menuBand.effectiveMelodicProgram instrumentList.midiModeActive = menuBand.midiMode instrumentList.radioBackendActive = (menuBand.instrumentBackend == .kpbj) + instrumentList.sampleBackendActive = (menuBand.instrumentBackend == .sample) instrumentList.selectedRadioStationID = menuBand.radioStation.id applyInstrumentReadout(safe: safe, familyColor: familyColor, isDark: isDark) @@ -594,12 +584,6 @@ final class CollapsedPianoWaveformView: NSView { } } - @objc private func sampleVoiceModeClicked(_ sender: NSButton) { - // Switch the active voice to the mic sampler. With nothing recorded - // yet, keys fall back to the last GM instrument until you record. - menuBand?.setSampleBackend(true) - } - @objc private func modeButtonClicked(_ sender: NSButton) { guard let menuBand else { return } let next: Keymap = (sender.tag == 1) ? .ableton : .notepat -- 2.51.2 From 9ee8178a5253f0765d7a525f30b585fdb8750701 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 24 Jun 2026 15:21:18 -0700 Subject: [PATCH 11/11] menuband: About language map plays like a sample board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Speak the language name on mouse-DOWN (and on slide into each cell) for instant, playable feedback — hold/press starts the word immediately instead of waiting for release. - Defer the actual language switch (the heavy About-content rebuild) to mouse UP and to the next runloop tick, and skip it entirely when re-pressing the current language. So sliding across cells plays names without tearing the view down mid-drag, and the switch no longer lags the press. --- .../Sources/MenuBand/AboutWindow.swift | 27 ++++++++++++------- .../Sources/MenuBand/LanguageMapView.swift | 26 +++++++++++++----- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/slab/menuband/Sources/MenuBand/AboutWindow.swift b/slab/menuband/Sources/MenuBand/AboutWindow.swift index 7e82de25d..8c897084c 100644 --- a/slab/menuband/Sources/MenuBand/AboutWindow.swift +++ b/slab/menuband/Sources/MenuBand/AboutWindow.swift @@ -295,7 +295,10 @@ final class AboutWindowController: NSWindowController, NSWindowDelegate { } langMap.selectedCode = Localization.current langMap.translatesAutoresizingMaskIntoConstraints = false - langMap.onPick = { [weak self] item in self?.applyLanguage(item) } + // Press/slide speaks the name instantly (sample-board); release commits + // the actual language switch. + langMap.onPlay = { [weak self] item in self?.onSpeakLanguage?(item.label, item.code) } + langMap.onPick = { [weak self] item in self?.switchLanguage(item) } langMap.widthAnchor.constraint(equalToConstant: 264).isActive = true // Height comes from the view's intrinsic size (rows × rowH), so the // grid grows with the number of languages. @@ -447,18 +450,22 @@ final class AboutWindowController: NSWindowController, NSWindowDelegate { /// Pick a language from the flat map: speak its own name through the /// Menu Band fx (easter egg — works even when re-picking the active /// language), then switch + rebuild the panel so every string re-reads. - private func applyLanguage(_ item: LanguageMapView.Item) { - // Speak first so the audio fires regardless of whether this is a - // real switch — re-clicking your current language still talks. - onSpeakLanguage?(item.label, item.code) + /// Commit a language switch (on release). The spoken name already fired on + /// press via `onPlay`, so this does NOT speak — it only applies the strings. + private func switchLanguage(_ item: LanguageMapView.Item) { + // No-op if it's already the current language — keeps rapid playing snappy. guard item.code != Localization.current else { return } Localization.current = item.code - // Rebuild the About content to apply the new strings. Cheaper than - // tearing down the window. - if let content = window?.contentView { - for sub in content.subviews { sub.removeFromSuperview() } + // Defer the (heavier) content rebuild to the next runloop tick so it + // never blocks the press / the audio. The string swap applies a beat + // after the sound, keeping the tap feeling instant. + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if let content = self.window?.contentView { + for sub in content.subviews { sub.removeFromSuperview() } + } + self.buildContent() } - buildContent() } // MARK: - Icon loader diff --git a/slab/menuband/Sources/MenuBand/LanguageMapView.swift b/slab/menuband/Sources/MenuBand/LanguageMapView.swift index 9e9de8d7e..c16fa503c 100644 --- a/slab/menuband/Sources/MenuBand/LanguageMapView.swift +++ b/slab/menuband/Sources/MenuBand/LanguageMapView.swift @@ -17,7 +17,11 @@ final class LanguageMapView: NSView { didSet { invalidateIntrinsicContentSize(); needsDisplay = true } } var selectedCode: String = "" { didSet { needsDisplay = true } } - /// Fires when the user clicks a cell. + /// Fires on press / slide-into a cell — speak that language's name only + /// (instant, no UI rebuild) so the map plays like a sample board. + var onPlay: ((Item) -> Void)? + /// Fires on release — commit the actual language switch (the heavier + /// rebuild) for whichever cell the cursor lifted over. var onPick: ((Item) -> Void)? /// Two columns; each row is this tall. @@ -106,22 +110,30 @@ final class LanguageMapView: NSView { override func mouseExited(with event: NSEvent) { if hovered != nil { hovered = nil; needsDisplay = true } } - // Press-and-release like a key: the cell lights "played" while held, - // and the pick (switch + spoken name) commits on release over the same - // cell — so the down state reads as the moment of play. + // Sample-board feel: fire on mouse-DOWN (instant — the spoken name starts + // the moment you press, so holding hears the whole word), and re-fire as + // you DRAG across cells so the language names can be "played" like pads. + // Release just clears the lit state. Re-triggering is cheap — the host + // speaks immediately and skips the heavy rebuild when the code is unchanged. override func mouseDown(with event: NSEvent) { - pressed = index(at: convert(event.locationInWindow, from: nil)) + let i = index(at: convert(event.locationInWindow, from: nil)) + pressed = i needsDisplay = true + if let i = i { onPlay?(items[i]) } // instant sound, no rebuild } override func mouseDragged(with event: NSEvent) { let i = index(at: convert(event.locationInWindow, from: nil)) - if i != pressed { pressed = i; needsDisplay = true } + if i != pressed { + pressed = i + needsDisplay = true + if let i = i { onPlay?(items[i]) } // slide to play the next name + } } override func mouseUp(with event: NSEvent) { let i = index(at: convert(event.locationInWindow, from: nil)) pressed = nil needsDisplay = true - if let i = i { onPick?(items[i]) } + if let i = i { onPick?(items[i]) } // commit the switch on release } override func draw(_ dirtyRect: NSRect) {