diff --git a/marketing/klokkentales/bin/reel-chrome.mjs b/marketing/klokkentales/bin/reel-chrome.mjs
new file mode 100644
index 0000000000..9e1b7309b2
--- /dev/null
+++ b/marketing/klokkentales/bin/reel-chrome.mjs
@@ -0,0 +1,466 @@
+#!/usr/bin/env node
+// reel-chrome.mjs — dress a dubbed Klokkentales reel in AC's pop chrome:
+// two pals watermarks hugging the edges (VHS chroma ghosts, pulsing with the
+// speech envelope), a drippy melt-feedback layer + chroma fringe + scanlines
+// over the footage, and karaoke subtitles in YWFT where the word being spoken
+// pops lime-green. Ported from marketing/kidlisp-reels/bin/render-reel.mjs /
+// pop/menuband/bin/chrome-reel.mjs.
+//
+// Inputs (in --dir): reel-en-dub.mp4, dub-audio.mp3, words-en.json
+// Output:
/-chrome.mp4 (voice + --music bed mixed under it)
+//
+// Usage:
+// node marketing/klokkentales/bin/reel-chrome.mjs \
+// --dir marketing/klokkentales/out/reels/1000-lyttere \
+// --music pop/marimba/out/marimbaba.mp3 --name 1000-lyttere
+
+import { spawn, spawnSync } from "node:child_process";
+import { readFileSync, existsSync, mkdirSync } from "node:fs";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { once } from "node:events";
+import { createCanvas, loadImage } from "canvas";
+import {
+ decodeAudioMono, computeRmsEnvelope,
+ magickMeasureWidth, magickRenderText,
+ setPreviewFont, prerenderTitleChars,
+} from "../../../pop/lib/preview-shared.mjs";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO = resolve(HERE, "..", "..", "..");
+
+const argv = process.argv.slice(2);
+const flags = {};
+for (let i = 0; i < argv.length; i++) {
+ if (argv[i].startsWith("--")) { flags[argv[i].slice(2)] = argv[i + 1]; i++; }
+}
+const DIR = resolve(flags.dir || ".");
+const NAME = flags.name || "reel";
+const MUSIC = flags.music ? resolve(flags.music) : null;
+const MUSIC_VOL = parseFloat(flags["music-vol"] || "0.15");
+
+const VIDEO = `${DIR}/reel-en-dub.mp4`;
+const VOICE = `${DIR}/dub-audio.mp3`;
+const WORDS_JSON = `${DIR}/words-en.json`;
+const OUTFILE = `${DIR}/${NAME}-chrome.mp4`;
+const assetsDir = `${DIR}/chrome-assets`;
+mkdirSync(assetsDir, { recursive: true });
+
+// ── source probe ─────────────────────────────────────────────────────────────
+function probe(args) {
+ const r = spawnSync("ffprobe", ["-v", "error", ...args], { encoding: "utf8" });
+ return r.stdout.trim();
+}
+const [SRC_W, SRC_H] = probe(["-select_streams", "v", "-show_entries",
+ "stream=width,height", "-of", "csv=p=0", VIDEO]).split(",").map(Number);
+const DUR = parseFloat(probe(["-show_entries", "format=duration", "-of",
+ "default=noprint_wrappers=1:nokey=1", VIDEO]));
+const FPS = 30;
+const FRAMES = Math.round(DUR * FPS);
+const W = 1080, H = 1920;
+
+// ── karaoke lines from word timestamps ───────────────────────────────────────
+// Greedy-wrap words into short lines, pair lines into 2-line blocks. The block
+// on screen follows the voice; the word being spoken tints lime + pops.
+// MacPal's playful register: Comic Sans MS Bold for subs + the laklok columns
+// (same face MacPal titles and the macneopolitan visuals use).
+setPreviewFont("/System/Library/Fonts/Supplemental/Comic Sans MS Bold.ttf");
+
+const PT = 58;
+const MAX_LINE_W = 680;
+const SPACE_W = Math.round(PT * 0.34);
+const rawWords = JSON.parse(readFileSync(WORDS_JSON, "utf8")).words
+ .filter((w) => w.type === "word");
+
+const widthCache = new Map();
+function wordWidth(text) {
+ if (!widthCache.has(text)) widthCache.set(text, magickMeasureWidth(text, PT));
+ return widthCache.get(text);
+}
+
+const lines = [];
+{
+ let line = null;
+ for (const w of rawWords) {
+ const ww = wordWidth(w.text);
+ const gap = line ? w.start - line.words[line.words.length - 1].end : 0;
+ if (!line || line.w + SPACE_W + ww > MAX_LINE_W || gap > 0.7 || line.words.length >= 5) {
+ line = { words: [], w: -SPACE_W };
+ lines.push(line);
+ }
+ line.words.push(w);
+ line.w += SPACE_W + ww;
+ }
+}
+const blocks = [];
+for (let i = 0; i < lines.length; i += 2) {
+ const pair = lines.slice(i, i + 2);
+ blocks.push({
+ lines: pair,
+ t0: pair[0].words[0].start,
+ t1: pair[pair.length - 1].words[pair[pair.length - 1].words.length - 1].end,
+ });
+}
+for (let i = 0; i < blocks.length; i++) {
+ blocks[i].show = Math.max(0, blocks[i].t0 - 0.15);
+ blocks[i].hide = i + 1 < blocks.length
+ ? Math.min(blocks[i].t1 + 0.6, blocks[i + 1].t0 - 0.04)
+ : Math.min(DUR, blocks[i].t1 + 0.8);
+}
+console.log(`▸ ${rawWords.length} words → ${lines.length} lines → ${blocks.length} blocks`);
+
+// ── prerender word glyphs (YWFT, white; tinted per frame) ────────────────────
+// three variants per word — MacPal comic treatment: white/ink-stroked for
+// inactive, lime/ink-stroked for the word being spoken, plain for ghost splits.
+const LIME = "#B4F046", INK = "rgb(18,18,18)";
+const wordImgs = new Map();
+for (const w of rawWords) {
+ if (wordImgs.has(w.text)) continue;
+ const safe = w.text.replace(/[^a-z0-9]/gi, "_") + "_" + wordImgs.size;
+ wordImgs.set(w.text, {
+ plain: await magickRenderText(w.text, {
+ ptSize: PT, fill: "#FFFFFF", outPath: `${assetsDir}/w_${safe}.png`,
+ }),
+ stroked: await magickRenderText(w.text, {
+ ptSize: PT, fill: "#FFFFFF", stroke: INK, strokeWidth: 3,
+ outPath: `${assetsDir}/ws_${safe}.png`,
+ }),
+ lime: await magickRenderText(w.text, {
+ ptSize: PT, fill: LIME, stroke: INK, strokeWidth: 3,
+ outPath: `${assetsDir}/wl_${safe}.png`,
+ }),
+ });
+}
+rawWords.forEach((w, i) => { w.idx = i; });
+
+// MacPal's per-glyph jitter (PalCore.swift): deterministic tiny dy + rotation,
+// scaled up per word for reel distance.
+function jitter(i) {
+ let h = 2166136261 >>> 0;
+ for (const b of Buffer.from("word" + i)) { h = (h ^ b) >>> 0; h = Math.imul(h, 16777619) >>> 0; }
+ const dy = ((h % 7) / 2 - 1.5) * 2;
+ const rot = ((((h >>> 8) % 9)) - 4) * 0.6 * Math.PI / 180;
+ return { dy, rot };
+}
+console.log(`▸ ${wordImgs.size} distinct word glyphs prerendered`);
+
+// ── pals watermark raster ────────────────────────────────────────────────────
+const PALS_S = 150, PALS_EDGE_X = 105;
+const LEFT_PALS_CY = H * 0.14, RIGHT_PALS_CY = H * 0.86;
+const PALS_SPEED = 0.18;
+let palsImg = null, palsBlur = null;
+{
+ const svg = `${REPO}/system/public/purple-pals.svg`;
+ const png = `${assetsDir}/pals-watermark.png`;
+ const blurPng = `${assetsDir}/pals-watermark-blur.png`;
+ const r = spawnSync("rsvg-convert", ["-w", "424", "-h", "424", "-o", png, svg]);
+ if (r.status === 0 && existsSync(png)) {
+ palsImg = await loadImage(png);
+ const rb = spawnSync("magick", [png, "-channel", "A", "-blur", "0x1.5", "+channel", blurPng]);
+ palsBlur = (rb.status === 0 && existsSync(blurPng)) ? await loadImage(blurPng) : palsImg;
+ }
+}
+if (!palsImg) throw new Error("pals watermark failed to rasterize (need rsvg-convert)");
+
+// 'laklok' climbing char columns next to each stamp, like the kidlisp reels'
+// $code columns — set in YWFT (the brand face), NOT the comic subtitle face.
+const TITLE = "laklok";
+setPreviewFont("ywft");
+const { chars: titleChars, totalWidth: titleTotalW } = await prerenderTitleChars({
+ text: TITLE, ptSize: 72, palette: ["#FFFFFF"], shadowColor: null, assetsDir,
+});
+setPreviewFont("/System/Library/Fonts/Supplemental/Comic Sans MS Bold.ttf");
+const CHAR_SCALE = 0.8, KERN = 2, CHAR_BUF = 34;
+const CHAR_SPAN = (titleTotalW + (titleChars.length - 1) * KERN) * CHAR_SCALE;
+const CHARS_EDGE_X = PALS_EDGE_X + 6;
+
+
+// ── speech envelope drives the pals pulse ────────────────────────────────────
+const { audio, sr } = decodeAudioMono(VOICE);
+const env = computeRmsEnvelope(audio, sr, FPS, DUR);
+
+// ── canvases ─────────────────────────────────────────────────────────────────
+const canvas = createCanvas(W, H);
+const ctx = canvas.getContext("2d");
+const stage = createCanvas(W, H); // current video frame at reel size
+const stageCtx = stage.getContext("2d");
+const tintCanvas = createCanvas(W, H); // full-frame chroma-fringe tints
+const tintCtx = tintCanvas.getContext("2d");
+const dripA = createCanvas(W, H), dripB = createCanvas(W, H);
+let dripFront = dripA, dripBack = dripB;
+const wmCanvas = createCanvas(8, 8);
+const wmCtx = wmCanvas.getContext("2d");
+const glyphTint = createCanvas(2, 2);
+const glyphTintCtx = glyphTint.getContext("2d");
+
+// scanlines pattern (1px dark line every 4px)
+const scan = createCanvas(W, H + 8);
+{
+ const sctx = scan.getContext("2d");
+ sctx.fillStyle = "rgba(0,0,0,1)";
+ for (let y = 0; y < H + 8; y += 4) sctx.fillRect(0, y, W, 1);
+}
+// vignette so the subs + stamps pop
+const vig = createCanvas(W, H);
+{
+ const vctx = vig.getContext("2d");
+ const g = vctx.createRadialGradient(W / 2, H / 2, H * 0.32, W / 2, H / 2, H * 0.72);
+ g.addColorStop(0, "rgba(0,0,0,0)");
+ g.addColorStop(1, "rgba(0,0,0,0.42)");
+ vctx.fillStyle = g;
+ vctx.fillRect(0, 0, W, H);
+}
+
+// KidLisp-reel VHS palette: lime core, magenta/cyan ghosts.
+const CORE = [180, 240, 70], CORE_HI = [225, 255, 150];
+const CHROMA_R = [255, 60, 120], CHROMA_B = [70, 170, 255], CHROMA_PX = 3;
+
+function tinted(src, c, cnv, cctx) {
+ cnv.width = src.width; cnv.height = src.height;
+ cctx.globalCompositeOperation = "source-over";
+ cctx.clearRect(0, 0, src.width, src.height);
+ cctx.drawImage(src, 0, 0);
+ cctx.globalCompositeOperation = "source-in";
+ cctx.fillStyle = `rgb(${c[0]},${c[1]},${c[2]})`;
+ cctx.fillRect(0, 0, src.width, src.height);
+ cctx.globalCompositeOperation = "source-over";
+ return cnv;
+}
+
+// one glyph/stamp as a VHS sandwich: shadow + split ghosts + core + env glow
+function drawChroma(sharp, soft, dx, dy, dw, dh, e, core = CORE, coreAlpha = 0.62, tintCnv = wmCanvas, tintCtx2 = wmCtx) {
+ ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 0.22;
+ ctx.drawImage(tinted(sharp, [8, 10, 6], tintCnv, tintCtx2), dx + 2.5, dy + 3, dw, dh);
+ ctx.globalCompositeOperation = "screen";
+ ctx.globalAlpha = 0.30; ctx.drawImage(tinted(soft, CHROMA_R, tintCnv, tintCtx2), dx - CHROMA_PX, dy, dw, dh);
+ ctx.globalAlpha = 0.30; ctx.drawImage(tinted(soft, CHROMA_B, tintCnv, tintCtx2), dx + CHROMA_PX, dy, dw, dh);
+ ctx.globalAlpha = coreAlpha; ctx.drawImage(tinted(sharp, core, tintCnv, tintCtx2), dx, dy, dw, dh);
+ if (e > 0.02) { ctx.globalAlpha = 0.08 + 0.20 * e; ctx.drawImage(tinted(soft, CORE_HI, tintCnv, tintCtx2), dx, dy, dw, dh); }
+ ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1;
+}
+
+function drawPals(t, e) {
+ const TAU = Math.PI * 2, u = t / DUR, sp = PALS_SPEED, s = PALS_S;
+ const wig = 13 * Math.sin(TAU * 24 * sp * u + 0.7) + 4 * Math.sin(TAU * 9 * sp * u);
+ const bob = 3 * Math.sin(TAU * 17 * sp * u + 2.1) + 1.5 * Math.sin(TAU * 31 * sp * u);
+ const swiv = 0.05 * Math.sin(TAU * 19 * sp * u) + 0.025 * Math.sin(TAU * 38 * sp * u + 1.4);
+ const spots = [
+ { cx: PALS_EDGE_X - wig, cy: LEFT_PALS_CY + bob, rot: Math.PI / 2 + swiv },
+ { cx: W - PALS_EDGE_X + wig, cy: RIGHT_PALS_CY - bob, rot: -Math.PI / 2 - swiv },
+ ];
+ for (const spot of spots) {
+ ctx.save();
+ ctx.translate(spot.cx, spot.cy);
+ ctx.rotate(spot.rot);
+ drawChroma(palsImg, palsBlur, -s / 2, -s / 2, s, s, e, CORE, 0.62);
+ ctx.restore();
+ }
+ drawTitleChars(t, e);
+}
+
+// laklok columns: below the left stamp, above the right stamp, climbing the
+// same edges, each char bouncing gently with the speech envelope.
+function drawTitleChars(t, e) {
+ const TAU = Math.PI * 2, u = t / DUR;
+ const wig = 11 * Math.sin(TAU * 30 * PALS_SPEED * u + 3.6) + 4 * Math.sin(TAU * 12 * PALS_SPEED * u + 1.1);
+ const leftCy = LEFT_PALS_CY + PALS_S / 2 + CHAR_BUF + CHAR_SPAN / 2;
+ const rightCy = RIGHT_PALS_CY - PALS_S / 2 - CHAR_BUF - CHAR_SPAN / 2;
+ const spots = [
+ { cx: CHARS_EDGE_X - wig, cy: leftCy, rot: Math.PI / 2 },
+ { cx: W - CHARS_EDGE_X + wig, cy: rightCy, rot: -Math.PI / 2 },
+ ];
+ const startX = -CHAR_SPAN / 2;
+ for (const sp of spots) {
+ ctx.save();
+ ctx.translate(sp.cx, sp.cy);
+ ctx.rotate(sp.rot);
+ for (let i = 0; i < titleChars.length; i++) {
+ const ch = titleChars[i];
+ if (!ch.img) continue;
+ const x = startX + (ch.prefixWidth + i * KERN) * CHAR_SCALE;
+ const dw = ch.img.width * CHAR_SCALE, dh = ch.img.height * CHAR_SCALE;
+ const lift = 3.5 * Math.sin(t * 1.5 + i * 0.8) * (0.6 + 0.25 * e);
+ const yy = -dh / 2 + lift;
+ const rot2 = 0.03 * Math.sin(t * 1.2 + i * 1.15);
+ ctx.save();
+ ctx.translate(x + dw / 2, yy + dh / 2);
+ ctx.rotate(rot2);
+ ctx.translate(-(x + dw / 2), -(yy + dh / 2));
+ drawChroma(ch.img, ch.img, x, yy, dw, dh, e, CORE, 0.78, glyphTint, glyphTintCtx);
+ ctx.restore();
+ }
+ ctx.restore();
+ }
+}
+
+// karaoke block: lines centered in the frame; the active word tints lime,
+// pops in scale, and gets the full ghost sandwich. Passed words solid white,
+// upcoming words dimmed.
+const LINE_H = Math.round(PT * 1.42);
+function drawSubs(t, e) {
+ const block = blocks.find((b) => t >= b.show && t < b.hide);
+ if (!block) return;
+ const blockH = block.lines.length * LINE_H;
+ let y = (H - blockH) / 2;
+ for (const line of block.lines) {
+ let x = (W - line.w) / 2;
+ for (const w of line.words) {
+ const pair = wordImgs.get(w.text);
+ if (!pair) { x += wordWidth(w.text) + SPACE_W; continue; }
+ const active = t >= w.start && t < w.end;
+ const passed = t >= w.end;
+ const img = pair.plain;
+ const dw = img.width, dh = img.height;
+ const dy = y + (LINE_H - dh) / 2;
+ // MacPal comic treatment: per-word jitter, ink stroke, hard accent
+ // shadow. Active word flips to lime with a magenta shadow + scale pop.
+ const j = jitter(w.idx);
+ const cx = x + dw / 2, cy = dy + dh / 2;
+ ctx.save();
+ ctx.translate(cx, cy + j.dy);
+ ctx.rotate(j.rot);
+ if (active) {
+ const pop = Math.exp(-(t - w.start) * 5);
+ const sc = 1 + 0.14 * pop;
+ ctx.scale(sc, sc);
+ }
+ ctx.translate(-cx, -cy);
+ const s = active ? pair.lime : pair.stroked;
+ const sx = x - (s.width - dw) / 2, sy = dy - (s.height - dh) / 2;
+ const shadowColor = active ? CHROMA_R : CORE;
+ ctx.globalAlpha = active ? 0.95 : 0.85;
+ ctx.drawImage(tinted(s, shadowColor, glyphTint, glyphTintCtx), sx + 4, sy + 4.5, s.width, s.height);
+ if (active) {
+ ctx.globalCompositeOperation = "screen";
+ ctx.globalAlpha = 0.35;
+ ctx.drawImage(tinted(img, CHROMA_R, glyphTint, glyphTintCtx), x - 3, dy, dw, dh);
+ ctx.drawImage(tinted(img, CHROMA_B, glyphTint, glyphTintCtx), x + 3, dy, dw, dh);
+ ctx.globalCompositeOperation = "source-over";
+ }
+ ctx.globalAlpha = active || passed ? 1 : 0.8;
+ ctx.drawImage(s, sx, sy, s.width, s.height);
+ ctx.globalAlpha = 1;
+ ctx.restore();
+ x += dw + SPACE_W;
+ }
+ y += LINE_H;
+ }
+}
+
+// ── decoder (rawvideo in) + encoder (rawvideo out) ───────────────────────────
+const dec = spawn("ffmpeg", [
+ "-hide_banner", "-loglevel", "error",
+ "-i", VIDEO, "-f", "rawvideo", "-pix_fmt", "rgba",
+ "-s", `${SRC_W}x${SRC_H}`, "-r", String(FPS), "-",
+], { stdio: ["ignore", "pipe", "inherit"] });
+const SILENT = `${DIR}/${NAME}-chrome-silent.mp4`;
+const enc = spawn("ffmpeg", [
+ "-hide_banner", "-loglevel", "error", "-y",
+ "-f", "rawvideo", "-pix_fmt", "bgra", "-s", `${W}x${H}`, "-r", String(FPS), "-i", "-",
+ // punch pass: crush blacks + saturate + sharpen the composited frame
+ "-vf", "eq=contrast=1.15:brightness=-0.02:saturation=1.12,unsharp=5:5:0.6",
+ "-c:v", "libx264", "-preset", "faster", "-crf", "18", "-threads", "0",
+ "-pix_fmt", "yuv420p", "-movflags", "+faststart", SILENT,
+], { stdio: ["pipe", "inherit", "inherit"] });
+
+const FRAME_BYTES = SRC_W * SRC_H * 4;
+const srcCanvas = createCanvas(SRC_W, SRC_H);
+const srcCtx = srcCanvas.getContext("2d");
+const srcImageData = srcCtx.createImageData(SRC_W, SRC_H);
+
+let leftover = Buffer.alloc(0);
+async function* frames() {
+ for await (const chunk of dec.stdout) {
+ leftover = leftover.length ? Buffer.concat([leftover, chunk]) : chunk;
+ while (leftover.length >= FRAME_BYTES) {
+ yield leftover.subarray(0, FRAME_BYTES);
+ leftover = leftover.subarray(FRAME_BYTES);
+ }
+ }
+}
+
+console.log(`▸ klokkentales chrome · ${FRAMES} frames @ ${FPS}fps · drippy VHS + pals + karaoke`);
+const t0ms = Date.now();
+let i = 0;
+for await (const raw of frames()) {
+ const t = i / FPS;
+ const e = env[Math.min(env.length - 1, i)];
+
+ srcImageData.data.set(raw);
+ srcCtx.putImageData(srcImageData, 0, 0);
+
+ // stage = full-bleed video frame at reel size
+ stageCtx.drawImage(srcCanvas, 0, 0, W, H);
+
+ ctx.globalCompositeOperation = "source-over";
+ ctx.globalAlpha = 1;
+ ctx.drawImage(stage, 0, 0);
+
+ // chroma fringe: magenta/cyan multiply-tinted copies split ±4px (screen)
+ tintCtx.globalCompositeOperation = "source-over";
+ tintCtx.drawImage(stage, 0, 0);
+ tintCtx.globalCompositeOperation = "multiply";
+ tintCtx.fillStyle = "rgb(255,60,120)"; tintCtx.fillRect(0, 0, W, H);
+ ctx.globalCompositeOperation = "screen"; ctx.globalAlpha = 0.09;
+ ctx.drawImage(tintCanvas, -4, 0);
+ tintCtx.globalCompositeOperation = "source-over";
+ tintCtx.drawImage(stage, 0, 0);
+ tintCtx.globalCompositeOperation = "multiply";
+ tintCtx.fillStyle = "rgb(70,170,255)"; tintCtx.fillRect(0, 0, W, H);
+ ctx.drawImage(tintCanvas, 4, 0);
+ ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1;
+
+ // drippy melt: feedback buffer falls + fades each frame, reseeded by the
+ // current frame; screen-composited so highlights leave falling trails.
+ const bc = dripBack.getContext("2d");
+ bc.globalCompositeOperation = "copy";
+ bc.globalAlpha = 0.90;
+ bc.drawImage(dripFront, 0, 6);
+ bc.globalCompositeOperation = "lighten";
+ bc.globalAlpha = 0.45;
+ bc.drawImage(stage, 0, 0);
+ bc.globalCompositeOperation = "source-over";
+ bc.globalAlpha = 1;
+ ctx.globalCompositeOperation = "screen";
+ ctx.globalAlpha = 0.17;
+ ctx.drawImage(dripBack, 0, 0);
+ ctx.globalCompositeOperation = "source-over";
+ ctx.globalAlpha = 1;
+ [dripFront, dripBack] = [dripBack, dripFront];
+
+ // scanlines (slow roll) + vignette
+ ctx.globalAlpha = 0.055;
+ ctx.drawImage(scan, 0, -(Math.floor(t * 18) % 4));
+ ctx.globalAlpha = 1;
+ ctx.drawImage(vig, 0, 0);
+
+ drawPals(t, e);
+ drawSubs(t, e);
+
+ if (!enc.stdin.write(canvas.toBuffer("raw"))) await once(enc.stdin, "drain");
+ i++;
+ if (i % 90 === 0) console.log(` ${i}/${FRAMES} · ${((Date.now() - t0ms) / 1000).toFixed(0)}s`);
+}
+enc.stdin.end();
+await new Promise((res, rej) => { enc.on("close", (c) => (c === 0 ? res() : rej(new Error(`encode exit ${c}`)))); });
+console.log(`✓ silent chrome: ${SILENT} (${i} frames)`);
+
+// ── mux: voice + music bed (music low, faded out at the end) ─────────────────
+const mixArgs = [
+ "-hide_banner", "-loglevel", "error", "-y",
+ "-i", SILENT, "-i", VOICE,
+];
+if (MUSIC) {
+ // atrim clamps the mix to the video's exact length — otherwise the audio
+ // track outlives the last frame by ~50ms and players flash black at the end.
+ mixArgs.push("-i", MUSIC, "-filter_complex",
+ `[2:a]volume=${MUSIC_VOL},afade=t=in:st=0:d=0.8,afade=t=out:st=${(DUR - 1.4).toFixed(2)}:d=1.4[m];` +
+ `[1:a][m]amix=inputs=2:duration=first:normalize=0,atrim=end=${DUR.toFixed(3)}[a]`,
+ "-map", "0:v", "-map", "[a]");
+} else {
+ mixArgs.push("-map", "0:v", "-map", "1:a", "-af", `atrim=end=${DUR.toFixed(3)}`);
+}
+mixArgs.push("-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", OUTFILE);
+const mix = spawnSync("ffmpeg", mixArgs, { stdio: "inherit" });
+if (mix.status !== 0) throw new Error("audio mux failed");
+console.log(`✓ ${OUTFILE}`);
diff --git a/toolchain/instagram/ig.mjs b/toolchain/instagram/ig.mjs
index 1549207fcd..14cc5e2337 100644
--- a/toolchain/instagram/ig.mjs
+++ b/toolchain/instagram/ig.mjs
@@ -22,7 +22,7 @@
// node toolchain/instagram/ig.mjs --as oskiewar refresh
// node toolchain/instagram/ig.mjs refresh --all # monthly cron
// node toolchain/instagram/ig.mjs --as oskiewar post reel.mp4 \
-// --caption "..." [--cover cover.jpg]
+// --caption "..." [--cover cover.jpg] [--audio-name "..."]
// node toolchain/instagram/ig.mjs --as oskiewar insights
// node toolchain/instagram/ig.mjs --as oskiewar snapshot
//
@@ -263,7 +263,7 @@ async function uploadPublic(creds, files) {
async function doPost(creds) {
const file = positional[0];
- if (!file) die(`usage: ig.mjs --as post --caption "..." [--cover img.jpg]`);
+ if (!file) die(`usage: ig.mjs --as post --caption "..." [--cover img.jpg] [--audio-name "..."] [--collaborators user1,user2]`);
const videoPath = resolve(process.cwd(), file);
if (!existsSync(videoPath)) die(`video not found: ${videoPath}`);
if (!flags.caption || flags.caption === true) die(`--caption is required`);
@@ -274,6 +274,19 @@ async function doPost(creds) {
coverPath = resolve(process.cwd(), String(flags.cover));
if (!existsSync(coverPath)) die(`cover not found: ${coverPath}`);
}
+ // Renames the reel's original audio track — the name other users see when
+ // they tap the audio. Meta only honors it on the first reel that mints the
+ // audio; it cannot be edited after publish.
+ const audioName = typeof flags["audio-name"] === "string" ? flags["audio-name"] : null;
+ if (flags["audio-name"] === true) die(`--audio-name needs a value`);
+
+ // Invite accounts as collaborators (comma-separated usernames, max 3).
+ // Each invitee must accept before the reel appears on their profile.
+ if (flags.collaborators === true) die(`--collaborators needs a value`);
+ const collaborators = typeof flags.collaborators === "string"
+ ? flags.collaborators.split(",").map((s) => s.trim().replace(/^@/, "")).filter(Boolean)
+ : null;
+ if (collaborators && collaborators.length > 3) die(`Instagram allows at most 3 collaborators`);
// Quota first — a spent budget should stop us before any bytes move.
const quota = await fetchQuota(creds);
@@ -293,6 +306,8 @@ async function doPost(creds) {
share_to_feed: true, // a reel that only lives in the Reels tab is invisible to followers
};
if (urls.cover) body.cover_url = urls.cover;
+ if (audioName) body.audio_name = audioName;
+ if (collaborators?.length) body.collaborators = collaborators;
const created = await call(api(`${creds.igUserId}/media?access_token=${creds.token}`), {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify(body),
@@ -323,6 +338,8 @@ async function doPost(creds) {
videoUrl: urls.reel,
coverUrl: urls.cover || null,
caption,
+ audioName,
+ collaborators,
file: videoPath,
};
const sidecar = videoPath.replace(/\.[^.]+$/, "") + ".instagram.json";
@@ -396,7 +413,7 @@ commands:
quota content_publishing_limit — posts used/allowed per 24h
refresh refresh the 60-day token, rewrite the vault env file in place
refresh --all refresh every provisioned account (monthly cron; no --as needed)
- post --caption "..." [--cover img.jpg]
+ post --caption "..." [--cover img.jpg] [--audio-name "..."]
publish a reel: Spaces upload → container → poll → publish
insights per-reel metrics (views, reach, skip rate, …)
snapshot print a social/accounts.json snapshots entry (does not write it)
--
2.51.2
From a061ceaff1a8ad40deb539b33ffd5442bcf44d49 Mon Sep 17 00:00:00 2001
From: "prompt.ac/@jeffrey"
Date: Thu, 13 Aug 2026 18:07:04 -0400
Subject: [PATCH 2/4] menuband: polyrhythm trainer as separate side-by-side
circles
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Each rhythm gets its own circle instead of stacked concentric rings, so a
finger can own a rhythm. Taps route by the contact's normalized x — equal
vertical bands mirroring the left-to-right layout — and score against that
circle's grid only, replacing the nearest-grid guess that let a sloppy
3-circle tap be excused by a nearby 2-circle beat.
The clock now holds N counts (3:2, 4:3, 5:4, 3:4:5) with per-circle click
voices. --render-polyrhythm gains --pattern/--phase/--bpm/--light/--scale so
the reel lane can sweep phase deterministically; pop/menuband gains the
polyrhythm jingle + sim that light each circle from its own audio lane.
---
pop/menuband/bin/render-polyrhythm-jingle.mjs | 129 +++++
pop/menuband/bin/sim-polyrhythm.mjs | 283 +++++++++++
.../Sources/MenuBand/AppDelegate.swift | 183 ++++++-
.../Sources/MenuBand/MenuBandController.swift | 32 +-
.../MenuBand/MenuBandRewindVoice.swift | 139 +++++-
.../MenuBand/MenuBandSampleVoice.swift | 66 ++-
.../Sources/MenuBand/MenuBandSynth.swift | 44 +-
.../Sources/MenuBand/PitchBendCursor.swift | 61 ++-
.../Sources/MenuBand/PolyrhythmTrainer.swift | 458 ++++++++++++++++++
.../Sources/MenuBand/ReverseWaveformHUD.swift | 110 +++++
.../Sources/MenuBand/WaveformStripView.swift | 187 +++++--
slab/menuband/Sources/MenuBand/main.swift | 5 +
.../Tests/MenuBandTests/ShapedownTests.swift | 140 ++++++
13 files changed, 1733 insertions(+), 104 deletions(-)
create mode 100644 pop/menuband/bin/render-polyrhythm-jingle.mjs
create mode 100644 pop/menuband/bin/sim-polyrhythm.mjs
create mode 100644 slab/menuband/Sources/MenuBand/PolyrhythmTrainer.swift
create mode 100644 slab/menuband/Sources/MenuBand/ReverseWaveformHUD.swift
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)
--
2.51.2
From da086f80a98862bd1c1b708e8dc9e1038ffebb1a Mon Sep 17 00:00:00 2001
From: "prompt.ac/@jeffrey"
Date: Thu, 13 Aug 2026 18:10:51 -0400
Subject: [PATCH 3/4] menuband: add the polyrhythm App Store shot, bump to
1.6.11 (171)
The trainer's three-circle pattern is the new store portrait: 3:4:5 frozen
off-beat so each hand sits at its own angle. The capture is transparent
around the circles, so it lands on the desktop as the overlay panel it is
rather than in a fake window.
---
slab/menuband/bin/app-store-real.mjs | 27 ++++++++++++++++++
.../en-US/00-keyboard-menu-1440x900.png | Bin 173230 -> 172490 bytes
.../screenshots/en-US/01-reel-1440x900.png | Bin 834429 -> 824858 bytes
.../screenshots/en-US/02-midi-1440x900.png | Bin 632900 -> 633752 bytes
.../en-US/03-polyrhythm-1440x900.png | Bin 0 -> 1100844 bytes
slab/menuband/project.yml | 4 +--
6 files changed, 29 insertions(+), 2 deletions(-)
create mode 100644 slab/menuband/fastlane/screenshots/en-US/03-polyrhythm-1440x900.png
diff --git a/slab/menuband/bin/app-store-real.mjs b/slab/menuband/bin/app-store-real.mjs
index df8a5b2549..b51350d5d9 100644
--- a/slab/menuband/bin/app-store-real.mjs
+++ b/slab/menuband/bin/app-store-real.mjs
@@ -58,6 +58,11 @@ console.log("rendering real UI surfaces…");
render(["--render-menubar", "--light"], "menubar.png"); // keys at rest, light theme
render(["--render-popover", "--lang", "en", "--program", "0"], "popover.png");
render(["--render-keymap", "--lang", "en", "--program", "0", "--keymap", "milkyTracker"], "keymap.png"); // fullscreen expanded view
+// The polyrhythm trainer at its widest pattern: three separate circles, one
+// per rhythm. Phase is chosen off-beat so every hand sits at a distinct angle
+// — a frozen unison downbeat reads as three identical clocks.
+render(["--render-polyrhythm", "--pattern", "3:4:5", "--phase", "0.41",
+ "--bpm", "75", "--light"], "polyrhythm.png");
// The ♪ status glyph at the strip's right end renders in the system accent
// (green). For a clean light-mode bar, recolor it to INK. That right slice
@@ -95,6 +100,7 @@ const SHOTS = [
{ file: "00-keyboard-menu", kind: "literal" },
{ file: "01-reel", kind: "reel" },
{ file: "02-midi", kind: "midi" },
+ { file: "03-polyrhythm", kind: "poly" },
];
const FILL = "#ffffff"; // solid light-theme backing behind the panels
@@ -156,6 +162,18 @@ const screenCSS = (s) => {
return windowEl({ screen: "keymap", scale,
left: Math.round((W - w) / 2), top: 104, z: 3 });
}
+ // The trainer capture is transparent around the circles, so it gets no
+ // window chrome or white backing — it sits on the desktop as the overlay
+ // panel it actually is, sized to dominate the frame.
+ if (s.kind === "poly") {
+ const d = pngDims("polyrhythm");
+ const h = Math.round((H - BAR_H) * 0.78);
+ const w = Math.round(h * d.w / d.h);
+ const img = uri("polyrhythm.png");
+ return `
`;
+ }
if (s.kind === "midi") {
const d = pngDims("popover");
const scale = 1.62;
@@ -248,6 +266,13 @@ const literalStrip = () => {
const scene = (s) => {
if (s.kind === "literal") return `${literalStrip()}${screenCSS(s)}`;
+ if (s.kind === "poly") return `
+ POLYRHYTHM
+ One circle per rhythm.
+ Three against four against five — each finger keeps its own grid.
+
+
+ ${screenCSS(s)}`;
if (s.kind === "midi") return `