diff --git a/pop/menuband/bin/render-polyrhythm-jingle.mjs b/pop/menuband/bin/render-polyrhythm-jingle.mjs new file mode 100644 index 0000000000..39c3f74515 --- /dev/null +++ b/pop/menuband/bin/render-polyrhythm-jingle.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +// render-polyrhythm-jingle.mjs — the Menu Band POLYRHYTHM reel's jingle, +// on the same /pop lullaby engine as render-waltz.mjs / render-jingles.mjs. +// +// The rhythmic identity IS the content: every rhythm is its own lane with +// its own timbre, and the sim lights one side-by-side circle per lane from +// that lane's notes — so the circles and the audio agree to the sample. +// +// solo 0.0– 4.8 "3" three (vibraphone) establishes the pulse +// duo 4.8–12.0 "3:2" two (gamelan) lands against it +// trio 12.0–19.2 "3:4" four (kalimba) swaps in — same 3, new grid +// full 19.2–26.4 "3:4:5" five (glockenspiel) on top — three grids +// end 26.4 every grid coincides: one unison C, ringing out +// +// The shared cycle is 2.4s — exactly the app's PolyrhythmTrainerClock +// default (75 BPM on the outer 3: 60/75*3 = 2.4). Every pitch is a WHITE +// KEY so the strip rig's real captured menu-bar piano lights pixel-real. +// +// Writes: out/menuband-polyrhythm.mp3 +// out/menuband-polyrhythm.notes.json (lanes preserved) +// out/menuband-polyrhythm.score.json (sections + rhythms per lane) +// +// Run: node pop/menuband/bin/render-polyrhythm-jingle.mjs (from repo root) + +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { writeFileSync } from "node:fs"; +import { renderLullaby, m } from "../../marimba/lullabies/lib/core.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT_DIR = resolve(HERE, "..", "out"); + +const CYCLE = 2.4; // one full polyrhythm cycle (75 BPM · 3) +const BPM = 75; // the app's trainer default + +// Chord tone tables — one pitch cell per lane per chord, all white keys. +// Each cell has exactly `count` pitches: hit i of a cycle plays cell[i]. +const CHORDS = { + C: { three: ["C5", "E5", "G5"], two: ["C3", "G3"], four: ["C4", "E4", "G4", "C5"], five: ["C6", "D6", "E6", "G6", "A6"], bass: "C2" }, + G: { three: ["B4", "D5", "G5"], two: ["G2", "D3"], four: ["G3", "B3", "D4", "G4"], five: ["G5", "A5", "B5", "D6", "G6"], bass: "G2" }, + Am: { three: ["A4", "C5", "E5"], two: ["A2", "E3"], four: ["A3", "C4", "E4", "A4"], five: ["A5", "B5", "C6", "E6", "A6"], bass: "A2" }, + F: { three: ["A4", "C5", "F5"], two: ["F2", "C3"], four: ["F3", "A3", "C4", "F4"], five: ["F5", "G5", "A5", "C6", "F6"], bass: "F2" }, +}; + +// One lane per rhythm: count, timbre, and the icon-palette color the sim's +// circle wears (reel-lib KEY_COLORS — brand-consistent, per-rhythm). +const RHYTHMS = { + three: { count: 3, preset: "vibraphone", gain: 0.42, ring: 1.15, decayMul: 1.25, color: [255, 77, 107] }, + two: { count: 2, preset: "gamelan", gain: 0.30, ring: 0.95, decayMul: 1.10, color: [97, 158, 255] }, + four: { count: 4, preset: "kalimba", gain: 0.24, ring: 0.90, decayMul: 1.00, color: [255, 153, 46] }, + five: { count: 5, preset: "glockenspiel", gain: 0.14, ring: 1.30, decayMul: 1.30, color: [51, 209, 179] }, +}; + +// The escalation: which lanes sound, over which chords, per section. +const SECTIONS = [ + { name: "solo", label: "3", lanes: ["three"], chords: ["C", "C"] }, + { name: "duo", label: "3:2", lanes: ["three", "two"], chords: ["C", "C", "G"] }, + { name: "trio", label: "3:4", lanes: ["three", "four"], chords: ["Am", "F", "Am"] }, + { name: "full", label: "3:4:5", lanes: ["three", "four", "five"], chords: ["F", "G", "C"] }, +]; + +const events = []; +const push = (lane, preset, startSec, note, durSec, gain, pan = 0, decayMul = 1.2) => { + events.push({ lane, preset, startSec: +startSec.toFixed(4), midi: m(note), durSec: +durSec.toFixed(4), gain, pan, decayMul }); +}; + +// Pan each lane toward where its circle sits in that section's row. +const lanePan = (lanes, lane) => { + if (lanes.length < 2) return 0; + const i = lanes.indexOf(lane); + return +(((i / (lanes.length - 1)) - 0.5) * 0.44).toFixed(3); +}; + +let t = 0; +const sections = []; +for (const S of SECTIONS) { + const t0 = t; + S.chords.forEach((chordName, c) => { + const chord = CHORDS[chordName]; + const ct = t0 + c * CYCLE; + // the bed: a bass root each cycle + a soft woodblock tick on the downbeat + push("bed", "bass", ct, chord.bass, 2.0, 0.26, 0, 1.1); + if (S.name !== "solo") push("tick", "woodblock", ct, "E5", 0.12, 0.055, 0.1, 0.9); + // the rhythms: lane k plays `count` evenly spaced hits per cycle + for (const lane of S.lanes) { + const R = RHYTHMS[lane]; + const step = CYCLE / R.count; + for (let i = 0; i < R.count; i++) { + push(lane, R.preset, ct + i * step, chord[lane][i], step * R.ring, R.gain, lanePan(S.lanes, lane), R.decayMul); + } + } + }); + t = t0 + S.chords.length * CYCLE; + sections.push({ + name: S.name, label: S.label, t0: +t0.toFixed(4), t1: +t.toFixed(4), + cycles: S.chords.length, + rhythms: S.lanes.map((lane) => ({ lane, ...RHYTHMS[lane], preset: RHYTHMS[lane].preset })), + }); +} + +// ── the unison — every grid's dot 0, at once: one spread C, ringing out ──── +const END = t; // 26.4 +push("bed", "bass", END, "C2", 3.4, 0.32, 0, 1.5); +for (const [i, n] of ["C5", "E5", "G5"].entries()) push("three", "vibraphone", END + i * 0.05, n, 3.0, 0.36, -0.12 + i * 0.12, 1.6); +for (const [i, n] of ["C4", "E4", "G4"].entries()) push("four", "kalimba", END + i * 0.04, n, 2.4, 0.20, 0.1, 1.4); +push("five", "glockenspiel", END, "C6", 3.2, 0.15, 0.26, 1.6); +push("five", "glockenspiel", END + 0.08, "G6", 2.8, 0.09, 0.34, 1.6); + +// ── master + scores ──────────────────────────────────────────────────────── +const NAME = "menuband-polyrhythm"; +const { mp3, durationSec } = renderLullaby(events, { + name: NAME, here: HERE, out: resolve(OUT_DIR, `${NAME}.mp3`), + healing: false, reverb: { wet: 0.24, decay: 0.8, damp: 0.4 }, + fadeIn: 0.3, fadeOut: 2.0, tailSec: 2.4, + title: "Menu Band Polyrhythm Trainer", +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); + +const notes = events + .map((e) => ({ t: e.startSec, dur: e.durSec, midi: e.midi, vel: +e.gain.toFixed(3), lane: e.lane })) + .sort((a, b) => a.t - b.t || a.midi - b.midi); +writeFileSync(resolve(OUT_DIR, `${NAME}.notes.json`), JSON.stringify({ + bpm: BPM, cycleSec: CYCLE, durationSec: +durationSec.toFixed(4), notes, +}, null, 2)); +writeFileSync(resolve(OUT_DIR, `${NAME}.score.json`), JSON.stringify({ + bpm: BPM, cycleSec: CYCLE, durationSec: +durationSec.toFixed(4), + end: +END.toFixed(4), sections, +}, null, 2)); +console.log(` ${sections.length} sections · unison @ ${END.toFixed(1)}s · ${notes.length} notes`); diff --git a/pop/menuband/bin/sim-polyrhythm.mjs b/pop/menuband/bin/sim-polyrhythm.mjs new file mode 100644 index 0000000000..4d9afeb0ca --- /dev/null +++ b/pop/menuband/bin/sim-polyrhythm.mjs @@ -0,0 +1,283 @@ +#!/usr/bin/env node +// sim-polyrhythm.mjs — the Menu Band POLYRHYTHM reel base. +// +// The visual centerpiece is the app's reworked polyrhythm trainer: N +// SEPARATE circles side by side — one circle per rhythm (3 next to 2), NOT +// concentric rings — each circle's beat dots lighting on its own grid, its +// own sweep hand, over the polyrhythm jingle +// (render-polyrhythm-jingle.mjs). Every circle lights from ITS OWN lane in +// menuband-polyrhythm.notes.json, so the picture and the audio agree to +// the sample — the lane's founding determinism. +// +// Acts (scene times come straight from the score's sections): +// 1 · intro — the real menu-bar strip falls in, icon + identity, and the +// first circle ("3") already keeping time below; +// 2 · duo — 3:2, two circles side by side; +// 3 · trio — 3:4, same three, new grid; +// 4 · full — 3:4:5, three circles, then every hand reaches the top at +// once for the unison ring-out; +// 5 · end tag — icon · "free on the Mac App Store" · menuband.app. +// +// CIRCLE RENDER — one swappable function (drawPolyCircle). Today it draws +// in node-canvas with geometry lifted from the app's own +// PolyrhythmTrainer.swift (face r=56 → ring r=45, dots 6/10, hand 52, +// hub 5, needle flash 0.12s — all scaled by u = R/56). When the Swift +// side-by-side rework + `--render-polyrhythm --pattern/--phase` CLI lands, +// pre-render per-phase captures to out/polyrhythm-frames/ (the sim.mjs +// menubar-frames pattern) and swap the body of drawPolyCircle for a blit. +// +// Output: out/base-menuband-polyrhythm.mp4 + meta-menuband-polyrhythm.json +// Then: node pop/menuband/bin/chrome-reel.mjs menuband-polyrhythm +// +// Usage: node pop/menuband/bin/sim-polyrhythm.mjs +// (render-polyrhythm-jingle.mjs first) + +import { readFileSync, existsSync } from "node:fs"; +import { + W, H, FPS, OUT, INK, easeOut, clamp01, rgb, + makeStage, roundRect, text, drawDesktop, vignette, drawIcon, + makeParticles, loadStripRig, drawStrip, stripKeyX, stripKeyColor, + loadScore, litAt, makeOnsets, + renderVideo, writeMeta, makeScenes, +} from "./reel-lib.mjs"; + +const SLUG = "menuband-polyrhythm"; +const score = loadScore(SLUG); +const POLY = JSON.parse(readFileSync(`${OUT}/${SLUG}.score.json`, "utf8")); +const TOTAL = score.durationSec; +const CYCLE = POLY.cycleSec; +const END = POLY.end; // the unison downbeat + +const { canvas, ctx } = makeStage(); +const rig = await loadStripRig(); +const particles = makeParticles(ctx); + +// ── lanes ────────────────────────────────────────────────────────────────── +const RHYTHM_LANES = ["three", "two", "four", "five"]; +const laneNotes = new Map(RHYTHM_LANES.map((l) => [l, score.notes.filter((n) => n.lane === l)])); +const stripNotes = score.notes.filter((n) => RHYTHM_LANES.includes(n.lane)); +const stripOnsets = makeOnsets(stripNotes); +const laneOnsets = new Map(RHYTHM_LANES.map((l) => [l, makeOnsets(laneNotes.get(l))])); + +const sectionAt = (t) => { + let s = POLY.sections[0]; + for (const sect of POLY.sections) if (t >= sect.t0) s = sect; + return s; +}; + +// ── scenes — boundaries ride the score's sections; `full` holds past the +// unison so the hands-at-the-top ring-out reads before the end card. ─────── +const HOLD = 1.6; +const { scenes: SCENES, sceneAt } = makeScenes([ + { name: "intro", from: 0, to: POLY.sections[1].t0 / TOTAL, tint: [255, 77, 107] }, + { name: "duo", from: POLY.sections[1].t0 / TOTAL, to: POLY.sections[2].t0 / TOTAL, tint: [97, 158, 255] }, + { name: "trio", from: POLY.sections[2].t0 / TOTAL, to: POLY.sections[3].t0 / TOTAL, tint: [255, 153, 46] }, + { name: "full", from: POLY.sections[3].t0 / TOTAL, to: (END + HOLD) / TOTAL, tint: [51, 209, 179] }, + { name: "end", from: (END + HOLD) / TOTAL, to: 1, tint: [255, 77, 107] }, +], TOTAL); + +// ── the hero strip (features-sim geometry): the real menu-bar piano parks +// at the very top, falls in during the first second, lit from every rhythm +// lane — the app tie-in over the trainer. ────────────────────────────────── +const HERO_W = W * 0.96, HERO_X = (W - HERO_W) / 2, HERO_TOP = 54; +function heroRect(t) { + const h = HERO_W / rig.aspect; + const enter = easeOut(clamp01(t / 1.0)); + const bob = Math.sin((t / CYCLE) * Math.PI * 2) * 6 * enter; + return { x: HERO_X, y: (-h - 40) + (HERO_TOP - (-h - 40)) * enter + bob, w: HERO_W, h }; +} + +// ── circle state: per-rhythm phase / grid step / strike glow, all derived +// from the score clock + that rhythm's OWN lane onsets. ──────────────────── +const FLASH_SEC = 0.30; // needle/dot pop (app: 0.12s — widened for 30fps) +function circleStates(t) { + const sect = sectionAt(t); + const frozen = t >= END; // after the unison the hands hold the top + const local = frozen ? 0 : Math.max(0, t - sect.t0); + const phase = frozen ? 0 : (local / CYCLE) % 1; + return { + sect, + specs: sect.rhythms.map((r) => { + const notes = laneNotes.get(r.lane) ?? []; + let flash = 0, litStep = -1, sustain = 0; + for (const n of notes) { + if (n.t > t) break; + const step = Math.round((((n.t - sect.t0) % CYCLE) / CYCLE) * r.count) % r.count; + if (t - n.t < FLASH_SEC) { flash = Math.max(flash, 1 - (t - n.t) / FLASH_SEC); litStep = step; } + if (t < n.t + n.dur) { sustain = Math.max(sustain, 0.55); litStep = litStep < 0 ? step : litStep; } + } + return { + lane: r.lane, count: r.count, color: r.color, phase, + activeStep: Math.floor(phase * r.count) % r.count, + litStep, flash, glow: Math.max(flash, sustain), + }; + }), + }; +} + +// ── THE SWAPPABLE CIRCLE RENDER ──────────────────────────────────────────── +// Geometry from PolyrhythmTrainer.swift, scaled by u = R/56: +// face r 56 (white, near-opaque, hairline quiet stroke) · beat ring r 45 +// dots ∅6 idle / ∅10 active at the ring · hand length 52, lw 1.5+2.5·flash +// hub ∅5 · center label (the count) + small bpm figure below. +// Light mode; the active dot wears the rhythm's own icon-palette color +// (in-app it's the one system accent — per-circle color is the reel's read). +// Replace this body with an out/polyrhythm-frames/ blit once the Swift +// side-by-side CLI (--pattern/--phase/--light) exists. +function drawPolyCircle(c, { cx, cy, R, count, color, phase, activeStep, litStep, flash, glow, alpha = 1 }) { + const u = R / 56; + const TAU = Math.PI * 2; + const quiet = "rgba(0,0,0,0.18)"; + c.save(); + c.globalAlpha = alpha; + + // face + c.beginPath(); c.arc(cx, cy, R, 0, TAU); + c.fillStyle = "rgba(255,255,255,0.93)"; c.fill(); + c.lineWidth = Math.max(2, 1.2 * u); c.strokeStyle = quiet; c.stroke(); + + // beat ring + c.beginPath(); c.arc(cx, cy, 45 * u, 0, TAU); + c.lineWidth = 1.5 * u; c.strokeStyle = quiet; c.stroke(); + + // dots — clockwise from the top, one per beat of this rhythm's grid + for (let i = 0; i < count; i++) { + const a = -Math.PI / 2 + (i / count) * TAU; + const px = cx + Math.cos(a) * 45 * u, py = cy + Math.sin(a) * 45 * u; + const active = i === activeStep; + const struck = i === litStep && glow > 0; + const dia = (active ? 10 : 6) * u + (struck ? 4 * u * flash : 0); + if (struck) { + c.save(); + c.shadowColor = rgb(color, 0.9 * glow); c.shadowBlur = 22 * u * glow; + c.beginPath(); c.arc(px, py, dia / 2, 0, TAU); + c.fillStyle = rgb(color); c.fill(); + c.restore(); + } else { + c.beginPath(); c.arc(px, py, dia / 2, 0, TAU); + c.fillStyle = active ? rgb(color) : quiet; c.fill(); + } + } + + // hand — one revolution per shared cycle; strikes brighten + thicken it + const ha = -Math.PI / 2 + phase * TAU; + c.beginPath(); c.moveTo(cx, cy); + c.lineTo(cx + Math.cos(ha) * 52 * u, cy + Math.sin(ha) * 52 * u); + c.lineWidth = (1.5 + 2.5 * flash) * u; + c.lineCap = "round"; + c.strokeStyle = flash > 0.01 ? rgb(color, 0.78 + 0.22 * flash) : "rgba(20,18,28,0.78)"; + c.stroke(); + + // hub + c.beginPath(); c.arc(cx, cy, 2.5 * u, 0, TAU); + c.fillStyle = INK; c.fill(); + + // the count + the app's bpm figure (75 — the trainer default the jingle rides) + text(c, String(count), cx, cy - 13 * u, 15 * u, "rgba(20,18,28,0.88)", 800); + text(c, String(POLY.bpm), cx, cy + 14 * u, 8 * u, "rgba(20,18,28,0.58)", 600); + + c.restore(); +} + +// ── row layout: N circles side by side, sized to the frame ──────────────── +const GAP = 44; +function rowSpecs(specs, cy, maxR = 210) { + const n = specs.length; + const R = Math.min(maxR, (W * 0.92 - (n - 1) * GAP) / (2 * n)); + const total = n * 2 * R + (n - 1) * GAP; + const x0 = (W - total) / 2 + R; + return specs.map((s, i) => ({ ...s, R, cx: x0 + i * (2 * R + GAP), cy })); +} + +// ── captions ─────────────────────────────────────────────────────────────── +const CAPTIONS = { + intro: ["meet the polyrhythm trainer", "one circle per rhythm · press / to start"], + duo: ["three against two", "each circle keeps its own grid"], + trio: ["same three · new grid", "the left circle never changes"], + full: ["three grids at once", "3 · 4 · 5 — one shared clock"], +}; + +function drawFrame(t) { + drawDesktop(ctx); + const sc = sceneAt(t); + const local = (sc.to - sc.from) > 0 ? (t - sc.from) / (sc.to - sc.from) : 1; + const dt = 1 / FPS; + + // the real strip up top, lit by every rhythm lane + const hero = heroRect(t); + const hRect = drawStrip(ctx, rig, litAt(stripNotes, t), hero.x, hero.y, hero.w); + for (const n of stripOnsets(t - dt, t)) { + particles.spawnNote(stripKeyX(rig, n.midi, hRect), hRect.y + hRect.h + 6, stripKeyColor(rig, n.midi), true); + } + + if (sc.name === "end") { + const e = easeOut(Math.min(1, local * 5)); + ctx.save(); ctx.globalAlpha = e; + const ipx = 300; + drawIcon(ctx, W / 2 - ipx / 2, H * 0.28, ipx, new Set(litAt(stripNotes, t).map((mm) => ((mm % 12) + 12) % 12 % 5))); + text(ctx, "Menu Band", W / 2, H * 0.50, 110, INK, 800); + text(ctx, "the polyrhythm trainer — in your menu bar", W / 2, H * 0.50 + 96, 44, "rgba(60,50,80,0.9)", 600); + text(ctx, "free on the Mac App Store", W / 2, H * 0.50 + 182, 50, "rgba(60,50,80,0.9)", 700); + text(ctx, "menuband.app", W / 2, H * 0.50 + 262, 58, INK, 800); + ctx.restore(); + } else { + const { sect, specs } = circleStates(t); + // section-entry pop + const enter = easeOut(clamp01((t - sect.t0) / 0.5)); + const alpha = 0.25 + 0.75 * enter; + + if (sc.name === "intro") { + const e = easeOut(clamp01(t / 0.8)); + ctx.save(); ctx.globalAlpha = e; + const ipx = 250; + drawIcon(ctx, W / 2 - ipx / 2, H * 0.135, ipx, new Set(litAt(stripNotes, t).map((mm) => ((mm % 12) + 12) % 12 % 5))); + text(ctx, "Menu Band", W / 2, H * 0.135 + ipx + 66, 100, INK, 800); + ctx.restore(); + const row = rowSpecs(specs, H * 0.60, 205); + for (const s of row) drawPolyCircle(ctx, { ...s, alpha }); + spawnDotParticles(row, t, dt); + } else { + // big pattern label + ctx.save(); ctx.globalAlpha = alpha; + text(ctx, sect.label, W / 2, H * 0.185, 148, INK, 800); + ctx.restore(); + const row = rowSpecs(specs, H * 0.465); + for (const s of row) { + const grow = 0.9 + 0.1 * enter; + drawPolyCircle(ctx, { ...s, R: s.R * grow, alpha }); + } + spawnDotParticles(row, t, dt); + } + + const cap = CAPTIONS[sc.name]; + if (cap) { + const a = easeOut(clamp01(local * 4)) * (1 - easeOut(clamp01((local - 0.86) / 0.14))); + if (a > 0) { + ctx.save(); ctx.globalAlpha = a; + text(ctx, cap[0], W / 2, H * 0.80, 82, INK, 800); + text(ctx, cap[1], W / 2, H * 0.80 + 82, 42, "rgba(60,50,80,0.88)", 600); + ctx.restore(); + } + } + } + + particles.stepAndDraw(dt); + vignette(ctx); +} + +// a note leaves the struck dot itself — each circle sprays its own color +function spawnDotParticles(row, t, dt) { + for (const s of row) { + for (const n of laneOnsets.get(s.lane)(t - dt, t)) { + const step = Math.round((((n.t - sectionAt(n.t).t0) % CYCLE) / CYCLE) * s.count) % s.count; + const a = -Math.PI / 2 + (step / s.count) * (Math.PI * 2); + particles.spawnNote(s.cx + Math.cos(a) * (s.R / 56) * 45, s.cy + Math.sin(a) * (s.R / 56) * 45, s.color, false); + } + } +} + +await renderVideo({ + canvas, audioPath: `${OUT}/${SLUG}.mp3`, outPath: `${OUT}/base-${SLUG}.mp4`, + total: TOTAL, drawFrame, label: "menuband polyrhythm sim", +}); +writeMeta(SLUG, TOTAL, SCENES); diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 34ab637487..4fe4aec089 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -119,6 +119,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var isPopoverPanelShown: Bool { popoverPanel?.isVisible == true } private lazy var pianoWaveformWindowDelegate = PianoWaveformWindowDelegate(menuBand: menuBand) + private lazy var reverseWaveformHUD = ReverseWaveformHUD(menuBand: menuBand) private var appBeforePopover: NSRunningApplication? private var appBeforeFocusCapture: NSRunningApplication? private var keyboardPerformanceFocusActive = false @@ -448,6 +449,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } private var trackpadSurfaceEnergy = TrackpadSurfaceEnergy() private var trackpadMembrane = TrackpadMembraneSimulation() + private var polyrhythmTrainer = PolyrhythmTrainerClock() private var trackpadEnergyTimer: Timer? private var trackpadOverlayLastDraw: Double = 0 /// Keep the last percussion surface readable after the final lift, like @@ -516,6 +518,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard self.pitchBendCursorPushed || self.keyboardPerformanceFocusActive else { return false } + let slashAction = Self.trackDrumSlashAction( + keyCode: keyCode, + flags: NSEvent.ModifierFlags(rawValue: UInt(flags.rawValue)) + ) + if let slashAction, isDown, self.trackpadPadMode == .skin { + if !isRepeat { + DispatchQueue.main.async { + switch slashAction { + case .trainer: self.togglePolyrhythmTrainer() + case .help: Self.openTips() + } + } + } + return true + } + let rateDelta = Self.trackDrumRateDelta( + keyCode: keyCode, + flags: NSEvent.ModifierFlags(rawValue: UInt(flags.rawValue)) + ) + if let rateDelta, self.trackpadPadMode == .skin, + self.polyrhythmTrainer.isActive { + if isDown && !isRepeat { + DispatchQueue.main.async { self.changePolyrhythmRate(by: rateDelta) } + } + return true + } + // These two ⌥⌘ chords are claimed globally outside Menu Band: // D toggles the Dock and X enters Slab's prox-keyboard focus. While // Menu Band owns performance focus they are musical sus chords. Sink @@ -1098,6 +1127,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } button.addSubview(dropTarget) } + menuBand.onRewindChanged = { [weak self] reversing, levels in + guard let self else { return } + if reversing { + let screen = self.pitchBendOverlay?.screen + ?? self.statusItem?.button?.window?.screen + ?? NSScreen.main + self.reverseWaveformHUD.show( + levels: levels, + above: self.pitchBendOverlay?.frame, + on: screen + ) + } else { + self.reverseWaveformHUD.hide() + } + } updateIcon() // Pre-build the waveform strip panel so the first note press @@ -1432,6 +1476,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.exitPerformanceFocusFromEscape() return true } + let slashAction = Self.trackDrumSlashAction( + keyCode: keyCode, flags: flags + ) + if let slashAction, self.trackpadPadMode == .skin, + (self.pitchBendCursorPushed || self.keyboardPerformanceFocusActive) { + if isDown && !isRepeat { + switch slashAction { + case .trainer: self.togglePolyrhythmTrainer() + case .help: Self.openTips() + } + } + return true + } + if let rateDelta = Self.trackDrumRateDelta(keyCode: keyCode, flags: flags), + self.trackpadPadMode == .skin, self.polyrhythmTrainer.isActive { + if isDown && !isRepeat { self.changePolyrhythmRate(by: rateDelta) } + return true + } if isDown && MenuBandShortcutPreferences.layoutShortcut.matches( keyCode: UInt32(keyCode), modifiers: MenuBandShortcut.carbonModifiers(from: flags) @@ -3934,6 +3996,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Closes with a short fade-out (~140 ms) so the popover dissolves /// the way standard macOS menu pop-ups do, instead of cutting away. private func closePopover(dismissFloatingPanel: Bool = true) { + menuBand.closeInputInterface() // Tear down monitors immediately so a stray click during the // fade can't re-trigger close. if let m = clickAwayMonitor { NSEvent.removeMonitor(m); clickAwayMonitor = nil } @@ -4982,6 +5045,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { + "lifted=\(changes.lifted.count) active=\(changes.active.count)" ) } + if polyrhythmTrainer.isActive { + for point in changes.began { + // Score from the hardware callback clock, not from whenever + // the main thread gets around to drawing the result. The + // contact's normalized x picks the circle: equal vertical + // bands left to right, so each finger individuates a rhythm. + polyrhythmTrainer.registerTap( + at: callbackTime, normalizedX: Double(point.x) + ) + } + } let touches = changes.active.map(\.point) let priorTouchCount = mtTouches.count mtTouches = touches @@ -5046,19 +5120,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if pitchBendOverlay?.isVisible != true { showPitchBendOverlay() } - updateTrackpadOverlayIfDue( - force: touches.count != priorTouchCount - ) + // Audio already staged off-main. Let the display clock pick up + // contact changes instead of forcing a WindowServer commit for + // every rapid down/up edge. + updateTrackpadOverlayIfDue() } else if priorTouchCount > 0, Self.shouldFadeTrackpadOverlayAfterLift( performanceSessionActive: trackpadPerformanceSessionActive ) { // Preserve the final contact/energy state long enough to read, // then ease it away on the same rhythm as the key-label ghost. - pitchBendOverlay?.fadeOut( - after: Self.trackpadOverlayIdleHold, - duration: Self.trackpadOverlayFadeDuration - ) + if !polyrhythmTrainer.isActive { + pitchBendOverlay?.fadeOut( + after: Self.trackpadOverlayIdleHold, + duration: Self.trackpadOverlayFadeDuration + ) + } } } } @@ -5427,13 +5504,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } self.trackpadSurfaceEnergy.decay(to: CACurrentMediaTime()) if self.trackpadPadMode == .skin || self.trackpadPadMode == .synth { + let now = CACurrentMediaTime() + // Quiet reference cues: distinct enough to follow, but well + // below a played TrackDrum strike. The guide is a clock, not + // an accompaniment. + for rhythm in self.polyrhythmTrainer.tick(at: now) { + switch rhythm { + case 0: self.menuBand.engineDrum(.block, velocity: 18) + case 1: self.menuBand.engineDrum(.snap, velocity: 14) + default: self.menuBand.engineDrum(.cowbell, velocity: 12) + } + } self.trackpadMembrane.advance( - to: CACurrentMediaTime(), touches: self.mtTouches + to: now, touches: self.mtTouches ) self.updateTrackpadOverlayIfDue() } } - timer.tolerance = 1.0 / 120.0 + timer.tolerance = 1.0 / 240.0 RunLoop.main.add(timer, forMode: .common) trackpadEnergyTimer = timer } @@ -5511,6 +5599,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { setTrackpadFighterSuppressed(true) #endif trackpadPadMode = useTrackDrum ? .skin : .fx + polyrhythmTrainer.stop() #if MAC_APP_STORE trackpadPluginCaptureActive = useTrackDrum trackpadPlugin.setCaptureEnabled(useTrackDrum) @@ -5646,14 +5735,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } activateDefaultTrackpadDrum() showPitchBendOverlay() - pitchBendOverlay?.fadeOut( - after: Self.trackpadOverlayIdleHold, - duration: Self.trackpadOverlayFadeDuration - ) + if !polyrhythmTrainer.isActive { + pitchBendOverlay?.fadeOut( + after: Self.trackpadOverlayIdleHold, + duration: Self.trackpadOverlayFadeDuration + ) + } debugLog("trackpad pad mode = skin (Tab handoff)") return #endif trackpadFXPrimaryContact.reset() + polyrhythmTrainer.stop() trackpadPadMode = Self.trackpadPadModeAfterTab(trackpadPadMode) if trackpadPadMode != .fx { pitchBendEndTimer?.invalidate() @@ -6160,6 +6252,47 @@ final class AppDelegate: NSObject, NSApplicationDelegate { && !NSEvent.modifierFlags.contains(.shift) } + private func togglePolyrhythmTrainer() { + guard trackpadPadMode == .skin, + pitchBendCursorPushed || keyboardPerformanceFocusActive else { return } + polyrhythmTrainer.cyclePattern(at: CACurrentMediaTime()) + if trackpadEnergyTimer == nil { startTrackpadEnergyDisplay() } + showPitchBendOverlay() + debugLog(polyrhythmTrainer.isActive + ? "TrackDrum polyrhythm trainer = \(polyrhythmTrainer.pattern.label)" + : "TrackDrum polyrhythm trainer = off") + } + + private func changePolyrhythmRate(by delta: Int) { + polyrhythmTrainer.changeRate(by: delta, at: CACurrentMediaTime()) + updateTrackpadOverlayIfDue(force: true) + debugLog("TrackDrum polyrhythm rate = \(polyrhythmTrainer.bpm) bpm") + } + + enum TrackDrumSlashAction: Equatable { case trainer, help } + + static func trackDrumSlashAction( + keyCode: UInt16, + flags: NSEvent.ModifierFlags + ) -> TrackDrumSlashAction? { + guard keyCode == UInt16(kVK_ANSI_Slash), + !flags.contains(.command), !flags.contains(.option), + !flags.contains(.control) else { return nil } + return flags.contains(.shift) ? .help : .trainer + } + + static func trackDrumRateDelta( + keyCode: UInt16, + flags: NSEvent.ModifierFlags + ) -> Int? { + guard !flags.contains(.command), !flags.contains(.option), + !flags.contains(.control) else { return nil } + if keyCode == UInt16(kVK_ANSI_Minus) { return -5 } + // The same physical key produces = or shifted +; both increase rate. + if keyCode == UInt16(kVK_ANSI_Equal) { return 5 } + return nil + } + private func showPitchBendOverlay() { #if MAC_APP_STORE guard !trackDrumInstallPromptActive else { return } @@ -6172,11 +6305,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } let overlay = ensurePitchBendOverlay() if showingTracktrampSkin { + let rhythm = polyrhythmTrainer.snapshot(at: CACurrentMediaTime()) overlay.showTracktramp( trackpadMembrane.snapshot(), touches: mtTouches, + polyrhythm: rhythm, atScreenPoint: trackpadOverlayAnchor( - imageSize: TracktrampMetalView.logicalSize, + imageSize: PitchBendCursorOverlayWindow.tracktrampSurfaceSize( + polyrhythm: rhythm), fallback: pitchBendLockScreenPoint ) ) @@ -6310,11 +6446,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard let overlay = pitchBendOverlay, overlay.isVisible else { return } trackpadOverlayLastDraw = CACurrentMediaTime() if showingTracktrampSkin { + let rhythm = polyrhythmTrainer.snapshot(at: CACurrentMediaTime()) overlay.updateTracktramp( trackpadMembrane.snapshot(), touches: mtTouches, + polyrhythm: rhythm, atScreenPoint: trackpadOverlayAnchor( - imageSize: TracktrampMetalView.logicalSize, + imageSize: PitchBendCursorOverlayWindow.tracktrampSurfaceSize( + polyrhythm: rhythm), fallback: pitchBendLockScreenPoint ) ) @@ -6331,10 +6470,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// from delaying the next input callback by a complete 8 ms touch frame. private func updateTrackpadOverlayIfDue(force: Bool = false) { let now = CACurrentMediaTime() - // Touch and sound stay at the hardware's 125 Hz cadence, but this - // decorative thumbnail must not compete with notation/UI animation. - // The Metal skin submits at most once per display frame and reuses its - // cached texture; input and sound continue independently at 125 Hz. + // Keep WindowServer work at a stable 60 Hz even though input and audio + // remain at 125 Hz. Motion is elapsed-time based and tap scoring uses + // callback timestamps, so this changes neither speed nor accuracy. guard force || now - trackpadOverlayLastDraw >= 1.0 / 60.0 else { return } updatePitchBendOverlayImage() } @@ -6488,6 +6626,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if keyboardPerformanceFocusActive, trackpadPadMode == .skin { releaseTrackpadPercussion() trackpadSkinTouches.removeAll() + // The guide owns a continuous clock even when the player's hands + // lift. Keep its display timer and panel alive between strikes. + if polyrhythmTrainer.isActive { + showPitchBendOverlay() + return + } trackpadEnergyTimer?.invalidate() trackpadEnergyTimer = nil startFxRelease() @@ -6510,6 +6654,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // somehow already cleared. pitchBendModeLatched = false trackpadPerformanceSessionActive = false + polyrhythmTrainer.stop() #if !MAC_APP_STORE setTrackpadFighterSuppressed(pianoWaveformWindowDelegate.isShown) #endif diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index 1f00e3bb3b..3e60949630 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -71,6 +71,15 @@ final class MenuBandController { func setAudioInputDevice(uid: String?) { synth.setPreferredInputDevice(uid: uid) } func setAudioOutputDevice(uid: String?) { synth.setPreferredOutputDevice(uid: uid) } func setAudioMonitorChannel(_ channel: Int) { synth.setMonitorInputChannel(channel) } + /// Closing the input UI is an ownership boundary: monitoring is no longer + /// visible, so revoke its persistent preference/pin and release CoreAudio + /// input immediately. Tape/sample recording pins still prevent shutdown. + func closeInputInterface() { + if inputMonitoringEnabled { + inputMonitoringEnabled = false + } + synth.stopUnpinnedHotMicNow() + } private var keyTap: KeyEventTap? private var heldNotes: [UInt16: UInt8] = [:] /// Chord EXTENSION voices for a held key, keyed by interval (semitones @@ -1236,21 +1245,29 @@ final class MenuBandController { /// True while the spacebar reverse-replay is sounding — drives the /// popover waveform strip's direction indicator (◀ reverse vs ▶ live). private(set) var isRewinding = false + /// Momentary reverse UI follows the actual controller gesture regardless + /// of whether the event arrived through local or global key capture. + var onRewindChanged: ((Bool, [Float]) -> Void)? func rewind() { isRewinding = true DispatchQueue.main.async { [weak self] in - self?.synth.playReverse() + guard let self else { return } + let didPlay = self.synth.playReverse() + self.isRewinding = didPlay + self.onRewindChanged?(didPlay, self.synth.rewindPreviewLevels()) } } - /// Spacebar released — stop reverse playback, banking the playhead so - /// the next press resumes from the same reverse point (deeper into the - /// tape). Playing a note resets that cursor to the live head. + /// Spacebar released — stop reverse playback and lift the needle back to + /// the session anchor. Quick presses therefore repeat the same phrase; + /// playing a real note drops the anchor so the next dive starts at now. func rewindRelease() { isRewinding = false DispatchQueue.main.async { [weak self] in - self?.synth.releaseReverse() + guard let self else { return } + self.synth.releaseReverse() + self.onRewindChanged?(false, []) } } @@ -1259,6 +1276,11 @@ final class MenuBandController { /// Reverse playback progress (0…1) or nil — for the strip's playhead. func rewindProgress() -> Double? { synth.rewindProgress() } + func rewindClipProgress() -> Double? { synth.rewindClipProgress() } + /// Live-input peak since last read — what's being played NOW, clean of + /// the reverse playback itself (the tape tap sits upstream of the reverse + /// player). Drives the HUD's dub bar. + func rewindLiveInputPeak() -> Float { synth.rewindLiveInputPeak() } func setBend(amount: Float, allChannels: Bool = false) { // No clamp here — the trackpad accumulator can swing past diff --git a/slab/menuband/Sources/MenuBand/MenuBandRewindVoice.swift b/slab/menuband/Sources/MenuBand/MenuBandRewindVoice.swift index c5e76ad07d..d9ead19b10 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandRewindVoice.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandRewindVoice.swift @@ -45,10 +45,9 @@ final class MenuBandRewindVoice { /// (~37 MB at 96 kHz mono float — fine.) Ring must exceed the reverse /// window (`captureSeconds`) with headroom. private let bufferSeconds: Double = 96.0 - /// Reverse window: a session (first press → next real note) can rewind - /// at most this far behind its anchor. Presses within a session RESUME - /// from where the last one stopped (see `sessionAnchor`/`consumedFrames`) - /// — they never re-add time. A new note re-anchors at the live head. + /// Reverse window: each held-space dive can reach this far behind the + /// session anchor. Releasing lifts the needle back to the anchor, making + /// rapid taps repeatable; a real note re-anchors at the live head. let captureSeconds: Double = 90.0 /// Generation token for the in-flight reverse one-shot. Bumped on every /// `playReverse` and `release` so a deferred release-fade can tell whether @@ -103,7 +102,7 @@ final class MenuBandRewindVoice { private var ringSampleRate: Double = 0 private let ringLock = NSLock() - // MARK: Reverse session (persistent cursor across presses) + // MARK: Reverse session (fixed anchor across presses) /// Absolute frame count (`framesWritten`) at the FIRST press of the /// current reverse session — the fixed "now" edge the whole session @@ -111,15 +110,11 @@ final class MenuBandRewindVoice { /// live head. Guarded by `ringLock` (reset can arrive from any thread /// via `resetReverseAnchor`). private var sessionAnchor: Int? = nil - /// Frames already reverse-played this session, banked on release (and - /// folded in by `settleInFlightLocked`). Each press resumes - /// `consumedFrames` behind the anchor — the same reverse point the last - /// press stopped at — never the top of the window again. + /// Frames traversed by the current dive. This is folded in if a duplicate + /// press arrives without release, then reset to zero when the needle lifts. private var consumedFrames = 0 /// The session's fixed window length in frames (`captureSeconds` at the - /// anchor's sample rate). Presses never re-add time: once a session has - /// consumed this much tape, further presses are silent until a note - /// resets the anchor. + /// anchor's sample rate). private var sessionWantFrames = 0 /// Frame length of the in-flight one-shot, for the settle accounting. private var lastClipFrames = 0 @@ -139,6 +134,15 @@ final class MenuBandRewindVoice { /// (exact, drift-free) instead of a free-running per-frame counter. private var playStart: Double = 0 private var clipDuration: Double = 0 + /// Display envelope of the exact scheduled reverse clip, ordered as tape + /// time (oldest → newest) so the HUD can put now at its right edge. + private var latestPreviewLevels: [Float] = [] + /// Seconds of tape the preview envelope covers — the recent audible span, + /// not the whole scheduled clip. The HUD's head clock runs against this. + private var previewDuration: Double = 0 + /// Max-held peak of the live input since the HUD's dub bar last read it. + /// Guarded by `ringLock` (written on the IO thread in `feed`). + private var liveInputPeakHold: Float = 0 // MARK: Attach @@ -247,6 +251,11 @@ final class MenuBandRewindVoice { // // Mixed in, not overwritten, so the dub sits ON the existing music // instead of erasing it. Clamped because two full-scale signals sum. + // Peak of THIS buffer — the live input alone, since this tap sits + // upstream of where reverse playback joins. Max-held until the HUD's + // dub bar takes it, so a transient can't fall between two UI frames. + var livePeak: Float = 0 + if reverseActive, let head = reverseReadHeadLocked() { // Absolute frame `a` lives at ring index writeIdx-1-(framesWritten-a). var idx = (writeIdx - 1 - (framesWritten - head)) % cap @@ -255,18 +264,22 @@ final class MenuBandRewindVoice { let l = ch[0] let r = ch[1] for i in 0..= cap { idx = 0 } } } else { let m = ch[0] for i in 0..= cap { idx = 0 } } } + liveInputPeakHold = Swift.max(liveInputPeakHold, livePeak) writeIdx = idx framesWritten += frames ringLock.unlock() } + /// Max-held live-input peak since the last take — read-and-clear, so the + /// HUD's dub bar sees every transient exactly once regardless of how the + /// UI frame rate and the audio buffer cadence interleave. + func takeLiveInputPeak() -> Float { + ringLock.lock() + let p = liveInputPeakHold + liveInputPeakHold = 0 + ringLock.unlock() + return p + } + /// Absolute frame the reverse pass is reading right now — the needle. The /// in-flight clip was snapshotted at press time, so there is no live read /// pointer to ask; the position comes from the clip's wall-clock progress @@ -337,9 +365,9 @@ final class MenuBandRewindVoice { // Fold any still-in-flight press into the cursor first, so a press // that arrives without an intervening release can't lose position. settleInFlightLocked() - // Session cursor: the first press anchors "now"; every later press - // resumes from the same reverse point the last one stopped at - // (anchor − consumed). Re-anchor only when there is no session (a + // Session cursor: the first press anchors "now". Duplicate downs can + // continue an in-flight dive; ordinary release resets consumed to 0, + // making the next press repeat from the anchor. Re-anchor only when there is no session (a // note reset it — see `resetReverseAnchor`), the ring was // reallocated under us (anchor > total), or the tape behind the // cursor has been fully overwritten. @@ -352,8 +380,7 @@ final class MenuBandRewindVoice { sessionWantFrames = Int(captureSeconds * rate) } let cursorAbs = (sessionAnchor ?? total) - consumedFrames - // What's left to reverse: bounded by the session window (no re-added - // time — the window is fixed at the first press) and by how much + // What's left to reverse: bounded by the fixed session window and by how much // tape behind the cursor still survives in the ring. let count = Swift.min(sessionWantFrames - consumedFrames, cursorAbs - oldest) guard count >= 256 else { ringLock.unlock(); return false } @@ -371,13 +398,27 @@ final class MenuBandRewindVoice { } ringLock.unlock() + // Normalize the snapshot to conservative headroom. The captured tape + // has already crossed the master limiter, but reverse joins the live + // signal downstream of that limiter; 0.68 leaves room for the two to + // overlap. Cap upward gain so a nearly-silent tape never turns its + // noise floor into a loud reverse wash. + var peak: Float = 0 + for sample in reversed { peak = max(peak, abs(sample)) } + if peak > 0.0005 { + let gain = min(2.5, 0.68 / peak) + if abs(gain - 1) > 0.001 { + for i in reversed.indices { reversed[i] *= gain } + } + } + // Declick: the window begins and ends on an arbitrary mid-waveform // sample, so jumping straight from silence to that value (on press) // and back (at the tail) snaps a click. A short raised-cosine fade at // both ends ramps those discontinuities away — the "very slight fade // in when reverse starts" that kills the spacebar pop, plus a matching // tail fade so the one-shot lands in silence cleanly. - let fade = Swift.min(count / 2, Swift.max(1, Int(rate * 0.006))) + let fade = Swift.min(count / 2, Swift.max(1, Int(rate * 0.008))) if fade > 1 { for i in 0..= 0 { + if abs(reversed[scan]) > 0.004 { audibleFrames = scan + 1; break } + scan -= 1 + } + // A beat of older silence pads the left edge so the phrase's tail-out + // is visible; a silent tape still shows a believable short window. + let visibleFrames = audibleFrames > 0 + ? Swift.min(count, audibleFrames + Int(rate * 0.25)) + : Swift.min(count, Int(rate * 1.5)) + let previewColumns = 192 + var preview = [Float](repeating: 0, count: previewColumns) + for column in 0.. Double? { guard clipDuration > 0, sessionWantFrames > 0 else { return nil } @@ -483,6 +556,20 @@ final class MenuBandRewindVoice { return Swift.min(1, frames / Double(sessionWantFrames)) } + /// Audio-clock position through the HUD's visible span — the recent + /// audible tape its envelope covers, not the full scheduled clip. The + /// head therefore crosses the strip in exactly the time the audible + /// reverse takes, then rests at the far edge while any trailing + /// (older, silent) tape plays out. + func currentClipProgress() -> Double? { + guard playStart > 0 else { return nil } + let span = previewDuration > 0 ? previewDuration : clipDuration + guard span > 0 else { return nil } + return Swift.min(1, Swift.max(0, (CACurrentMediaTime() - playStart) / span)) + } + + func reversePreviewLevels() -> [Float] { latestPreviewLevels } + /// Space released — the needle snaps back to NOW, and the reverse voice /// stops. Space is a momentary excursion: while it's held you dive back /// through the tape, and letting go drops you at the live record head @@ -516,7 +603,7 @@ final class MenuBandRewindVoice { DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in guard let self = self, self.playGeneration == gen else { return } if self.player.isPlaying { self.player.stop() } - self.mixer.outputVolume = 1.0 + self.mixer.outputVolume = 0.88 } } diff --git a/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift b/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift index db6a2e6dbf..397c9bc8f2 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSampleVoice.swift @@ -289,9 +289,10 @@ final class MenuBandSampleVoice { } // No launch-time hot-mic prewarm: opening the input device here made // macOS surface the mic-mode HUD (Standard / Voice Isolation) at - // EVERY launch, even when the user never monitors or records. The - // mic now opens only when something actually needs it — monitoring - // toggled on, or the first record-key press (which pays the + // EVERY launch — and lit the orange privacy indicator on an instrument + // with no visible input interface — even when the user never monitors + // or records. The mic now opens only when something actually needs it: + // monitoring toggled on, or the first record-key press (which pays the // documented ~50–200 ms cold start once; the mic then stays hot for // `hotMicIdleSeconds` so subsequent takes are instant). } @@ -761,6 +762,65 @@ final class MenuBandSampleVoice { } } + /// Stop capture now when no recording consumer still owns it. UI close + /// uses this instead of the normal 30-second re-recording grace period. + func stopUnpinnedHotMicNow() { + guard !recording, hotMicPinReasons.isEmpty else { return } + hotMicStopWork?.cancel() + hotMicStopWork = nil + if inputTapInstalled { + recordEngine.inputNode.removeTap(onBus: 0) + inputTapInstalled = false + } + if monitorOutputTapInstalled { + recordEngine.mainMixerNode.removeTap(onBus: 0) + monitorOutputTapInstalled = false + } + inputMonitorPlayer.stop() + inputMonitorQueueLock.withLock { inputMonitorQueuedBuffers = 0 } + recordEngine.stop() + inputConverter = nil + inputFormat = nil + boundInputDeviceID = 0 + NSLog("MenuBand SampleVoice: unpinned microphone stopped") + } + + /// Release microphone ownership immediately during app termination. + /// The normal idle path deliberately lingers for fast re-recording, but + /// shutdown must not leave that timer, the input tap, or the dedicated + /// record engine alive while AppKit is tearing down the process. + func shutdown() { + hotMicStopWork?.cancel() + hotMicStopWork = nil + monitorConfigRecovery?.cancel() + monitorConfigRecovery = nil + hotMicPinReasons.removeAll() + recordingRequested = false + recording = false + onInputBuffer = nil + onLevel = nil + inputMonitoringEnabled = false + inputMonitorPlayer.stop() + inputMonitorQueueLock.withLock { inputMonitorQueuedBuffers = 0 } + if inputTapInstalled { + recordEngine.inputNode.removeTap(onBus: 0) + inputTapInstalled = false + } + if monitorOutputTapInstalled { + recordEngine.mainMixerNode.removeTap(onBus: 0) + monitorOutputTapInstalled = false + } + recordEngine.stop() + inputConverter = nil + inputFormat = nil + boundInputDeviceID = 0 + if let observer = monitorConfigObserver { + NotificationCenter.default.removeObserver(observer) + monitorConfigObserver = nil + } + NSLog("MenuBand SampleVoice: microphone shutdown complete") + } + /// Replace the global Piano Sampler recording with audio supplied by an /// in-process source such as CDJ Radio. Unlike microphone recording this /// is an instantaneous, explicit handoff: no input permission, no key diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift index df5ccba9e5..72feb9b50f 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -191,16 +191,16 @@ final class MenuBandSynth { /// else holds the device at 512). When Menu Band IS the sole client the /// device drops to this size. /// - /// 512 @ 96 kHz ≈ 5.3 ms — a deliberately conservative budget. The full + /// 256 @ 96 kHz ≈ 2.7 ms (5.8 ms at 44.1 kHz). The full /// synth stack (GM C core + DLS MIDISynth + per-note sample voices + /// percussion + fx + limiter) must finish WITHIN this window every cycle /// or CoreAudio underruns and you hear a click/pop, in any playback. On a /// memory-constrained / few-core machine an ultra-low buffer (we used to /// dive to the device minimum, 32 frames / 0.33 ms) can't survive normal - /// scheduling jitter, so it popped constantly. 5.3 ms is still well below - /// the perceptual threshold for a keyboard instrument; bump it back down - /// only on a machine with headroom to spare. - private static let targetIOBufferFrames: UInt32 = 512 + /// scheduling jitter, so it popped constantly. TrackDrum stages sound + /// off-main; 256 keeps useful scheduler headroom without accepting the + /// 11.6 ms buffer delay of 512 frames at 44.1 kHz. + private static let targetIOBufferFrames: UInt32 = 256 /// True once we've successfully taken hog mode on the active /// output device. Tracks the device ID alongside so we can /// release the right one on shutdown / device switch. @@ -433,6 +433,9 @@ final class MenuBandSynth { func unpinHotMic(reason: String) { sampleVoice.removeHotMicPin(reason) } + func stopUnpinnedHotMicNow() { + sampleVoice.stopUnpinnedHotMicNow() + } // Tap-driven ring buffer for the popover's live waveform display. private static let waveformRingSize = 4096 @@ -501,7 +504,9 @@ final class MenuBandSynth { // Duplex monitor: attached only when monitoring is actually wanted // AND the input/output resolve to the same interface — see // `duplexMonitorEligible` for why an unconditional attach kills - // the whole engine when they differ. + // the whole engine when they differ. Attaching unconditionally also + // made CoreAudio hold audio-in (orange privacy indicator) on a + // menu-bar instrument that was never monitoring. syncInputMonitorGraph() // Speech easter egg: same pre-limiter sum bus, so spoken language // names ride the bend/space/echo fx. Idle (no player) until `speak`. @@ -994,17 +999,17 @@ final class MenuBandSynth { } /// Spacebar reverse-replay: play the master-output tape backwards from - /// the session's reverse cursor (first press anchors "now"; later - /// presses resume where the last one stopped). Returns false if there + /// the session anchor (first press anchors "now"; repeated presses dive + /// from that same point until a real note re-anchors it). Returns false if there /// wasn't enough captured audio to rewind (caller can cue a fallback). @discardableResult func playReverse() -> Bool { return rewindVoice.playReverse() } - /// Spacebar released — bank the reverse playhead into the session - /// cursor and stop the voice; the next press resumes from that same - /// reverse point. Playing a note resets the cursor to the live head + /// Spacebar released — fade and stop the voice, lifting the playhead back + /// to the session anchor so the next press repeats the dive. Playing a + /// note resets that anchor to the live head /// (see the `resetReverseAnchor` call in `noteOn`). func releaseReverse() { rewindVoice.release() @@ -1017,6 +1022,9 @@ final class MenuBandSynth { /// Reverse playback progress (0…1), or nil if not reversing — for the /// strip's exact, drift-free playhead. func rewindProgress() -> Double? { rewindVoice.reverseProgress() } + func rewindClipProgress() -> Double? { rewindVoice.currentClipProgress() } + func rewindPreviewLevels() -> [Float] { rewindVoice.reversePreviewLevels() } + func rewindLiveInputPeak() -> Float { rewindVoice.takeLiveInputPeak() } /// Route the trackpad pitch-bend / echo onto the spacebar tape playback /// (its own DRY inserts — see MenuBandRewindVoice), so moving the mouse @@ -2217,6 +2225,11 @@ final class MenuBandSynth { func stop() { engineLock.lock(); defer { engineLock.unlock() } + // The microphone uses SampleVoice's separate input-only engine, so it + // must be torn down even if the synth output engine never started or + // has already stopped. This is synchronous: application termination + // returns only after CoreAudio input ownership has been released. + sampleVoice.shutdown() guard started else { return } if let obs = configChangeObserver { NotificationCenter.default.removeObserver(obs) @@ -2260,6 +2273,11 @@ final class MenuBandSynth { // would otherwise leave the engine paused and the hit silent. Wake // it (and cancel any pending suspend) before staging the voices. _ = resumeAudioEngineIfNeeded() + // Percussion prints to the same tape as melodic notes (it joins the + // pre-limiter sum), so it re-syncs the reverse clock the same way — + // a Space press after drumming dives into the drums just played, + // not whatever tape an older session anchored. + rewindVoice.resetReverseAnchor() let p = max(-1.0, min(1.0, (Double(pan) - 64.0) / 63.0)) let depth: Float let hold: TimeInterval @@ -2282,6 +2300,7 @@ final class MenuBandSynth { func playReverseKick(velocity: UInt8, pan: UInt8) { guard started else { return } _ = resumeAudioEngineIfNeeded() + rewindVoice.resetReverseAnchor() let p = max(-1.0, min(1.0, (Double(pan) - 64.0) / 63.0)) triggerMelodicDuck(depth: 0.38, velocity: velocity, hold: 0.12) percussion.playReverseKick(velocity: velocity, pan: p) @@ -2291,6 +2310,7 @@ final class MenuBandSynth { func playDrumSkin(strike: CGPoint, anchors: [CGPoint], velocity: UInt8) { guard started else { return } _ = resumeAudioEngineIfNeeded() + rewindVoice.resetReverseAnchor() let zone = MenuBandPercussion.drumSkinZone(at: strike) let depth: Float = zone == .kick ? 0.40 : (zone == .tom ? 0.30 : (zone == .snare ? 0.22 : 0.10)) @@ -2303,6 +2323,7 @@ final class MenuBandSynth { func playSynthSurface(strike: CGPoint, anchors: [CGPoint], velocity: UInt8) { guard started else { return } _ = resumeAudioEngineIfNeeded() + rewindVoice.resetReverseAnchor() let zone = MenuBandPercussion.drumSkinZone(at: strike) triggerMelodicDuck(depth: zone == .kick ? 0.34 : 0.16, velocity: velocity, hold: 0.07) @@ -2314,6 +2335,7 @@ final class MenuBandSynth { velocity: UInt8, synthetic: Bool) { guard started else { return } _ = resumeAudioEngineIfNeeded() + rewindVoice.resetReverseAnchor() triggerMelodicDuck(depth: 0.10, velocity: velocity, hold: 0.04) percussion.playSurfaceLift(at: point, anchors: anchors, velocity: velocity, synthetic: synthetic) diff --git a/slab/menuband/Sources/MenuBand/PitchBendCursor.swift b/slab/menuband/Sources/MenuBand/PitchBendCursor.swift index 8680422e53..bc40c56825 100644 --- a/slab/menuband/Sources/MenuBand/PitchBendCursor.swift +++ b/slab/menuband/Sources/MenuBand/PitchBendCursor.swift @@ -1058,9 +1058,11 @@ private final class TrackDrumInstallCard: NSVisualEffectView { /// cursor so the chart visibly replaces it. final class PitchBendCursorOverlayWindow: NSPanel { static let trackDrumInstallSize = NSSize(width: 260, height: 126) + static let polyrhythmGap: CGFloat = 12 private let imageView = NSImageView() private let tracktrampView = TracktrampMetalView() + private let polyrhythmView = PolyrhythmTrainerView() private let installCard = TrackDrumInstallCard() private let installIcon = NSImageView() private let installTagline = NSTextField( @@ -1096,6 +1098,8 @@ final class PitchBendCursorOverlayWindow: NSPanel { tracktrampView.frame = frame tracktrampView.isHidden = true contentView?.addSubview(tracktrampView) + polyrhythmView.isHidden = true + contentView?.addSubview(polyrhythmView) installCard.material = .popover installCard.blendingMode = .behindWindow @@ -1142,6 +1146,7 @@ final class PitchBendCursorOverlayWindow: NSPanel { installAction = nil installCard.isHidden = true tracktrampView.isHidden = true + polyrhythmView.isHidden = true imageView.isHidden = false } @@ -1174,6 +1179,7 @@ final class PitchBendCursorOverlayWindow: NSPanel { /// small height field changes between frames; no NSImage is constructed. func showTracktramp(_ membrane: TrackpadMembraneSimulation.Snapshot, touches: [CGPoint], + polyrhythm: PolyrhythmTrainerSnapshot? = nil, atScreenPoint screenPoint: NSPoint) { fadeTimer?.invalidate() fadeTimer = nil @@ -1181,25 +1187,28 @@ final class PitchBendCursorOverlayWindow: NSPanel { ignoresMouseEvents = true installAction = nil installCard.isHidden = true - applyTracktrampFrame() + applyTracktrampFrame(polyrhythm: polyrhythm) imageView.isHidden = true tracktrampView.isHidden = false tracktrampView.update(membrane, touches: touches) + polyrhythmView.snapshot = polyrhythm alphaValue = 1 if !isVisible { orderFrontRegardless() } } func updateTracktramp(_ membrane: TrackpadMembraneSimulation.Snapshot, touches: [CGPoint], + polyrhythm: PolyrhythmTrainerSnapshot? = nil, atScreenPoint screenPoint: NSPoint) { anchorScreenPoint = screenPoint ignoresMouseEvents = true installAction = nil installCard.isHidden = true - applyTracktrampFrame() + applyTracktrampFrame(polyrhythm: polyrhythm) imageView.isHidden = true tracktrampView.isHidden = false tracktrampView.update(membrane, touches: touches) + polyrhythmView.snapshot = polyrhythm } /// Missing-helper state for the Tab-selected percussion surface. This is @@ -1213,6 +1222,7 @@ final class PitchBendCursorOverlayWindow: NSPanel { installAction = onInstall imageView.isHidden = true tracktrampView.isHidden = true + polyrhythmView.isHidden = true installCard.isHidden = false ignoresMouseEvents = false @@ -1238,12 +1248,46 @@ final class PitchBendCursorOverlayWindow: NSPanel { if !isVisible { orderFrontRegardless() } } - private func applyTracktrampFrame() { - let size = TracktrampMetalView.logicalSize - setFrame(NSRect(x: anchorScreenPoint.x - size.width / 2, - y: anchorScreenPoint.y - size.height / 2, - width: size.width, height: size.height), display: false) - tracktrampView.frame = NSRect(origin: .zero, size: size) + private func applyTracktrampFrame(polyrhythm: PolyrhythmTrainerSnapshot?) { + let size = Self.tracktrampSurfaceSize(polyrhythm: polyrhythm) + let panelFrame = NSRect(x: anchorScreenPoint.x - size.width / 2, + y: anchorScreenPoint.y - size.height / 2, + width: size.width, height: size.height) + if frame != panelFrame { setFrame(panelFrame, display: false) } + let tracktrampFrame = NSRect( + x: (size.width - TracktrampMetalView.logicalSize.width) / 2, + y: 0, + width: TracktrampMetalView.logicalSize.width, + height: TracktrampMetalView.logicalSize.height + ) + if tracktrampView.frame != tracktrampFrame { tracktrampView.frame = tracktrampFrame } + let trainerSize = PolyrhythmTrainerView.logicalSize( + rhythmCount: polyrhythm?.rhythms.count ?? 1 + ) + let polyrhythmFrame = NSRect( + x: (size.width - trainerSize.width) / 2, + y: TracktrampMetalView.logicalSize.height + Self.polyrhythmGap, + width: trainerSize.width, + height: trainerSize.height + ) + if polyrhythmView.frame != polyrhythmFrame { polyrhythmView.frame = polyrhythmFrame } + polyrhythmView.isHidden = polyrhythm == nil + } + + static func tracktrampSurfaceSize(polyrhythm: PolyrhythmTrainerSnapshot?) -> NSSize { + guard let polyrhythm else { return TracktrampMetalView.logicalSize } + return tracktrampSurfaceSize(rhythmCount: polyrhythm.rhythms.count) + } + + /// The surface grows sideways with the circle count so the trainer never + /// clips, while TrackDrum's own artwork stays centered at its fixed size. + static func tracktrampSurfaceSize(rhythmCount: Int) -> NSSize { + let trainer = PolyrhythmTrainerView.logicalSize(rhythmCount: rhythmCount) + return NSSize(width: max(TracktrampMetalView.logicalSize.width, + trainer.width), + height: TracktrampMetalView.logicalSize.height + + Self.polyrhythmGap + + trainer.height) } /// A new hardware contact reclaims a surface that is still visible but @@ -1302,6 +1346,7 @@ final class PitchBendCursorOverlayWindow: NSPanel { ignoresMouseEvents = true installAction = nil installCard.isHidden = true + polyrhythmView.isHidden = true tracktrampView.pause() orderOut(nil) } diff --git a/slab/menuband/Sources/MenuBand/PolyrhythmTrainer.swift b/slab/menuband/Sources/MenuBand/PolyrhythmTrainer.swift new file mode 100644 index 0000000000..541887b4a7 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/PolyrhythmTrainer.swift @@ -0,0 +1,458 @@ +import AppKit + +/// One shared cycle rendered as N side-by-side clocks — one circle per +/// rhythm, so each finger can individuate a rhythm on the trackpad (left +/// band strikes the left circle, and so on). Keeping the clock pure makes +/// its boundary behavior testable without audio or AppKit timers. +struct PolyrhythmTrainerClock { + struct Pattern: Equatable { + let counts: [Int] + var label: String { counts.map(String.init).joined(separator: ":") } + init(_ counts: [Int]) { self.counts = counts } + } + + static let patterns = [ + Pattern([3, 2]), + Pattern([4, 3]), + Pattern([5, 4]), + Pattern([3, 4, 5]), + ] + + private(set) var isActive = false + private(set) var startedAt: CFTimeInterval = 0 + private(set) var patternIndex = 0 + private(set) var bpm = 75 + private var lastOrdinals: [Int] = [] + private var lastPulseAt: CFTimeInterval = -.infinity + private struct RecordedTap { + let at: CFTimeInterval + let phase: Double + let rhythmIndex: Int + let accuracy: Double + } + private var recordedTaps: [RecordedTap] = [] + var pattern: Pattern { Self.patterns[patternIndex] } + var cycleDuration: TimeInterval { + 60.0 / Double(bpm) * Double(pattern.counts[0]) + } + + mutating func start(at now: CFTimeInterval) { + isActive = true + startedAt = now + lastOrdinals = Array(repeating: -1, count: pattern.counts.count) + lastPulseAt = -.infinity + recordedTaps.removeAll(keepingCapacity: true) + } + + mutating func stop() { + isActive = false + lastOrdinals = [] + lastPulseAt = -.infinity + recordedTaps.removeAll(keepingCapacity: true) + } + + /// `/` walks a finite loop so the feature remains easy to dismiss: + /// off → 3:2 → 4:3 → 5:4 → 3:4:5 → off. + mutating func cyclePattern(at now: CFTimeInterval) { + if !isActive { + patternIndex = 0 + start(at: now) + } else if patternIndex + 1 < Self.patterns.count { + patternIndex += 1 + start(at: now) + } else { + stop() + } + } + + mutating func changeRate(by delta: Int, at now: CFTimeInterval) { + guard isActive, delta != 0 else { return } + let oldDuration = cycleDuration + let oldPhase = (max(0, now - startedAt) / oldDuration) + .truncatingRemainder(dividingBy: 1) + bpm = min(300, max(30, bpm + delta)) + startedAt = now - oldPhase * cycleDuration + lastOrdinals = Array(repeating: -1, count: pattern.counts.count) + } + + /// The trackpad splits into equal vertical bands, one per circle, + /// mirroring the on-screen left-to-right layout: for 3:2 the left half + /// strikes the 3-circle and the right half the 2-circle. + static func rhythmIndex(forNormalizedX x: Double, rhythmCount: Int) -> Int { + guard rhythmCount > 0 else { return 0 } + let clamped = min(max(x, 0), 1) + return min(rhythmCount - 1, Int(clamped * Double(rhythmCount))) + } + + private func phase(at now: CFTimeInterval) -> Double { + let cycle = max(0, now - startedAt) / cycleDuration + return cycle - floor(cycle) + } + + /// 0 on a grid line, 1 exactly between two grid lines of that rhythm. + private static func gridError(phase: Double, count: Int) -> Double { + abs(phase * Double(count) - (phase * Double(count)).rounded()) * 2 + } + + /// Route a strike to the circle under the finger's horizontal band and + /// score it against that circle's own grid ONLY — a tap on the 3-circle + /// is never excused by landing near a 2-circle beat. + mutating func registerTap(at now: CFTimeInterval, normalizedX: Double) { + record(at: now, rhythmIndex: Self.rhythmIndex( + forNormalizedX: normalizedX, rhythmCount: pattern.counts.count + )) + } + + /// Positionless fallback (no touch coordinate at the call site): place + /// the strike on the rhythm whose grid it most closely fits, ties going + /// to the leftmost circle. + mutating func registerTap(at now: CFTimeInterval) { + guard isActive else { return } + let phase = phase(at: now) + let index = pattern.counts.indices.min(by: { + Self.gridError(phase: phase, count: pattern.counts[$0]) + < Self.gridError(phase: phase, count: pattern.counts[$1]) + }) ?? 0 + record(at: now, rhythmIndex: index) + } + + /// Accuracy is normalized within half of the chosen rhythm's beat + /// interval. + private mutating func record(at now: CFTimeInterval, rhythmIndex: Int) { + guard isActive, pattern.counts.indices.contains(rhythmIndex) else { + return + } + let phase = phase(at: now) + let error = Self.gridError(phase: phase, count: pattern.counts[rhythmIndex]) + recordedTaps.append(RecordedTap( + at: now, phase: phase, rhythmIndex: rhythmIndex, + accuracy: 1 - min(1, error) + )) + recordedTaps = recordedTaps.filter { now - $0.at < 0.65 } + if recordedTaps.count > 12 { recordedTaps.removeFirst(recordedTaps.count - 12) } + } + + /// Indices of the rhythms whose beat boundary passed since the last + /// tick, left to right. Each index maps to its own click voice so the + /// circles stay audibly distinct. + mutating func tick(at now: CFTimeInterval) -> [Int] { + guard isActive else { return [] } + if lastOrdinals.count != pattern.counts.count { + lastOrdinals = Array(repeating: -1, count: pattern.counts.count) + } + let elapsed = max(0, now - startedAt) + var fired: [Int] = [] + for (index, count) in pattern.counts.enumerated() { + let ordinal = Int(floor(elapsed / cycleDuration * Double(count))) + if ordinal != lastOrdinals[index] { + fired.append(index) + lastOrdinals[index] = ordinal + } + } + if !fired.isEmpty { lastPulseAt = now } + return fired + } + + func snapshot(at now: CFTimeInterval) -> PolyrhythmTrainerSnapshot? { + guard isActive else { return nil } + let elapsed = max(0, now - startedAt) + let cycle = elapsed / cycleDuration + return PolyrhythmTrainerSnapshot( + phase: cycle - floor(cycle), + rhythms: pattern.counts.map { + PolyrhythmRhythmSnapshot( + count: $0, + step: Int(floor(cycle * Double($0))) % $0 + ) + }, + label: pattern.label, + bpm: bpm, + needleFlash: max(0, 1 - (now - lastPulseAt) / 0.12), + tapFeedback: recordedTaps.compactMap { tap in + let age = now - tap.at + guard age >= 0, age < 0.6 else { return nil } + return PolyrhythmTapFeedback( + phase: tap.phase, + rhythmIndex: tap.rhythmIndex, + accuracy: tap.accuracy, + opacity: 1 - age / 0.6 + ) + } + ) + } +} + +struct PolyrhythmRhythmSnapshot { + let count: Int + let step: Int +} + +struct PolyrhythmTrainerSnapshot { + let phase: Double + let rhythms: [PolyrhythmRhythmSnapshot] + let label: String + let bpm: Int + let needleFlash: Double + let tapFeedback: [PolyrhythmTapFeedback] +} + +struct PolyrhythmTapFeedback { + let phase: Double + let rhythmIndex: Int + let accuracy: Double + let opacity: Double +} + +/// Compact, distance-readable polyrhythm notation: one clock per rhythm, +/// laid side by side so each circle sits above the trackpad band that +/// strikes it. Dots are beats, the radial hand is shared time, and each +/// circle's center digit is its subdivision count. +final class PolyrhythmTrainerView: NSView { + static let circleSlotWidth: CGFloat = 100 + static let circleGap: CGFloat = 8 + static let sideMargin: CGFloat = 6 + static let fixedHeight: CGFloat = 116 + private static let faceRadius: CGFloat = 44 + private static let dotRadius: CGFloat = 34 + private static let handLength: CGFloat = 38 + private static let circleCenterY: CGFloat = 64 + + static func logicalSize(rhythmCount: Int) -> NSSize { + let circles = CGFloat(max(1, rhythmCount)) + return NSSize( + width: sideMargin * 2 + circles * circleSlotWidth + + (circles - 1) * circleGap, + height: fixedHeight + ) + } + + /// Left to right: accent, ink, then a blend for a third rhythm — three + /// voices that stay apart without adding loud new hues. + static func circleColors(accent: NSColor, ink: NSColor, + count: Int) -> [NSColor] { + let blend = accent.blended(withFraction: 0.55, of: ink) ?? accent + let palette = [accent, ink, blend] + return (0.. 0 ? color : ink).withAlphaComponent(0.78 + flash * 0.22).setStroke() + hand.lineWidth = 1.5 + flash * 2.5 + hand.stroke() + let hub = NSBezierPath(ovalIn: NSRect(x: center.x - 2.5, y: center.y - 2.5, + width: 5, height: 5)) + ink.setFill(); hub.fill() + + let label = "\(rhythm.count)" + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .bold), + .foregroundColor: ink.withAlphaComponent(0.88) + ] + let size = label.size(withAttributes: attrs) + label.draw(at: CGPoint(x: center.x - size.width / 2, + y: center.y - size.height - 3), + withAttributes: attrs) + } + + private func drawTapFeedback(_ feedback: [PolyrhythmTapFeedback], + center: CGPoint, accent: NSColor) { + for tap in feedback { + let angle = CGFloat.pi / 2 - CGFloat(tap.phase) * 2 * .pi + let point = CGPoint(x: center.x + cos(angle) * Self.dotRadius, + y: center.y + sin(angle) * Self.dotRadius) + let color: NSColor = tap.accuracy >= 0.7 + ? accent : (tap.accuracy >= 0.4 ? .systemOrange : .systemRed) + let mark = NSBezierPath(ovalIn: NSRect(x: point.x - 6, y: point.y - 6, + width: 12, height: 12)) + color.withAlphaComponent(CGFloat(tap.opacity) * 0.9).setStroke() + mark.lineWidth = 2 + mark.stroke() + } + } +} + +/// Headless render of the real guide stacked over TrackDrum artwork. This is +/// intentionally a QA door, not another product surface — reel tooling +/// composites these PNGs. +/// +/// Flags after `--render-polyrhythm`: +/// --pattern 3:2 colon-separated subdivision counts (e.g. 3:4:5) +/// --phase 0.25 cycle phase 0…1 (wraps; default 0.18) +/// --bpm 85 tempo readout + needle-flash timing (default 85) +/// --light | --dark appearance (light is the default; reels use light) +/// --scale 4 pixel scale (default 4) +/// --out /path.png output PNG (default /tmp/menuband-polyrhythm.png) +enum PolyrhythmTrainerCLI { + static func runIfRequested(_ args: [String]) -> Bool { + guard args.contains("--render-polyrhythm") else { return false } + func value(_ flag: String) -> String? { + guard let index = args.firstIndex(of: flag), index + 1 < args.count else { + return nil + } + return args[index + 1] + } + let output = value("--out") ?? "/tmp/menuband-polyrhythm.png" + let scale = max(1, Double(value("--scale") ?? "4") ?? 4) + let counts = (value("--pattern") ?? "3:2").split(separator: ":") + .compactMap { Int($0) }.filter { $0 >= 1 } + guard !counts.isEmpty else { + FileHandle.standardError.write(Data( + "polyrhythm: --pattern wants colon-separated counts like 3:2 or 3:4:5\n" + .utf8 + )) + exit(1) + } + let rawPhase = Double(value("--phase") ?? "0.18") ?? 0.18 + let phase = rawPhase - floor(rawPhase) + let bpm = min(300, max(30, Int(value("--bpm") ?? "85") ?? 85)) + let app = NSApplication.shared + app.setActivationPolicy(.prohibited) + let dark = args.contains("--dark") && !args.contains("--light") + app.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + + // Deterministic: the same flags always yield the same pixels, so a + // reel can sweep --phase frame by frame. The needle flash re-derives + // the clock's 0.12 s decay from phase distance to the nearest beat. + let cycleDuration = 60.0 / Double(bpm) * Double(counts[0]) + let sinceBeat = counts.map { count -> Double in + let beats = phase * Double(count) + return (beats - floor(beats)) / Double(count) * cycleDuration + }.min() ?? .infinity + let snapshot = PolyrhythmTrainerSnapshot( + phase: phase, + rhythms: counts.map { + PolyrhythmRhythmSnapshot( + count: $0, step: Int(floor(phase * Double($0))) % $0 + ) + }, + label: counts.map(String.init).joined(separator: ":"), + bpm: bpm, + needleFlash: max(0, 1 - sinceBeat / 0.12), + tapFeedback: [] + ) + + let size = PitchBendCursorOverlayWindow.tracktrampSurfaceSize( + rhythmCount: counts.count + ) + let root = NSView(frame: NSRect(origin: .zero, size: size)) + root.appearance = app.appearance + let skin = NSImageView(frame: NSRect( + x: (size.width - TracktrampMetalView.logicalSize.width) / 2, + y: 0, + width: TracktrampMetalView.logicalSize.width, + height: TracktrampMetalView.logicalSize.height + )) + skin.image = TrackpadDrumSkinPad.image(touches: [], energy: [], membrane: nil, + appearance: app.appearance) + root.addSubview(skin) + let trainerSize = PolyrhythmTrainerView.logicalSize(rhythmCount: counts.count) + let circles = PolyrhythmTrainerView(frame: NSRect( + x: (size.width - trainerSize.width) / 2, + y: TracktrampMetalView.logicalSize.height + + PitchBendCursorOverlayWindow.polyrhythmGap, + width: trainerSize.width, + height: trainerSize.height + )) + circles.appearance = app.appearance + circles.snapshot = snapshot + root.addSubview(circles) + root.displayIfNeeded() + + let pixelWidth = Int((size.width * scale).rounded()) + let pixelHeight = Int((size.height * scale).rounded()) + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: pixelWidth, pixelsHigh: pixelHeight, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 + ) else { return true } + bitmap.size = size + root.cacheDisplay(in: root.bounds, to: bitmap) + guard let png = bitmap.representation(using: .png, properties: [:]) else { + return true + } + do { try png.write(to: URL(fileURLWithPath: output)) } + catch { + FileHandle.standardError.write(Data("polyrhythm write failed: \(error)\n".utf8)) + exit(1) + } + print("polyrhythm \(pixelWidth)x\(pixelHeight) → \(output)") + return true + } +} diff --git a/slab/menuband/Sources/MenuBand/ReverseWaveformHUD.swift b/slab/menuband/Sources/MenuBand/ReverseWaveformHUD.swift new file mode 100644 index 0000000000..6797495db3 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/ReverseWaveformHUD.swift @@ -0,0 +1,110 @@ +import AppKit + +/// A momentary, non-activating tape monitor. Space owns its visibility: the +/// panel appears for the reverse dive and dissolves after release without +/// moving focus away from the app being played. +/// +/// Two stacked displays, one story: the TOP strip is the tape — the exact +/// reversed clip, frozen, with the orange needle diving right→left across +/// it. The BOTTOM strip is the record meter — a live red sweep of what you +/// are playing RIGHT NOW, which `feed` is printing backwards onto the tape +/// under that same needle (the reverse dub). Sound in the bottom bar while +/// the needle moves above it = new material landing on the tape. +final class ReverseWaveformHUD { + private let panel: NSPanel + private let waveform: WaveformStripView + private let dubMeter: WaveformStripView + private weak var menuBand: MenuBandController? + private var hideGeneration: UInt64 = 0 + + init(menuBand: MenuBandController) { + self.menuBand = menuBand + + waveform = WaveformStripView(frame: NSRect(x: 7, y: 33, width: 346, height: 44)) + waveform.menuBand = menuBand + waveform.tintColor = NSColor.systemOrange + waveform.hoverColorFeedbackEnabled = false + waveform.usesReverseClipProgress = true + waveform.usesDisplayLink = true + waveform.isLive = false + + dubMeter = WaveformStripView(frame: NSRect(x: 7, y: 7, width: 346, height: 22)) + dubMeter.menuBand = menuBand + dubMeter.tintColor = NSColor.systemRed + dubMeter.hoverColorFeedbackEnabled = false + dubMeter.usesDisplayLink = true + dubMeter.isLive = false + // The live input alone — the tape tap sits upstream of the reverse + // player, so the dive's own sound can't paint itself into this meter. + dubMeter.externalLevelSource = { [weak menuBand] in + menuBand?.rewindLiveInputPeak() ?? 0 + } + + panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 84), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.ignoresMouseEvents = true + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.alphaValue = 0 + + let plate = NSView(frame: NSRect(origin: .zero, size: panel.frame.size)) + plate.wantsLayer = true + plate.layer?.cornerRadius = 9 + plate.layer?.masksToBounds = true + plate.layer?.backgroundColor = NSColor(srgbRed: 0.025, green: 0.03, blue: 0.03, alpha: 0.94).cgColor + plate.addSubview(waveform) + plate.addSubview(dubMeter) + panel.contentView = plate + } + + func show(levels: [Float], above trackDrumFrame: NSRect?, on screen: NSScreen?) { + hideGeneration &+= 1 + position(above: trackDrumFrame, on: screen) + waveform.seedWaveform(levels: levels, cursorAt: 1) + waveform.isLive = true + // Fresh sweep each dive, and drop any peak that accumulated between + // dives so old sound can't flash into the first meter column. + dubMeter.resetRaster() + _ = menuBand?.rewindLiveInputPeak() + dubMeter.isLive = true + panel.orderFrontRegardless() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.07 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().alphaValue = 1 + } + } + + func hide() { + hideGeneration &+= 1 + let generation = hideGeneration + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.12 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + panel.animator().alphaValue = 0 + }, completionHandler: { [weak self] in + guard let self, self.hideGeneration == generation else { return } + self.panel.orderOut(nil) + self.waveform.isLive = false + self.dubMeter.isLive = false + }) + } + + private func position(above trackDrumFrame: NSRect?, on screen: NSScreen?) { + let size = panel.frame.size + let visible = (screen ?? NSScreen.main)?.visibleFrame ?? .zero + let x = visible.midX - size.width / 2 + let trackTop = trackDrumFrame?.maxY ?? (visible.minY + 126) + let y = min(visible.maxY - size.height - 8, trackTop + 12) + panel.setFrameOrigin(NSPoint(x: x, y: y)) + } +} diff --git a/slab/menuband/Sources/MenuBand/WaveformStripView.swift b/slab/menuband/Sources/MenuBand/WaveformStripView.swift index 728d98b97f..a1f8567b53 100644 --- a/slab/menuband/Sources/MenuBand/WaveformStripView.swift +++ b/slab/menuband/Sources/MenuBand/WaveformStripView.swift @@ -48,6 +48,27 @@ final class WaveformStripView: NSView { didSet { needsDisplay = true } } + /// Momentary reverse HUDs show one exact scheduled clip, so their head + /// should cross the whole strip using clip time rather than the broader + /// persistent tape-session clock. + var usesReverseClipProgress = false + + /// Drive repaints from the display's own clock instead of the 30 fps + /// timer. The momentary HUD opts in so its head glides at native refresh + /// (120 Hz on ProMotion); the always-on popover strip stays at 30 fps. + var usesDisplayLink = false + + /// When set, the strip sweeps THIS signal instead of the synth's + /// visualizer capture: a read-and-clear peak per call. Also bypasses the + /// reverse freeze — an input meter keeps sweeping while the tape + /// reverses, which is the whole point of the HUD's dub bar. + var externalLevelSource: (() -> Float)? + + /// Tape speed of the live sweep, in columns per second — independent of + /// the repaint rate, so a display-link strip doesn't sweep 4× faster + /// than a timer strip. + var columnRate: Double = 30 + /// Fired when the display is clicked. The popover wires this to open the /// full keymap view — same action as the "Keymap" button — so the live /// scope doubles as a keymap affordance. @@ -144,6 +165,23 @@ final class WaveformStripView: NSView { /// draw loop allocates nothing. private var columnRects: [CGRect] = [] private var timer: Timer? + /// The display-link drive when `usesDisplayLink` is on (macOS 14+). Typed + /// `Any` because `CADisplayLink` postdates the deployment target. + private var displayLinkObj: Any? + /// Wall-clock of the previous tick — every per-tick rate (tape advance, + /// head easing) derives from real elapsed time, not from an assumed + /// frame interval. + private var lastTickTime: Double = 0 + /// Time accumulated toward the next column advance (see `columnRate`). + private var columnAccum: Double = 0 + /// The in-progress column's running min/max — transients land on screen + /// the tick they sound, then the column seals when the tape advances. + private var pendingMin: Float = 0 + private var pendingMax: Float = 0 + /// Silence does not need a 30 fps Core Graphics repaint. Keep sampling so + /// the first transient is caught, but coalesce the empty-grid animation to + /// 6 fps until audio becomes visible again. + private var quietTicks = 0 /// While reversing: eased column offset of the playhead back from the /// cursor, driven by the audio's actual reverse progress. private var scrubOffset: CGFloat = 0 @@ -194,32 +232,69 @@ final class WaveformStripView: NSView { } private func start() { - guard timer == nil else { return } - menuBand?.setWaveformCaptureEnabled(true) - let t = Timer(timeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in - self?.tick() + guard timer == nil, displayLinkObj == nil else { return } + // An externally-fed strip never touches the shared visualizer + // capture, so it can't steal the pin from the scope that owns it. + if externalLevelSource == nil { menuBand?.setWaveformCaptureEnabled(true) } + lastTickTime = CACurrentMediaTime() + columnAccum = 0 + if usesDisplayLink, #available(macOS 14.0, *) { + let link = displayLink(target: self, selector: #selector(displayTick(_:))) + link.add(to: .main, forMode: .common) + displayLinkObj = link + } else { + // Display-link opt-in on an older OS still gets a faster clock. + let interval = usesDisplayLink ? 1.0 / 60.0 : 1.0 / 30.0 + let t = Timer(timeInterval: interval, repeats: true) { [weak self] _ in + self?.tick() + } + t.tolerance = interval / 3 + RunLoop.main.add(t, forMode: .common) + timer = t } - t.tolerance = 1.0 / 90.0 - RunLoop.main.add(t, forMode: .common) - timer = t } private func stop() { + // Only release capture ownership if this view actually acquired it. + // Hidden pre-warmed views receive viewDidMoveToWindow too; treating + // that as a release could steal the pin from another visible scope. + guard timer != nil || displayLinkObj != nil else { return } timer?.invalidate(); timer = nil - menuBand?.setWaveformCaptureEnabled(false) + if #available(macOS 14.0, *), let link = displayLinkObj as? CADisplayLink { + link.invalidate() + } + displayLinkObj = nil + if externalLevelSource == nil { menuBand?.setWaveformCaptureEnabled(false) } } + @available(macOS 14.0, *) + @objc private func displayTick(_ link: CADisplayLink) { tick() } + /// Stop capturing and clear the raster so the display shows the empty /// off-grid (used when `isLive` is switched off). private func freezeToBaseline() { stop() + resetRaster() + } + + /// Clear the raster and rewind the write cursor — a fresh sweep for a + /// momentary panel that just reappeared. + func resetRaster() { for i in 0.. 1 ? Double(c) / Double(fillEnd - 1) : 0 - let level = levels[min(levels.count - 1, Int(f * Double(levels.count - 1)))] + // Max-pool the level range this raster column covers. Nearest + // sampling could land between two envelope columns and drop a + // short transient — a single drum hit — from the display. + let lo = levels.count * c / fillEnd + let hi = max(lo + 1, levels.count * (c + 1) / fillEnd) + var level: Float = 0 + for l in lo.. 0 else { updateGridGeometry(); return } - let reversing = menuBand?.isRewinding ?? false + let now = CACurrentMediaTime() + let dt = min(0.25, max(0.0005, now - lastTickTime)) + lastTickTime = now + let reversing = externalLevelSource == nil && (menuBand?.isRewinding ?? false) if reversing { // Freeze the sweep; position the playhead from the ACTUAL reverse - // playback progress (exact, drift-free). The player's progress is - // polled at 30 fps and can land unevenly, so ease toward the - // audio-derived offset (snap on the first reverse frame) — the - // indicator glides while the lit columns stay put. - let windowCols = CGFloat(menuBand?.rewindWindowSeconds ?? 1.5) * 30.0 - let progress = menuBand?.rewindProgress() ?? 0 + // playback progress (exact, drift-free). Progress lands unevenly + // against the repaint clock, so ease toward the audio-derived + // offset (snap on the first reverse frame) with a rate-scaled + // factor — the glide looks the same at 30 and at 120 Hz. + let windowCols = usesReverseClipProgress + ? CGFloat(max(1, cols - 1)) + : CGFloat(menuBand?.rewindWindowSeconds ?? 1.5) * CGFloat(columnRate) + let progress = usesReverseClipProgress + ? (menuBand?.rewindClipProgress() ?? 0) + : (menuBand?.rewindProgress() ?? 0) let target = CGFloat(progress) * windowCols - scrubOffset = wasReversing ? scrubOffset + (target - scrubOffset) * 0.4 : target + let ease = CGFloat(1 - pow(0.6, dt * 30)) + scrubOffset = wasReversing ? scrubOffset + (target - scrubOffset) * ease : target + quietTicks = 0 } else { - // Live sweep: reduce this frame to one min/max column, write it at - // the cursor, advance, wrap (overwriting the oldest column). - menuBand?.synthSnapshotWaveform(into: &samples) + // Live sweep: fold this tick's signal into the column under the + // cursor immediately — a transient lights up the frame it sounds + // — and advance the cursor on the tape clock (`columnRate` + // columns/second) however often the repaint runs. var mn: Float = 0, mx: Float = 0 - for s in samples { - if s < mn { mn = s } - if s > mx { mx = s } + if let source = externalLevelSource { + let peak = source() + mx = peak + mn = -peak * 0.85 + } else { + menuBand?.synthSnapshotWaveform(into: &samples) + for s in samples { + if s < mn { mn = s } + if s > mx { mx = s } + } } - gridMin[cursor] = min(-0.012, mn) - gridMax[cursor] = max(0.012, mx) + pendingMin = min(pendingMin, mn) + pendingMax = max(pendingMax, mx) + gridMin[cursor] = min(-0.012, pendingMin) + gridMax[cursor] = max(0.012, pendingMax) written[cursor] = true - cursor = (cursor + 1) % cols + columnAccum += dt + let columnInterval = 1.0 / max(1, columnRate) + if columnAccum >= columnInterval { + // One advance per crossing; cap the carry so a hitch can't + // machine-gun the cursor across the strip. + columnAccum = min(columnAccum - columnInterval, columnInterval) + cursor = (cursor + 1) % cols + pendingMin = 0 + pendingMax = 0 + } scrubOffset = 0 + if max(abs(mn), abs(mx)) < 0.002 { + quietTicks += 1 + } else { + quietTicks = 0 + } } wasReversing = reversing - needsDisplay = true + if reversing || quietTicks == 0 || quietTicks % 5 == 0 { + needsDisplay = true + } } // ── Drawing ───────────────────────────────────────────────────────────── @@ -357,9 +472,17 @@ final class WaveformStripView: NSView { guard bounds.width > 6, bounds.height > 6 else { return } if cols == 0 { updateGridGeometry() } guard cols > 0, let ctx = NSGraphicsContext.current?.cgContext else { return } - let reversing = menuBand?.isRewinding ?? false - let accent = NSColor.controlAccentColor - let tint = tintColor ?? accent + // An externally-fed meter is always a live sweep — while the tape + // strip above it freezes into reverse styling, this one keeps its + // ordinary comet-tail look. + let reversing = externalLevelSource == nil && (menuBand?.isRewinding ?? false) + // Resolve dynamic AppKit colors once per frame. Calling + // `colorWithAlphaComponent` on a dynamic system color inside the + // column loop repeatedly enters CoreUI's theme catalog. + let accent = NSColor.controlAccentColor.usingColorSpace(.sRGB) + ?? NSColor.controlAccentColor + let rawTint = tintColor ?? accent + let tint = rawTint.usingColorSpace(.sRGB) ?? rawTint // The compact and Global Keys graphs opt into this color treatment. // The full-screen visualizer uses the same view but opts out; shifting // the entire wall as the pointer crosses it would be distracting. diff --git a/slab/menuband/Sources/MenuBand/main.swift b/slab/menuband/Sources/MenuBand/main.swift index d8f3999e58..803d64f3dc 100644 --- a/slab/menuband/Sources/MenuBand/main.swift +++ b/slab/menuband/Sources/MenuBand/main.swift @@ -47,6 +47,11 @@ if VisualizerCLI.runIfRequested(CommandLine.arguments) { exit(0) } +// Headless capture of TrackDrum's circle-per-rhythm polyrhythm trainer. +if PolyrhythmTrainerCLI.runIfRequested(CommandLine.arguments) { + exit(0) +} + // Singleton guard: when MenuBand is spawned by both launchd's // KeepAlive (after crash / sleep wake) AND MenuBandLauncher's // double-tap path at the same time, we get two instances fighting diff --git a/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift b/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift index 7716495a5d..96a490232f 100644 --- a/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift +++ b/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift @@ -468,6 +468,146 @@ final class ShapedownTests: XCTestCase { ) } + func testPolyrhythmClockStartsTogetherAndDividesOneCycleThreeAgainstTwo() { + var clock = PolyrhythmTrainerClock() + clock.start(at: 10) + XCTAssertEqual(clock.tick(at: 10), [0, 1]) + XCTAssertEqual(clock.snapshot(at: 10)!.needleFlash, 1) + XCTAssertEqual(clock.snapshot(at: 10.12)!.needleFlash, 0, accuracy: 0.000_001) + XCTAssertEqual(clock.tick(at: 10.79), []) + XCTAssertEqual(clock.tick(at: 10.81), [0]) + XCTAssertEqual(clock.tick(at: 11.21), [1]) + XCTAssertEqual(clock.tick(at: 11.61), [0]) + XCTAssertEqual(clock.tick(at: 12.41), [0, 1]) + } + + func testSlashCyclesFourPatternsThenTurnsTrainerOff() { + var clock = PolyrhythmTrainerClock() + clock.cyclePattern(at: 1) + XCTAssertEqual(clock.pattern, .init([3, 2])) + clock.cyclePattern(at: 2) + XCTAssertEqual(clock.pattern, .init([4, 3])) + clock.cyclePattern(at: 3) + XCTAssertEqual(clock.pattern, .init([5, 4])) + clock.cyclePattern(at: 4) + XCTAssertEqual(clock.pattern, .init([3, 4, 5])) + clock.cyclePattern(at: 5) + XCTAssertFalse(clock.isActive) + } + + func testThreeRhythmPatternTicksEveryCircleAtCycleStart() { + var clock = PolyrhythmTrainerClock() + for step in 1...4 { clock.cyclePattern(at: CFTimeInterval(step)) } + XCTAssertEqual(clock.pattern.label, "3:4:5") + XCTAssertEqual(clock.tick(at: 4), [0, 1, 2]) + // 75 bpm × 3 primary beats = 2.4 s cycle; the 5-circle subdivides + // it every 0.48 s. + XCTAssertEqual(clock.tick(at: 4.47), []) + XCTAssertEqual(clock.tick(at: 4.49), [2]) + } + + func testPolyrhythmRateStepsByFiveAndKeepsPhase() { + var clock = PolyrhythmTrainerClock() + clock.start(at: 10) + let before = clock.snapshot(at: 10.6)!.phase + clock.changeRate(by: 5, at: 10.6) + XCTAssertEqual(clock.bpm, 80) + XCTAssertEqual(clock.snapshot(at: 10.6)!.phase, before, accuracy: 0.000_001) + clock.changeRate(by: 500, at: 10.6) + XCTAssertEqual(clock.bpm, 300) + } + + func testTrackpadBandRoutesTapToItsOwnCircle() { + // 75 bpm 3:2 → 2.4 s cycle. 10.8 is exactly the 3-circle's second + // beat but lands a third of the way between the 2-circle's beats. + var clock = PolyrhythmTrainerClock() + clock.start(at: 10) + clock.registerTap(at: 10.8, normalizedX: 0.1) + clock.registerTap(at: 10.8, normalizedX: 0.9) + let feedback = clock.snapshot(at: 10.8)!.tapFeedback + XCTAssertEqual(feedback.count, 2) + XCTAssertEqual(feedback[0].rhythmIndex, 0) + XCTAssertGreaterThan(feedback[0].accuracy, 0.95) + XCTAssertEqual(feedback[1].rhythmIndex, 1) + XCTAssertEqual(feedback[1].accuracy, 1.0 / 3.0, accuracy: 0.000_001) + XCTAssertEqual(clock.snapshot(at: 11.5)!.tapFeedback.count, 0) + } + + func testTrackpadBandMathSplitsEvenlyAndClamps() { + XCTAssertEqual(PolyrhythmTrainerClock.rhythmIndex( + forNormalizedX: 0.2, rhythmCount: 3), 0) + XCTAssertEqual(PolyrhythmTrainerClock.rhythmIndex( + forNormalizedX: 0.5, rhythmCount: 3), 1) + XCTAssertEqual(PolyrhythmTrainerClock.rhythmIndex( + forNormalizedX: 0.9, rhythmCount: 3), 2) + XCTAssertEqual(PolyrhythmTrainerClock.rhythmIndex( + forNormalizedX: 1.0, rhythmCount: 3), 2) + XCTAssertEqual(PolyrhythmTrainerClock.rhythmIndex( + forNormalizedX: -0.5, rhythmCount: 2), 0) + XCTAssertEqual(PolyrhythmTrainerClock.rhythmIndex( + forNormalizedX: 0.49, rhythmCount: 2), 0) + XCTAssertEqual(PolyrhythmTrainerClock.rhythmIndex( + forNormalizedX: 0.51, rhythmCount: 2), 1) + } + + func testPositionlessTapFallsBackToNearestGrid() { + var clock = PolyrhythmTrainerClock() + clock.start(at: 10) + clock.registerTap(at: 10.82) + let feedback = clock.snapshot(at: 10.82)!.tapFeedback + XCTAssertEqual(feedback.count, 1) + XCTAssertEqual(feedback[0].rhythmIndex, 0) + XCTAssertGreaterThan(feedback[0].accuracy, 0.9) + } + + func testPolyrhythmOverlayGrowsAboveTrackDrumWithoutClippingCircles() { + XCTAssertEqual( + PitchBendCursorOverlayWindow.tracktrampSurfaceSize(polyrhythm: nil), + TracktrampMetalView.logicalSize + ) + for circles in 2...3 { + let surface = PitchBendCursorOverlayWindow.tracktrampSurfaceSize( + rhythmCount: circles + ) + let trainer = PolyrhythmTrainerView.logicalSize(rhythmCount: circles) + XCTAssertEqual(surface.width, + max(TracktrampMetalView.logicalSize.width, + trainer.width)) + XCTAssertEqual( + surface.height, + TracktrampMetalView.logicalSize.height + + PitchBendCursorOverlayWindow.polyrhythmGap + + trainer.height + ) + } + // Side-by-side circles: three circles are wider than two. + XCTAssertGreaterThan( + PolyrhythmTrainerView.logicalSize(rhythmCount: 3).width, + PolyrhythmTrainerView.logicalSize(rhythmCount: 2).width + ) + } + + func testTrackDrumSlashAndHelpRemainDistinct() { + XCTAssertEqual( + AppDelegate.trackDrumSlashAction(keyCode: 44, flags: []), + .trainer + ) + XCTAssertEqual( + AppDelegate.trackDrumSlashAction(keyCode: 44, flags: [.shift]), + .help + ) + XCTAssertNil( + AppDelegate.trackDrumSlashAction(keyCode: 44, flags: [.command]) + ) + XCTAssertNil( + AppDelegate.trackDrumSlashAction(keyCode: 12, flags: []) + ) + XCTAssertEqual(AppDelegate.trackDrumRateDelta(keyCode: 27, flags: []), -5) + XCTAssertEqual(AppDelegate.trackDrumRateDelta(keyCode: 24, flags: []), 5) + XCTAssertEqual(AppDelegate.trackDrumRateDelta(keyCode: 24, flags: [.shift]), 5) + XCTAssertNil(AppDelegate.trackDrumRateDelta(keyCode: 24, flags: [.command])) + } + func testTrackDrumClearsVisibleAndRevealedDock() { let screen = NSRect(x: 0, y: 0, width: 1200, height: 800) let size = NSSize(width: 240, height: 160)