diff --git a/slab/bin/instagram-mcp.mjs b/slab/bin/instagram-mcp.mjs index 9fba31441..6a610a450 100644 --- a/slab/bin/instagram-mcp.mjs +++ b/slab/bin/instagram-mcp.mjs @@ -16,7 +16,7 @@ const reelApps = { 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 index 6cf99f92d..c6ee7339c 100644 --- a/slab/menuband/bin/conduct.mjs +++ b/slab/menuband/bin/conduct.mjs @@ -167,7 +167,19 @@ if (argv[0] === "stop") { 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 @@ if (talk) 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 @@ async function run() { 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 index 000000000..3e1095edb --- /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 index 000000000..2a939a8d8 --- /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 index 000000000..62b411a3d --- /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 index 000000000..f6229a821 --- /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 index 000000000..404cd220a --- /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 index 16a783b6a..1549207fc 100644 --- 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 ──────────────────────────────────────────────────