From 1ff7c14197eef6c08c88b2c7214e2d6bbe17b11c Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Wed, 12 Aug 2026 04:57:39 +0000 Subject: [PATCH] menuband: perform scores as skew-locked desktop shows — the real strip flies out of the menu bar conduct.mjs learns per-host clock skew (host=seconds after the roster) and prints its say schedule; perform.mjs measures the skew over a persistent ssh pipe (min-RTT), conducts, and raises shapedown visuals on every machine at the same corrected downbeat. shapedown.mjs renders the captured menu-bar strip (pop/menuband menubar-frames — real pixels, never redrawn) on a transparent click-through overlay (ShapedownOverlay.swift, floating, no backdrop): the strip flies out of the menu bar, note bars float down past the real keys, pressed frames swap in as notes sound, and it flies home after the last note. Subtitles are only what the machines actually say, set in prox bubble letters (PromptSigilOverlay's jitter + wiggle, ported). ambient-drift joins the score library (~105s two-pad set for two machines). Instagram registries gain the menuband alias (@menuband.app) for the reels campaign these visuals feed. Co-Authored-By: Claude Fable 5 --- slab/bin/instagram-mcp.mjs | 2 +- slab/menuband/bin/conduct.mjs | 28 ++++++++++++++++++++++++---- slab/menuband/bin/perform.mjs | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/menuband/bin/shapedown.mjs | 314 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/menuband/scores/ambient-drift.mbscore | 22 ++++++++++++++++++++++ slab/menuband/shapedown/.gitignore | 4 ++++ slab/menuband/shapedown/ShapedownOverlay.swift | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ toolchain/instagram/ig.mjs | 1 + 8 file(s) changed, 544 insertion(s)(+), 5 deletion(s)(-) diff --git a/slab/bin/instagram-mcp.mjs b/slab/bin/instagram-mcp.mjs --- a/slab/bin/instagram-mcp.mjs +++ b/slab/bin/instagram-mcp.mjs @@ -16,7 +16,7 @@ whistlegraph: resolve(root, "toolchain/instagram/whistlegraph-ig.mjs"), aesthetic: resolve(root, "toolchain/instagram/aesthetic-ig.mjs"), }; const accounts = { oskiewar: "OSKIEWAR", whistlegraph: "WHISTLEGRAPH", - aesthetic: "AESTHETIC" }; + aesthetic: "AESTHETIC", menuband: "MENUBAND" }; // menuband = @menuband.app const text = (value) => [{ type: "text", text: String(value) }]; function vaultPath(account) { diff --git a/slab/menuband/bin/conduct.mjs b/slab/menuband/bin/conduct.mjs --- a/slab/menuband/bin/conduct.mjs +++ b/slab/menuband/bin/conduct.mjs @@ -167,7 +167,19 @@ } const { score } = loadScore(argv[0]); const flags = argv.slice(1).filter((a) => a.startsWith("--")); -const hosts = argv.slice(1).filter((a) => !a.startsWith("--")); +const hosts = argv.slice(1).filter((a) => !a.startsWith("--") && !a.includes("=")); + +// Skew args: `neo=-0.0745` after the host list — measured clock offset per +// host in seconds (host clock minus conductor clock). A host whose clock runs +// behind gets an earlier epoch so all machines SOUND at the same true moment. +const skews = {}; +for (const a of argv.slice(1)) + if (a.includes("=") && !a.startsWith("--")) { + const [h, v] = a.split("="); + skews[shortName(h)] = Number(v); + } +const skewFor = (host) => skews[shortName(host)] ?? 0; +const skewed = (host, epoch) => (Number(epoch) + skewFor(host)).toFixed(3); const need = score.machines; const talk = !flags.includes("--quiet"); // machines greet + sign off unless silenced @@ -223,6 +235,14 @@ byeAt[i] = bcur; bcur += sayDur(byeText(i)) + SPEAK_GAP; } +// Machine-readable speech schedule (consumed by perform.mjs for subtitles): +// each line a machine speaks, with its epoch and estimated spoken length. +if (talk) + for (let i = 0; i < roster.length; i++) { + console.log(` say v${i} @${greetAt[i].toFixed(3)} ~${sayDur(greetText(i)).toFixed(2)} "${greetText(i)}"`); + console.log(` say v${i} @${byeAt[i].toFixed(3)} ~${sayDur(byeText(i)).toFixed(2)} "${byeText(i)}"`); + } + async function run() { const sends = []; // { host, kind, ok } @@ -230,17 +250,17 @@ if (talk) { console.log(`\n greetings…`); for (let i = 0; i < roster.length; i++) if (ready[i]) - sends.push({ host: roster[i], kind: "greeting", ok: await fireSay(roster[i], greetText(i), SAY_VOICES[i % SAY_VOICES.length], greetAt[i].toFixed(3)) }); + sends.push({ host: roster[i], kind: "greeting", ok: await fireSay(roster[i], greetText(i), SAY_VOICES[i % SAY_VOICES.length], skewed(roster[i], greetAt[i])) }); } console.log(`\n downbeat at epoch ${downbeat.toFixed(3)} (in ${(downbeat - Date.now() / 1000).toFixed(1)}s)…`); for (let i = 0; i < roster.length; i++) - sends.push({ host: roster[i], kind: "music", ok: ready[i] ? await firePlay(roster[i], score.voices[i], score.bpm, downbeat.toFixed(3), score.title) : false }); + sends.push({ host: roster[i], kind: "music", ok: ready[i] ? await firePlay(roster[i], score.voices[i], score.bpm, skewed(roster[i], downbeat), score.title) : false }); if (talk) { for (let i = 0; i < roster.length; i++) if (ready[i]) - sends.push({ host: roster[i], kind: "farewell", ok: await fireSay(roster[i], byeText(i), SAY_VOICES[i % SAY_VOICES.length], byeAt[i].toFixed(3)) }); + sends.push({ host: roster[i], kind: "farewell", ok: await fireSay(roster[i], byeText(i), SAY_VOICES[i % SAY_VOICES.length], skewed(roster[i], byeAt[i])) }); } // Honest reporting: which voices actually accepted their cues. diff --git a/slab/menuband/bin/perform.mjs b/slab/menuband/bin/perform.mjs new file mode 100644 --- /dev/null +++ b/slab/menuband/bin/perform.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// perform.mjs — conduct a fleet score WITH desktop shapedown visuals and +// measured clock-skew compensation, all locked to one true downbeat. +// +// node bin/perform.mjs whistle-call-response blueberry neo +// +// 1. Measures each remote host's clock offset over a persistent ssh pipe +// (min-RTT sampling — LAN-accurate to ~1ms). +// 2. Fires conduct.mjs with per-host skew args so every machine SOUNDS at +// the same true moment (conduct alone assumes NTP got it right; it can be +// off by 50-100ms, an audible flam in a ping-pong piece). +// 3. Bakes shapedown pages with the same skewed epochs and opens them via +// the shapedown-overlay binary: borderless, desktop-level, click-through — +// the desktop itself is the stage; the menu bar (the band) stays on top. + +import { spawn, execSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { hostname } from "node:os"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const OVERLAY = resolve(HERE, "..", "shapedown", "shapedown-overlay"); +const [slug, ...hosts] = process.argv.slice(2); +if (!slug || !hosts.length) { + console.log("usage: perform.mjs …"); + process.exit(1); +} +const isLocal = (h) => + ["local", "localhost", "self", hostname().split(".")[0]].includes(h); + +// Score duration (beats include rests) for the overlay's lifetime. +const score = JSON.parse( + readFileSync(resolve(HERE, "..", "scores", `${slug}.mbscore`), "utf8"), +); +const beats = (csv) => csv.split(",").reduce((a, t) => a + Number(t.split(":")[1]), 0); +const durSec = Math.max(...score.voices.map((v) => beats(v.notes))) * (60 / score.bpm); + +// ── 1. clock skew per host (host clock minus ours), min-RTT sampling ────── +function measureSkew(host) { + return new Promise((done) => { + if (isLocal(host)) return done(0); + const probe = spawn("ssh", [host, + 'python3 -u -c "import sys,time\nfor line in sys.stdin: print(time.time(), flush=True)"']); + const samples = []; + let t0 = 0; + const ping = () => { t0 = Date.now() / 1000; probe.stdin.write("x\n"); }; + probe.stdout.on("data", (d) => { + const t1 = Date.now() / 1000; + const remote = Number(String(d).trim().split("\n").at(-1)); + samples.push({ rtt: t1 - t0, off: remote - (t0 + t1) / 2 }); + if (samples.length >= 15) { + probe.kill(); + samples.sort((a, b) => a.rtt - b.rtt); + done(samples[0].off); + } else ping(); + }); + probe.on("error", () => done(0)); + setTimeout(() => { try { probe.kill(); } catch {} ; if (samples.length) { samples.sort((a,b)=>a.rtt-b.rtt); done(samples[0].off); } else done(0); }, 15000); + setTimeout(ping, 400); // let ssh+python settle + }); +} + +console.log(`⏱ measuring clock skew…`); +const skews = []; +for (const h of hosts) { + const s = await measureSkew(h); + skews.push(s); + console.log(` ${h}: ${isLocal(h) ? "conductor (0.0ms)" : (s * 1000).toFixed(1) + "ms"}`); +} + +// ── 2. conduct with skew compensation ───────────────────────────────────── +const skewArgs = hosts.map((h, i) => `${h}=${skews[i].toFixed(4)}`).filter((_, i) => !isLocal(hosts[i])); +const conduct = spawn("node", + [resolve(HERE, "conduct.mjs"), slug, ...hosts, ...skewArgs], + { stdio: ["ignore", "pipe", "inherit"] }); +let epoch = null; +const says = []; // {v, at, dur, text} — the machines' spoken lines +let buf = ""; +conduct.stdout.on("data", (chunk) => { + process.stdout.write(chunk); + buf += chunk; + for (const m of buf.matchAll(/say v(\d+) @([\d.]+) ~([\d.]+) "([^"]*)"/g)) + if (!says.some((s) => s.at === m[2])) + says.push({ v: Number(m[1]), at: Number(m[2]), dur: Number(m[3]), text: m[4] }); + const m = String(chunk).match(/epoch (\d+(?:\.\d+)?)/); + if (m && !epoch) { epoch = m[1]; visuals(epoch); } +}); +conduct.on("close", (code) => { + if (!epoch) { console.error("no downbeat epoch seen — visuals not launched"); process.exit(code || 1); } +}); + +// ── 3. desktop overlays, same skewed epochs ─────────────────────────────── +function visuals(epoch) { + // Subtitles = exactly what gets said, re-timed relative to the downbeat. + const captions = says.map((s) => ({ + v: s.v, t: s.at - Number(epoch), s: s.dur, text: s.text, + })); + const capPath = resolve(HERE, "..", "shapedown", `${slug}-captions.json`); + writeFileSync(capPath, JSON.stringify(captions)); + execSync( + `node ${resolve(HERE, "shapedown.mjs")} ${slug} --epoch ${epoch} --skews ${skews.join(",")} --captions ${capPath}`, + { stdio: "inherit" }); + const life = Math.ceil(Number(epoch) - Date.now() / 1000 + durSec + 14); + hosts.forEach((host, i) => { + const page = resolve(HERE, "..", "shapedown", `${slug}-v${i}.html`); + try { + if (isLocal(host)) { + spawn(OVERLAY, [page, String(life)], { detached: true, stdio: "ignore" }).unref(); + console.log(` ✓ desktop visuals v${i} → ${host} (local, ${life}s)`); + } else { + execSync(`scp -q "${page}" ${host}:/tmp/shapedown-v${i}.html`); + spawn("ssh", [host, `/tmp/shapedown-overlay /tmp/shapedown-v${i}.html ${life} >/dev/null 2>&1 &`], + { detached: true, stdio: "ignore" }).unref(); + console.log(` ✓ desktop visuals v${i} → ${host} (ssh, ${life}s)`); + } + } catch (e) { + console.error(` ✗ visuals v${i} → ${host}: ${e.message.split("\n")[0]}`); + } + }); +} diff --git a/slab/menuband/bin/shapedown.mjs b/slab/menuband/bin/shapedown.mjs new file mode 100644 --- /dev/null +++ b/slab/menuband/bin/shapedown.mjs @@ -0,0 +1,314 @@ +#!/usr/bin/env node +// shapedown.mjs — transparent, over-everything score visuals for a Menu Band +// fleet performance. No backdrop: the desktop stays visible. +// +// The plate is THE REAL STRIP: captured Menu Band menu-bar frames +// (pop/menuband/out/menubar-frames/mb-idle.png + mb-.png pressed +// states — the same assets the waltz reel used). The overlay never redraws +// the design; it scales the actual pixels. The strip flies OUT of the menu +// bar before the downbeat, note bars float down past the real keys (the +// pressed frame swaps in as each note sounds), and when the music ends the +// strip flies home. Subtitles are ONLY what each machine speaks (conduct's +// say schedule via --captions), in prox bubble letters (Comic Sans, white +// fill, dark outline, hard status-colour shadow, per-char jitter + wiggle — +// ported from slab/menubar-swift PromptSigilOverlay.rebuildName). +// +// node bin/shapedown.mjs [--epoch ] [--skews s0,s1] +// [--captions ] [--debug ] + +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT = resolve(HERE, "..", "shapedown"); +const FRAMES = resolve(HERE, "..", "..", "..", "pop", "menuband", "out", "menubar-frames"); + +const [slug, ...rest] = process.argv.slice(2); +if (!slug) { + console.log("usage: shapedown.mjs [--epoch ] [--skews s0,s1] [--captions file] [--debug ]"); + process.exit(1); +} +const arg = (flag) => { + const i = rest.indexOf(flag); + return i >= 0 ? rest[i + 1] : null; +}; +const epoch = Number(arg("--epoch")) || null; +const debugT = arg("--debug"); +const skews = (arg("--skews") || "").split(",").map(Number); +const captionsFile = arg("--captions"); +const allCaptions = captionsFile + ? JSON.parse(readFileSync(captionsFile, "utf8")) + : []; + +const score = JSON.parse( + readFileSync(resolve(HERE, "..", "scores", `${slug}.mbscore`), "utf8"), +); + +function parseNotes(csv) { + let at = 0; + const notes = []; + for (const tok of csv.split(",")) { + const [p, b] = tok.split(":"); + const beats = Number(b); + if (p !== "r") notes.push({ midi: Number(p), atBeat: at, beats }); + at += beats; + } + return { notes, total: at }; +} +function parsePerc(csv) { + if (!csv) return []; + let at = 0; + return csv.split(",").map((tok) => { + const [hit, b] = tok.split(":"); + const e = { hit, atBeat: at }; + at += Number(b); + return e; + }); +} + +const bpm = score.bpm; +const spb = 60 / bpm; +const voices = score.voices.map((v, i) => { + const { notes, total } = parseNotes(v.notes); + return { index: i, name: v.name, notes, totalBeats: total, perc: parsePerc(v.notes2) }; +}); +const durSec = Math.max(...voices.map((v) => v.totalBeats)) * spb; + +// ── the real strip frames ───────────────────────────────────────────────── +// mb-idle.png is the plate; mb-.png is the strip with that key pressed. +// The captured strip is two octaves C4–B5 (white keys 60..83) — that IS the +// layout, identical everywhere, never re-derived. +const b64 = (p) => `data:image/png;base64,${readFileSync(p).toString("base64")}`; +const frames = { idle: b64(resolve(FRAMES, "mb-idle.png")) }; +const usedMidis = [...new Set(voices.flatMap((v) => v.notes.map((n) => n.midi)))]; +for (const m of usedMidis) { + const p = resolve(FRAMES, `mb-${m}.png`); + if (existsSync(p)) frames[m] = b64(p); +} +// White-key slot for a midi within the captured strip (C4=slot 0 … B5=13). +// Sharps snap to their lower white neighbour (the capture set is diatonic). +const WHITE_SLOT = { 0:0, 2:1, 4:2, 5:3, 7:4, 9:5, 11:6 }; + +const STATUS = ["#ff64b4", "#78dcff", "#ffd166", "#95f2a6"]; + +function html(voice) { + const cfg = { + bpm, + epoch: epoch ? epoch + (skews[voice.index] || 0) : null, + debugT: debugT ? Number(debugT) : null, + voice: voice.name, + notes: voice.notes, + perc: voice.perc, + durSec, + status: STATUS[voice.index % STATUS.length], + captions: allCaptions.filter((c) => c.v === voice.index) + .map(({ t, s, text }) => ({ t, s, text })), + }; + if (cfg.debugT != null && !cfg.captions.length) + cfg.captions = [{ t: cfg.debugT - 1, s: 4, text: "Hi neo, this is blueberry. I have the caller." }]; + return ` +shapedown · ${score.title} · ${voice.name} + + +`; +} + +mkdirSync(OUT, { recursive: true }); +const files = voices.map((v) => { + const p = resolve(OUT, `${slug}-v${v.index}.html`); + writeFileSync(p, html(v)); + return p; +}); +console.log(`shapedown → ${files.join("\n ")} (${Object.keys(frames).length - 1} pressed frames embedded)`); +console.log(epoch ? `synced to epoch ${epoch}` : "no --epoch: free-runs 1s after open"); diff --git a/slab/menuband/scores/ambient-drift.mbscore b/slab/menuband/scores/ambient-drift.mbscore new file mode 100644 --- /dev/null +++ b/slab/menuband/scores/ambient-drift.mbscore @@ -0,0 +1,22 @@ +{ + "title": "Ambient Drift", + "composer": "Menu Band, 2026", + "machines": 2, + "bpm": 66, + "lead": 3.0, + "description": "A slow ambient set (~105s): long warm pad tones drifting between the two machines, low drones on one side, high answers floating over from the other, generous silence in between. Nothing hurries.", + "voices": [ + { + "name": "low drift", + "program": 88, + "velocity": 56, + "notes": "60:8,r:4,64:8,r:4,67:8,r:2,65:8,r:4,60:8,r:4,69:8,r:4,64:8,r:2,62:8,r:4,65:8,r:4,60:10,r:4" + }, + { + "name": "high drift", + "program": 91, + "velocity": 50, + "notes": "r:6,72:8,r:4,76:8,r:4,79:8,r:4,77:8,r:4,81:8,r:4,76:8,r:4,74:8,r:4,79:8,r:4,72:10,r:6" + } + ] +} diff --git a/slab/menuband/shapedown/.gitignore b/slab/menuband/shapedown/.gitignore new file mode 100644 --- /dev/null +++ b/slab/menuband/shapedown/.gitignore @@ -0,0 +1,4 @@ +# generated per-performance artifacts — only the Swift source is real +shapedown-overlay +*.html +*-captions.json diff --git a/slab/menuband/shapedown/ShapedownOverlay.swift b/slab/menuband/shapedown/ShapedownOverlay.swift new file mode 100644 --- /dev/null +++ b/slab/menuband/shapedown/ShapedownOverlay.swift @@ -0,0 +1,57 @@ +// ShapedownOverlay.swift — borderless, edge-to-edge stage for shapedown pages. +// +// shapedown-overlay [seconds] +// +// A single borderless window covering the WHOLE screen frame at normal level: +// the menu bar (the band) stays drawn above it, everything else disappears +// behind the visuals — no window chrome, no separate-window look. Escape +// quits; with [seconds] it exits by itself after the performance. + +import Cocoa +import WebKit + +final class Delegate: NSObject, NSApplicationDelegate { + let page: URL + let life: Double? + var window: NSWindow! + + init(page: URL, life: Double?) { self.page = page; self.life = life } + + func applicationDidFinishLaunching(_ n: Notification) { + let screen = NSScreen.main!.frame + window = NSWindow(contentRect: screen, styleMask: [.borderless], + backing: .buffered, defer: false) + // Floating: above every app window (terminals included), below the + // menu bar — the whole screen becomes the stage and the band stays + // visible on top. Click-through so it never traps the mouse. + window.level = .floating + window.ignoresMouseEvents = true + window.isOpaque = false + window.backgroundColor = .clear // no backdrop — desktop shows through + window.hasShadow = false + window.collectionBehavior = [.canJoinAllSpaces, .stationary] + + let web = WKWebView(frame: screen) + web.setValue(false, forKey: "drawsBackground") // transparent web content + web.loadFileURL(page, allowingReadAccessTo: page.deletingLastPathComponent()) + window.contentView = web + window.orderFrontRegardless() // desktop-level + click-through: never key + + NSEvent.addLocalMonitorForEvents(matching: .keyDown) { e in + if e.keyCode == 53 { NSApp.terminate(nil) } // esc + return e + } + if let life { + DispatchQueue.main.asyncAfter(deadline: .now() + life) { NSApp.terminate(nil) } + } + } +} + +let args = CommandLine.arguments +guard args.count >= 2 else { print("usage: shapedown-overlay [seconds]"); exit(1) } +let app = NSApplication.shared +app.setActivationPolicy(.accessory) // no Dock icon — it's a stage, not an app +let delegate = Delegate(page: URL(fileURLWithPath: args[1]), + life: args.count > 2 ? Double(args[2]) : nil) +app.delegate = delegate +app.run() diff --git a/toolchain/instagram/ig.mjs b/toolchain/instagram/ig.mjs --- a/toolchain/instagram/ig.mjs +++ b/toolchain/instagram/ig.mjs @@ -43,6 +43,7 @@ const ACCOUNTS = { oskiewar: "OSKIEWAR", whistlegraph: "WHISTLEGRAPH", aesthetic: "AESTHETIC", + menuband: "MENUBAND", // IG handle is @menuband.app }; // ── tiny arg parser ────────────────────────────────────────────────── -- tangled.sh