diff --git a/ants/mail-mcp/server.mjs b/ants/mail-mcp/server.mjs
--- a/ants/mail-mcp/server.mjs
+++ b/ants/mail-mcp/server.mjs
@@ -103,14 +103,23 @@ };
}
}
+// URLs stay verbatim — Drive file ids and similar are case-sensitive,
+// and a lowercased link is a dead link.
+function lowercasePreservingUrls(text) {
+ return text
+ .split(/(https?:\/\/\S+|www\.\S+)/g)
+ .map((segment, i) => (i % 2 ? segment : segment.toLowerCase()))
+ .join("");
+}
+
function applyEmailStyle({ subject, body, preserveCase, signature }, style) {
let nextSubject = subject;
let nextBody = body;
const finalSignature = (signature || style.defaultSignature).trim();
if (style.forceLowercase && !preserveCase) {
- nextSubject = nextSubject.toLowerCase();
- nextBody = nextBody.toLowerCase();
+ nextSubject = lowercasePreservingUrls(nextSubject);
+ nextBody = lowercasePreservingUrls(nextBody);
}
if (style.appendSignature && finalSignature) {
diff --git a/fedac/native/ac-usb b/fedac/native/ac-usb
--- a/fedac/native/ac-usb
+++ b/fedac/native/ac-usb
@@ -170,7 +170,7 @@ # AC auth token + device tokens: fetch from device-token API
AC_TOKEN=""
if [ -n "${HANDLE}" ]; then
echo " Fetching credentials for @${HANDLE}..."
- DEVICE_JSON=$(curl -s "https://aesthetic.computer/api/device-token?handle=${HANDLE}" 2>/dev/null || true)
+ DEVICE_JSON=$(curl -s -H "x-ac-device-secret: ${AC_DEVICE_SECRET}" "https://aesthetic.computer/api/device-token?handle=${HANDLE}" 2>/dev/null || true)
AC_TOKEN=$(echo "${DEVICE_JSON}" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const j=JSON.parse(d);process.stdout.write(j.token||'')}catch{}})" 2>/dev/null || true)
# Grab Claude/GitHub tokens from API if not already set locally
if [ -z "${CLAUDE_TOKEN}" ]; then
diff --git a/marketing/bin/capture-ac-native.mjs b/marketing/bin/capture-ac-native.mjs
--- a/marketing/bin/capture-ac-native.mjs
+++ b/marketing/bin/capture-ac-native.mjs
@@ -15,6 +15,8 @@ // Usage:
// node marketing/bin/capture-ac-native.mjs # writes to marketing/captures/
// node marketing/bin/capture-ac-native.mjs --out
# custom out dir
// node marketing/bin/capture-ac-native.mjs --out ~/Desktop/foo/refs
+// node marketing/bin/capture-ac-native.mjs --width 480 --height 320 --scale 1
+// node marketing/bin/capture-ac-native.mjs --hold c,j # hold note keys during the notepat shot so tiles render pressed
import { existsSync, mkdirSync } from "node:fs";
import { resolve, dirname } from "node:path";
@@ -57,7 +59,11 @@ });
for (const t of targets) {
const page = await browser.newPage();
- await page.setViewport({ width: 960, height: 640, deviceScaleFactor: 2 });
+ await page.setViewport({
+ width: parseInt(flags.width || 960, 10),
+ height: parseInt(flags.height || 640, 10),
+ deviceScaleFactor: parseFloat(flags.scale || 2),
+ });
page.on("pageerror", (e) => console.log(` [${t.name}/pageerror] ${e.message}`));
await page.goto(t.url, { waitUntil: "networkidle0" });
await page.evaluate(() => {
@@ -68,8 +74,17 @@ await page.keyboard.press("Space");
await new Promise((r) => setTimeout(r, 3000));
if (t.type === "prompt") await page.keyboard.type("notepat", { delay: 80 });
await new Promise((r) => setTimeout(r, 4000));
+ const held = [];
+ if (t.type === "notepat" && typeof flags.hold === "string") {
+ for (const k of flags.hold.split(",")) {
+ const key = k.trim();
+ if (key) { await page.keyboard.down(key.toUpperCase().length === 1 ? `Key${key.toUpperCase()}` : key); held.push(key); }
+ }
+ await new Promise((r) => setTimeout(r, 600));
+ }
const out = `${OUT}/${t.name}.png`;
await page.screenshot({ path: out, omitBackground: false });
+ for (const k of held) await page.keyboard.up(k.toUpperCase().length === 1 ? `Key${k.toUpperCase()}` : k);
console.log(`✓ ${out}`);
await page.close();
}
diff --git a/oven/bundler.mjs b/oven/bundler.mjs
--- a/oven/bundler.mjs
+++ b/oven/bundler.mjs
@@ -864,6 +864,377 @@
return { html: finalHtml, filename, sizeKB: Math.round(finalHtml.length / 1024) };
}
+// ─── JS library bundle (aesthetic.computer.js) ──────────────────────
+//
+// Same frozen runtime as the HTML pack, but emitted as a standalone
+// JavaScript file instead of an HTML document. Including the file with a
+// just in case the file is
+ // ever inlined into an HTML
+ *
+ *
+ * Usage (module):
+ * import AC from "./aesthetic.computer.js"; AC.boot();
+ *
+ * boot(opts?) options:
+ * piece — override the embedded starting piece (default: ${pieceName})
+ * density — pixel density override (default: smart)
+ * Returns a Promise that resolves once boot.mjs has been imported.
+ * Note: the runtime takes over document.body, like the HTML pack.
+ */
+(function (root, factory) {
+ var AC = factory();
+ if (typeof module === "object" && module.exports) module.exports = AC;
+ root.AestheticComputer = AC;
+})(typeof self !== "undefined" ? self : this, function () {
+ "use strict";
+
+ var VFS = ${vfsJSON};
+ var GLYPHS = ${glyphsJSON};
+ var DEFAULT_PIECE = ${JSON.stringify(pieceName)};
+ var KIDLISP_SOURCE = ${isKidLisp ? JSON.stringify(mainSource) : "null"};
+ var KIDLISP_SOURCES = ${isKidLisp ? JSON.stringify(kidlispSources) : "null"};
+ var PAINTING_CODE_MAP = ${isKidLisp ? JSON.stringify(paintingData) : "{}"};
+ var COLOPHON = ${JSON.stringify(colophon)};
+ var IS_KIDLISP = ${isKidLisp ? "true" : "false"};
+ var BG_COLOR = ${JSON.stringify(bgColor || "black")};
+
+ var booted = false;
+
+ function decodePaintingToBitmap(base64Data) {
+ return new Promise(function (resolve, reject) {
+ var img = new Image();
+ img.onload = function () {
+ var c = document.createElement("canvas");
+ c.width = img.width; c.height = img.height;
+ var ctx = c.getContext("2d");
+ ctx.drawImage(img, 0, 0);
+ var d = ctx.getImageData(0, 0, c.width, c.height);
+ resolve({ width: d.width, height: d.height, pixels: d.data });
+ };
+ img.onerror = reject;
+ img.src = "data:image/png;base64," + base64Data;
+ });
+ }
+
+ function setupPhase1(piece) {
+ // ── Globals (mirror the HTML pack's Phase-1 block) ───────────────
+ window.acPACK_MODE = true;
+ window.acUseWebGLComposite = false;
+ window.KIDLISP_SUPPRESS_SNAPSHOT_LOGS = true;
+ window.__acKidlispConsoleEnabled = false;
+ window.acKEEP_MODE = IS_KIDLISP;
+ window.acSTARTING_PIECE = piece;
+ window.acPACK_PIECE = piece;
+ window.acPACK_DATE = ${JSON.stringify(packDate)};
+ window.acPACK_GIT = ${JSON.stringify(gitVersion)};
+ window.acPACK_COLOPHON = COLOPHON;
+ window.acBUNDLED_GLYPHS = GLYPHS;
+ window.acOBJKT_MATRIX_CHUNKY_GLYPHS = GLYPHS.MatrixChunky8 || {};
+ window.VFS = VFS;
+ ${densitySnippet}
+
+ if (IS_KIDLISP) {
+ window.acKIDLISP_SOURCE = KIDLISP_SOURCE;
+ window.EMBEDDED_KIDLISP_SOURCE = KIDLISP_SOURCE;
+ window.EMBEDDED_KIDLISP_PIECE = ${JSON.stringify(pieceNameNoDollar || "")};
+ window.objktKidlispCodes = KIDLISP_SOURCES;
+ window.acPREFILL_CODE_CACHE = KIDLISP_SOURCES;
+ window.acPAINTING_CODE_MAP = PAINTING_CODE_MAP;
+ window.acEMBEDDED_PAINTING_BITMAPS = {};
+ window.acPAINTING_BITMAPS_READY = false;
+ }
+
+ // ── Block live CSS/font injection ─────────────────────────
+ var origAppend = Element.prototype.appendChild;
+ Element.prototype.appendChild = function (child) {
+ if (child.tagName === "LINK" && child.rel === "stylesheet" && child.href && child.href.includes(".css")) return child;
+ return origAppend.call(this, child);
+ };
+ var origBodyAppend = HTMLBodyElement.prototype.append;
+ HTMLBodyElement.prototype.append = function () {
+ var args = []; for (var i = 0; i < arguments.length; i++) { var n = arguments[i]; if (!(n.tagName === "LINK" && n.rel === "stylesheet")) args.push(n); }
+ return origBodyAppend.apply(this, args);
+ };
+
+ // ── VFS → blob URLs ──────────────────────────────────────────────
+ window.VFS_BLOB_URLS = {};
+ window.modulePaths = [];
+ Object.entries(window.VFS).forEach(function (entry) {
+ var p = entry[0], file = entry[1];
+ if (p.endsWith(".mjs") || p.endsWith(".js")) {
+ var blob = new Blob([file.content], { type: "application/javascript" });
+ window.VFS_BLOB_URLS[p] = URL.createObjectURL(blob);
+ window.modulePaths.push(p);
+ }
+ });
+
+ // ── Import map (must be in the DOM before the first module import) ─
+ var entries = {};
+ for (var i = 0; i < window.modulePaths.length; i++) {
+ var fp = window.modulePaths[i];
+ if (window.VFS_BLOB_URLS[fp]) {
+ entries[fp] = window.VFS_BLOB_URLS[fp];
+ entries["/" + fp] = window.VFS_BLOB_URLS[fp];
+ entries["./" + fp] = window.VFS_BLOB_URLS[fp];
+ entries["../" + fp] = window.VFS_BLOB_URLS[fp];
+ entries["../../" + fp] = window.VFS_BLOB_URLS[fp];
+ entries["aesthetic.computer/" + fp] = window.VFS_BLOB_URLS[fp];
+ entries["/aesthetic.computer/" + fp] = window.VFS_BLOB_URLS[fp];
+ entries["./aesthetic.computer/" + fp] = window.VFS_BLOB_URLS[fp];
+ entries["https://aesthetic.computer/" + fp] = window.VFS_BLOB_URLS[fp];
+ }
+ }
+ var imScript = document.createElement("script");
+ imScript.type = "importmap";
+ imScript.textContent = JSON.stringify({ imports: entries });
+ document.head.appendChild(imScript);
+
+ // ── fetch shim: serve the VFS / glyphs / paintings, neuter /api/ ─
+ var origFetch = window.fetch;
+ window.fetch = function (url, options) {
+ var urlStr = typeof url === "string" ? url : url.toString();
+ if (urlStr.includes("/api/")) {
+ if (urlStr.includes("/api/bdf-glyph") && window.acBUNDLED_GLYPHS) {
+ var fontM = urlStr.match(/[?&]font=([^&]+)/);
+ var charsM = urlStr.match(/[?&]chars?=([^&]+)/);
+ var fontKey = fontM ? decodeURIComponent(fontM[1]) : "unifont";
+ if (fontKey === "unifont-16.0.03") fontKey = "unifont";
+ var fontMap = window.acBUNDLED_GLYPHS[fontKey] || {};
+ var glyphs = {};
+ if (charsM) { for (var c of charsM[1].split(",")) { var hex = c.trim().toUpperCase(); if (fontMap[hex]) glyphs[c.trim()] = fontMap[hex]; } }
+ return Promise.resolve(new Response(JSON.stringify({ glyphs: glyphs }), { status: 200, headers: { "Content-Type": "application/json" } }));
+ }
+ if (urlStr.includes("/api/painting-code")) {
+ var m = urlStr.match(/[?&]code=([^&]+)/);
+ if (m) { var info = window.acPAINTING_CODE_MAP && window.acPAINTING_CODE_MAP[m[1]]; if (info) return Promise.resolve(new Response(JSON.stringify({ code: info.code, handle: info.handle, slug: info.slug }), { status: 200, headers: { "Content-Type": "application/json" } })); }
+ return Promise.resolve(new Response(JSON.stringify({ error: "Not found" }), { status: 404 }));
+ }
+ return Promise.resolve(new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }));
+ }
+ var vfsPath = decodeURIComponent(urlStr).replace(/^https?:\\/\\/[^\\/]+\\//g, "").replace(/^aesthetic\\.computer\\//g, "").replace(/#.*$/g, "").replace(/\\?.*$/g, "");
+ vfsPath = vfsPath.replace(/^\\.\\.\\/+/g, "").replace(/^\\.\\//g, "").replace(/^\\//g, "").replace(/^aesthetic\\.computer\\//g, "");
+ if (IS_KIDLISP && urlStr.includes("/media/") && urlStr.includes("/painting/")) {
+ var pk = Object.keys(window.acPAINTING_CODE_MAP || {});
+ for (var pi = 0; pi < pk.length; pi++) {
+ var pcode = pk[pi], pinfo = window.acPAINTING_CODE_MAP[pcode];
+ if (urlStr.includes(pinfo.slug)) {
+ var pvfs = "paintings/" + pcode + ".png";
+ if (window.VFS[pvfs]) { var f0 = window.VFS[pvfs]; var b0 = atob(f0.content); var u0 = new Uint8Array(b0.length); for (var z0 = 0; z0 < b0.length; z0++) u0[z0] = b0.charCodeAt(z0); return Promise.resolve(new Response(u0, { status: 200, headers: { "Content-Type": "image/png" } })); }
+ }
+ }
+ }
+ if (window.VFS[vfsPath]) {
+ var file = window.VFS[vfsPath]; var content; var ct = "text/plain";
+ if (file.binary) { var bs = atob(file.content); var bytes = new Uint8Array(bs.length); for (var j = 0; j < bs.length; j++) bytes[j] = bs.charCodeAt(j); content = bytes; if (file.type === "png") ct = "image/png"; else if (file.type === "jpg" || file.type === "jpeg") ct = "image/jpeg"; }
+ else { content = file.content; if (file.type === "mjs" || file.type === "js") ct = "application/javascript"; else if (file.type === "json") ct = "application/json"; }
+ return Promise.resolve(new Response(content, { status: 200, headers: { "Content-Type": ct } }));
+ }
+ if (vfsPath.includes("disks/drawings/font_") || vfsPath.endsWith(".mjs") || vfsPath.includes("cursors/") || vfsPath.endsWith(".svg") || vfsPath.endsWith(".css") || urlStr.includes("/type/webfonts/")) {
+ return Promise.resolve(new Response("", { status: 200, headers: { "Content-Type": "text/css" } }));
+ }
+ return origFetch.call(this, url, options);
+ };
+
+ // ── Decode embedded paintings (KidLisp only) ─────────────────────
+ if (IS_KIDLISP) {
+ var promises = [];
+ var map = window.acPAINTING_CODE_MAP || {};
+ Object.keys(map).forEach(function (code) {
+ var vfsPath2 = "paintings/" + code + ".png";
+ if (window.VFS[vfsPath2]) {
+ promises.push(decodePaintingToBitmap(window.VFS[vfsPath2].content).then(function (bitmap) {
+ window.acEMBEDDED_PAINTING_BITMAPS["#" + code] = bitmap;
+ window.acEMBEDDED_PAINTING_BITMAPS[code] = bitmap;
+ }).catch(function () {}));
+ }
+ });
+ return Promise.all(promises).then(function () { window.acPAINTING_BITMAPS_READY = true; });
+ }
+ return Promise.resolve();
+ }
+
+ function boot(o) {
+ o = o || {};
+ if (booted) { console.warn("[aesthetic.computer] already booted"); return Promise.resolve(); }
+ booted = true;
+ if (typeof window === "undefined") return Promise.reject(new Error("AestheticComputer.boot() requires a browser environment"));
+ if (o.density != null) window.acPACK_DENSITY = o.density;
+ var piece = o.piece || DEFAULT_PIECE;
+ document.documentElement.style.background = BG_COLOR;
+ return Promise.resolve(setupPhase1(piece)).then(function () {
+ return import(window.VFS_BLOB_URLS["boot.mjs"]);
+ });
+ }
+
+ return {
+ boot: boot,
+ version: ${JSON.stringify(gitVersion)},
+ piece: DEFAULT_PIECE,
+ isKidLisp: IS_KIDLISP,
+ colophon: COLOPHON,
+ };
+});
+`;
+}
+
// ─── M4D (Max for Live) ─────────────────────────────────────────────
const M4L_HEADER_INSTRUMENT = Buffer.from(
diff --git a/oven/server.mjs b/oven/server.mjs
--- a/oven/server.mjs
+++ b/oven/server.mjs
@@ -14,7 +14,7 @@ import { healthHandler, bakeHandler, statusHandler, bakeCompleteHandler, bakeStatusHandler, getActiveBakes, getIncomingBakes, getRecentBakes, subscribeToUpdates, cleanupStaleBakes } from './baker.mjs';
import { grabHandler, grabGetHandler, grabIPFSHandler, grabPiece, getCachedOrGenerate, getActiveGrabs, getRecentGrabs, getLatestKeepThumbnail, ensureLatestKeepThumbnail, getLatestIPFSUpload, getAllLatestIPFSUploads, setNotifyCallback, setLogCallback, cleanupStaleGrabs, clearAllActiveGrabs, getQueueStatus, getCurrentProgress, getAllProgress, getConcurrencyStatus, IPFS_GATEWAY, generateKidlispOGImage, getOGImageCacheStatus, getFrozenPieces, clearFrozenPiece, getLatestOGImageUrl, regenerateOGImagesBackground, generateKidlispBackdrop, getLatestBackdropUrl, APP_SCREENSHOT_PRESETS, generateNotepatOGImage, getLatestNotepatOGUrl, prewarmGrabBrowser, generateNewsOGImage } from './grabber.mjs';
import archiver from 'archiver';
import sharp from 'sharp';
-import { createBundle, createJSPieceBundle, createM4DBundle, generateDeviceHTML, prewarmCache, getCacheStatus, setSkipMinification } from './bundler.mjs';
+import { createBundle, createJSPieceBundle, createJSLibraryBundle, createM4DBundle, generateDeviceHTML, prewarmCache, getCacheStatus, setSkipMinification } from './bundler.mjs';
import { bundleMini, fetchPieceSource } from './kidlisp-mini/bundle.mjs';
import { streamOSImage, getOSBuildStatus, invalidateManifest, purgeOSBuildCache, clearOSBuildLocalCache } from './os-builder.mjs';
import { startOSBaseBuild, getOSBaseBuild, getOSBaseBuildsSummary, cancelOSBaseBuild } from './os-base-build.mjs';
@@ -2947,6 +2947,42 @@ return res.status(500).json({ error: error.message });
}
}
+ // JS library mode: emit aesthetic.computer.js (a frozen runtime *library*,
+ // not an HTML doc). Supports SSE streaming (format=js-stream) and a plain
+ // file download / json (format=js).
+ if (format === 'js' || format === 'js-stream') {
+ if (format === 'js-stream') {
+ res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' });
+ res.flushHeaders();
+ const sendEvent = (type, data) => {
+ res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
+ if (typeof res.flush === 'function') res.flush();
+ };
+ try {
+ const onProgress = (p) => sendEvent('progress', p);
+ const { js, filename, sizeKB } = await createJSLibraryBundle(bundleTarget, isJSPiece, onProgress, density, nocache);
+ sendEvent('complete', { filename, content: Buffer.from(js).toString('base64'), sizeKB });
+ } catch (error) {
+ console.error('JS library bundle failed:', error);
+ sendEvent('error', { error: error.message });
+ }
+ return res.end();
+ }
+ try {
+ const onProgress = (p) => console.log(`[bundler] js ${p.stage}: ${p.message}`);
+ const { js, filename, sizeKB } = await createJSLibraryBundle(bundleTarget, isJSPiece, onProgress, density, nocache);
+ if (req.query.json === '1') {
+ return res.json({ filename, content: Buffer.from(js).toString('base64'), sizeKB });
+ }
+ const headers = { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'public, max-age=3600' };
+ if (!inline) headers['Content-Disposition'] = `attachment; filename="${filename}"`;
+ return res.set(headers).send(js);
+ } catch (error) {
+ console.error('JS library bundle failed:', error);
+ return res.status(500).json({ error: error.message });
+ }
+ }
+
// SSE streaming mode
if (format === 'stream') {
res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' });
@@ -2988,6 +3024,52 @@ if (!inline) headers['Content-Disposition'] = `attachment; filename="${filename}"`;
return res.set(headers).send(html);
} catch (error) {
console.error('Bundle failed:', error);
+ return res.status(500).json({ error: error.message });
+ }
+});
+
+// JS library packer — convenience alias for /pack-html?...&format=js
+// Emits aesthetic.computer.js, a frozen single-file AC runtime *library*.
+app.get('/packjs', async (req, res) => {
+ const code = req.query.code;
+ const piece = req.query.piece;
+ const density = parseInt(req.query.density) || null;
+ const nocache = req.query.nocache === '1' || req.query.nocache === 'true';
+ const inline = req.query.inline === '1' || req.query.inline === 'true';
+ const stream = req.query.format === 'stream';
+
+ const isJSPiece = !!piece;
+ const bundleTarget = piece || code;
+ if (!bundleTarget) {
+ return res.status(400).json({ error: "Missing 'code' or 'piece' parameter.", usage: { kidlisp: "/packjs?code=39j", javascript: "/packjs?piece=notepat" } });
+ }
+
+ if (stream) {
+ res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' });
+ res.flushHeaders();
+ const sendEvent = (type, data) => {
+ res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
+ if (typeof res.flush === 'function') res.flush();
+ };
+ try {
+ const onProgress = (p) => sendEvent('progress', p);
+ const { js, filename, sizeKB } = await createJSLibraryBundle(bundleTarget, isJSPiece, onProgress, density, nocache);
+ sendEvent('complete', { filename, content: Buffer.from(js).toString('base64'), sizeKB });
+ } catch (error) {
+ console.error('JS library bundle failed:', error);
+ sendEvent('error', { error: error.message });
+ }
+ return res.end();
+ }
+
+ try {
+ const onProgress = (p) => console.log(`[bundler] packjs ${p.stage}: ${p.message}`);
+ const { js, filename, sizeKB } = await createJSLibraryBundle(bundleTarget, isJSPiece, onProgress, density, nocache);
+ const headers = { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'public, max-age=3600', 'X-Bundle-Size-KB': String(sizeKB) };
+ if (!inline) headers['Content-Disposition'] = `attachment; filename="${filename}"`;
+ return res.set(headers).send(js);
+ } catch (error) {
+ console.error('JS library bundle failed:', error);
return res.status(500).json({ error: error.message });
}
});
diff --git a/papers/SCORE.md b/papers/SCORE.md
--- a/papers/SCORE.md
+++ b/papers/SCORE.md
@@ -97,6 +97,7 @@ - [`jeffrey-platter/`](jeffrey-platter/) — biographical materials, image corpus, archival sources for Jeffrey-as-subject
- [`jeffrey-lexicon/`](jeffrey-lexicon/) — frequency-attributed dictionary of words used by Jeffrey, sourced only from first-hand textual + transcribed material (textual analogue of `jeffrey-platter`'s photo index and the `jeffrey-pvc` voice clone)
- [`whistlegraph-platter/`](whistlegraph-platter/) — Whistlegraph-specific artifacts and references
- [`people-platter/`](people-platter/) — TODO; will hold AC-adjacent people biographies
+- [`corporate-graphics-platter/`](corporate-graphics-platter/) — reference library of historic bank/corporate/seal/privacy marks (66, from Wikimedia Commons) collected to inform logo work; four sections (banks-finance, modernist-canon, seals-monograms, surveillance-consent) each with `manifest.md` + a `_sheet-*.png` contact sheet. Built via `fetch-logos.mjs` + `finalize.mjs`. Third-party trademarks — research/moodboard reference only.
### 9. CV (`cv/`)
diff --git a/pop/americomputadora/.gitignore b/pop/americomputadora/.gitignore
--- a/pop/americomputadora/.gitignore
+++ b/pop/americomputadora/.gitignore
@@ -9,3 +9,10 @@ variations/
catalog.json
audition.html
out/
+# snapped/ is the autotuned + whispered hook library, fully derived by
+# bin/hooks.mjs from utterances/. hooks.json + hooks.html are its index/UI.
+snapped/
+hooks.json
+hooks.html
+# demucs vocal stems + proxy windows for marathon sources (bin/clean.mjs)
+.cache/
diff --git a/pop/americomputadora/README.md b/pop/americomputadora/README.md
--- a/pop/americomputadora/README.md
+++ b/pop/americomputadora/README.md
@@ -78,19 +78,103 @@ double-click to drop into the right-hand "arrangement" pane, then **▶ play in
sequence** to hear the string. **export json** dumps the arrangement so a
follow-up render script can turn it into a fixed mix.
-## current state (2026-05-26)
+## word isolation (bin/clean.mjs)
+
+the america/dora cuts came straight off full mixes — crowd, band and
+theme-music rode under every word, and the whisper transform smears that bed
+into the breath. `clean.mjs` fixes it at the source:
+
+1. **demucs** (two-stems) separates each source recording into vocals /
+ accompaniment. full recordings give the model context that sub-second
+ clips can't; marathon sources (the 3-hour soy-dora compilation) get a
+ ±15 s proxy window per clip so an 8 GB machine survives. stems cache in
+ `.cache/demucs/`.
+2. every clip in clips.json is **re-cut from the vocal stem**, denoised
+ (`afftdn`), silence-trimmed on both ends — the music bleed is silence
+ now, so the word boundary tightens itself — micro-faded, loudnormed.
+3. results overwrite `utterances//` under the same names.
-- ✅ pipeline scripts (`fetch`, `extract`, `say-computer`, `variations`, `catalog`)
-- ✅ `computer/` TTS — **69 utterances**, **1,173 variations**, all in
- `utterances/computer/` and `variations/computer/`
-- ⏳ `america/` + `dora/` — sources blocked by harness auto-mode classifier;
- run `node bin/fetch.mjs` yourself, then fill timestamps in `clips.json` and
- re-run `bin/extract.mjs` and `bin/variations.mjs`
-- ⏳ render script (`bin/render.mjs`) — not yet written; will consume an
- `arrangement.json` + a bed score (notepat `.np` + bubblegum synths) and mix
- the final track
-- ⏳ bed: needs DX-bell stab synth, toy-piano preset (might reuse
- `pop/booch/synths/rhodes.mjs` with stretched partials), square lead
+```bash
+node bin/clean.mjs --resnap # separate + re-cut + rebuild snapped/ + hooks.html
+```
+
+falls back to a denoised mix-cut (and says so) if a stem comes up empty for
+some span. demucs installed via `pipx install demucs` + `pipx inject demucs
+torchcodec`.
+
+## the hook machine (bin/hooks.mjs)
+
+every utterance gets **autotuned** to every target note its slot needs across
+the hook variants, plus a **whispered twin** (FFT phase randomization — the
+word's formants survive, the pitch dissolves into breath). that's the
+`snapped/` library, indexed by `hooks.json`. from there every combination of
+the full phrase — currently **22,218** (14 america × 69 computer × 23 dora) —
+is playable instantly, no per-combo render.
+
+```bash
+node bin/hooks.mjs # build snapped library + hooks.html
+node bin/hooks.mjs flight --count 24 --seed 7 # random stitched mp3s → out/hooks/
+node bin/serve.mjs # then open http://localhost:7777/hooks.html
+node bin/hooks.mjs render --fav hooks-favorites.json # stitch kept combos
+```
+
+`hooks.html`: three columns (america / computer / dora), per-slot whisper
+toggles, melody-variant selector, beat-grid playback. **space** plays,
+**s** surfs a random combo, **f** keeps it, arrows/`,`/`.` cycle each slot.
+export favorites json and feed it straight to `render.mjs --fav`.
+
+`out/hooks/_flight-all.mp3` is the contact sheet — every flight combo in one
+listen with a breath between.
+
+## bachiamatrixian mode (render.mjs)
+
+the hook sections now ride a **serious four-on-the-floor kick** (42 Hz body,
+tanh drive) with a deep sidechain pump (duck to 0.22, cosine recovery across
+the beat). a **bach arp** — 16th-note baroque figuration, chord tones + the
+diatonic ninth, plucked like a harpsichord pixel — rains above the vocal and
+takes the full pump. three `bach-*` hook variants in melody.json give the
+vocals baroque contours to follow; the variant rotation reaches them on the
+second chorus. the bridge is the matrix breakdown: kick + sub + arp alone in
+8ths an octave down.
+
+**the canonical mix** (the old bubblegum-only cut is deprecated):
+
+```bash
+node bin/render.mjs \
+ --pick america=whitney-houston-w1 \
+ --pick computer=samantha-slow,fred-slow,karen-slow,daniel-slow,moira-slow,ralph-slow,kathy-slow,albert-slow \
+ --stretch computer=1.4 --decap computer --whisper computer \
+ --pick dora=theme-song-w1 \
+ --out out/americomputadora-bach-whisperz.mp3
+```
+
+- comma lists in `--pick` are a **roster that rotates per hook phrase** —
+ a different computer voice every utterance.
+- `--stretch group=1.4` time-stretches (pitch kept): "cawwwmputerrrr".
+- `--decap group` chops the leading consonant — vowel-onset detection
+ (energy + zero-crossing) finds where /k/ ends, so "computer" becomes
+ "ahmputer" and pours straight out of america's open "aaaah". detection
+ runs on the voiced twin even when the whispered file is used.
+- every vocal clip is RMS-matched after processing so the three words sit
+ at the same loudness in the same space.
+- `--fav hooks-favorites.json --fav-index 2` renders a kept combo.
+
+render pulls clips from `snapped/` directly (already tuned + loudnormed,
+whisper twins included) and only live-shifts when a snap is missing.
+
+## current state (2026-06-09)
+
+- ✅ all three sample groups cut: america **14**, computer **69**, dora **23**
+ utterances (+1,802 variations)
+- ✅ america/dora words demucs-isolated from their mixes, denoised,
+ boundary-tightened (`bin/clean.mjs`, 37/37 clean — no fallbacks)
+- ✅ `bin/hooks.mjs` — snapped/whispered library (940 wavs), `hooks.html`
+ combinatorial audition page, `flight` + `render --fav` stitchers
+- ✅ `bin/render.mjs` — full mix with bubblegum kit, bach arps, serious
+ sidechained kicks, `--pick/--whisper/--fav` hook selection
+- ✅ melody.json — 4 classic + 3 bachiamatrixian hook variants
+- ⏳ pick THE hook: surf hooks.html, keep favorites, render each with
+ `--fav`, A/B until one sticks
## album
diff --git a/pop/americomputadora/bin/render.mjs b/pop/americomputadora/bin/render.mjs
--- a/pop/americomputadora/bin/render.mjs
+++ b/pop/americomputadora/bin/render.mjs
@@ -14,16 +14,24 @@ // the bed is synthesized in-process: dry kick + clap on 2&4, 16th tambourine,
// sub-sine bass on chord roots, FM bell stab on hook downbeats, square lead
// shadowing the melody an octave up, toy-piano on verses.
//
+// the hook clips are pulled from the snapped/ library (bin/hooks.mjs) when it
+// exists — already autotuned + loudnormed, with whispered twins — and only
+// fall back to live pitch-shifting when a snap is missing.
+//
// usage:
// node bin/render.mjs # uses arrangement.json + melody.json
// node bin/render.mjs --auto # picks one clip per group automatically
+// node bin/render.mjs --pick america=whitney-houston-w1 --pick computer=fred-mid
+// node bin/render.mjs --whisper # whispered hook vocals (all slots)
+// node bin/render.mjs --whisper america,dora # whisper just those slots
+// node bin/render.mjs --fav hooks-favorites.json --fav-index 0 # from hooks.html export
// node bin/render.mjs --bpm 116 # override bpm
// node bin/render.mjs --bed-only # instrumental, no hook clips
// node bin/render.mjs --wav # output wav instead of mp3
// node bin/render.mjs --out ~/Desktop/x.mp3 # explicit out path
-import { writeFileSync, mkdirSync, unlinkSync, readFileSync, existsSync, readdirSync } from "node:fs";
-import { resolve, dirname, join } from "node:path";
+import { writeFileSync, mkdirSync, unlinkSync, readFileSync, existsSync, readdirSync, statSync } from "node:fs";
+import { resolve, dirname, join, basename } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { homedir, tmpdir } from "node:os";
@@ -37,9 +45,20 @@
// ── args ────────────────────────────────────────────────────────────────
const argv = process.argv.slice(2);
const flags = {};
+const pickPins = {}; // --pick group=name[,name…] — rotates per phrase
+const stretchPins = {}; // --stretch group=factor — time-stretch, pitch kept
+const decapPins = {}; // --decap group — chop the leading consonant ("ahmputer")
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
- if (a.startsWith("--")) {
+ if (a === "--pick") {
+ const [g, name] = (argv[++i] || "").split("=");
+ if (g && name) pickPins[g] = name.split(",").map((n) => n.trim().replace(/\.wav$/, "")).filter(Boolean);
+ } else if (a === "--stretch") {
+ const [g, f] = (argv[++i] || "").split("=");
+ if (g && +f > 0) stretchPins[g] = +f;
+ } else if (a === "--decap") {
+ for (const g of (argv[++i] || "").split(",")) if (g) decapPins[g.trim()] = true;
+ } else if (a.startsWith("--")) {
const k = a.slice(2), n = argv[i + 1];
if (n !== undefined && !n.startsWith("--")) { flags[k] = n; i++; } else flags[k] = true;
}
@@ -58,17 +77,24 @@ const beat = 60 / BPM;
const bar = beat * 4;
function autoArrangement() {
- // deterministic pick: first utterance found per group. computer prefers fred-mid
- // (the canon synth-voice), america/dora pick alphabetically.
+ // duration-aware pick: the clip whose length best fits its hook slot, so a
+ // 7-second phrase never lands in a 1-second hole. 16-bit mono 48k wavs →
+ // duration ≈ (bytes - header) / 2 / 48000, close enough to rank by.
const pick = {};
- for (const g of ["america", "computer", "dora"]) {
+ melody.hook.words.forEach((g, w) => {
const d = join(ROOT, "utterances", g);
- if (!existsSync(d)) continue;
+ if (!existsSync(d)) return;
const files = readdirSync(d).filter((f) => f.endsWith(".wav"));
- if (!files.length) continue;
- const preferred = g === "computer" ? files.find((f) => f === "fred-mid.wav") : null;
- pick[g] = preferred || files.sort()[0];
- }
+ if (!files.length) return;
+ const slot = melody.hook.beats_per_word[w] * beat + 0.15;
+ let best = files[0], bestDiff = Infinity;
+ for (const f of files) {
+ const dur = (statSync(join(d, f)).size - 44) / 2 / 48000;
+ const diff = Math.abs(dur - slot);
+ if (diff < bestDiff) { bestDiff = diff; best = f; }
+ }
+ pick[g] = best;
+ });
return pick;
}
@@ -97,6 +123,37 @@ console.log(`# arrangement.json picks:`);
for (const [g, p] of Object.entries(arrangement)) console.log(` ${g}: ${p || "(missing)"}`);
}
+// per-slot whisper: --whisper (all) or --whisper america,dora
+const whisperSlots = { america: false, computer: false, dora: false };
+if (flags.whisper === true) for (const g of Object.keys(whisperSlots)) whisperSlots[g] = true;
+else if (typeof flags.whisper === "string")
+ for (const g of flags.whisper.split(",")) if (g in whisperSlots) whisperSlots[g] = true;
+
+// --fav: a kept combo exported from hooks.html (clips + per-slot whisper)
+if (flags.fav) {
+ const favs = JSON.parse(readFileSync(expandHome(flags.fav), "utf8"));
+ const f = favs[Number(flags["fav-index"] ?? 0)];
+ if (!f) { console.error(`✗ no favorite at index ${flags["fav-index"] ?? 0}`); process.exit(1); }
+ console.log(`# favorite combo [${f.label}]:`);
+ for (const g of Object.keys(whisperSlots)) {
+ arrangement[g] = join("utterances", g, f.picks[g].name + ".wav");
+ whisperSlots[g] = !!f.picks[g].whisper;
+ console.log(` ${g}: ${f.picks[g].name}${whisperSlots[g] ? " ·whisper" : ""}`);
+ }
+}
+
+// --pick overrides, last word. comma lists become a roster that rotates
+// per hook phrase ("switch voices every utterance").
+for (const [g, names] of Object.entries(pickPins)) {
+ arrangement[g] = names.map((n) => join("utterances", g, n + ".wav"));
+ console.log(`# pinned ${g}: ${names.join(" → ")}`);
+}
+
+// normalize every slot to a roster (array of clip paths)
+for (const g of Object.keys(whisperSlots)) {
+ if (arrangement[g] && !Array.isArray(arrangement[g])) arrangement[g] = [arrangement[g]];
+}
+
// ── helpers ─────────────────────────────────────────────────────────────
const TAU = Math.PI * 2;
const midiToHz = (m) => 440 * Math.pow(2, (m - 69) / 12);
@@ -163,45 +220,128 @@ // each group gets autotuned to a single dominant MIDI source pitch, then we
// store per-target-midi pre-rendered Float32Arrays so the mix loop is fast.
const hookClips = {}; // { america: { [targetMidi]: Float32Array }, ... }
-function prepareClip(group, relPath) {
+// find where the opening consonant ends and the vowel begins — the /k/ of
+// "computer" is an unvoiced burst (high zero-crossing rate, modest energy);
+// the vowel is loud and periodic. returns the sample index of vowel onset.
+function vowelOnset(samples) {
+ const frame = Math.floor(0.01 * SR); // 10 ms frames
+ const nF = Math.floor(samples.length / frame);
+ let peakE = 0;
+ const E = new Array(nF), Z = new Array(nF);
+ for (let f = 0; f < nF; f++) {
+ let e = 0, z = 0;
+ for (let i = f * frame + 1; i < (f + 1) * frame; i++) {
+ e += samples[i] * samples[i];
+ if ((samples[i] >= 0) !== (samples[i - 1] >= 0)) z++;
+ }
+ E[f] = e / frame; Z[f] = z / frame;
+ if (E[f] > peakE) peakE = E[f];
+ }
+ const maxTrim = Math.floor(nF * 0.4); // never eat more than 40% of the word
+ for (let f = 0; f < maxTrim; f++) {
+ if (E[f] > peakE * 0.22 && Z[f] < 0.14) return f * frame;
+ }
+ return 0;
+}
+
+// match every vocal clip's loudness: normalize the active region (gate at
+// 0.02) to a common RMS so america / computer / dora sit in the same space.
+function normalizeRms(s, target = 0.12) {
+ let sum = 0, n = 0, peak = 0;
+ for (let i = 0; i < s.length; i++) {
+ const a = Math.abs(s[i]);
+ if (a > peak) peak = a;
+ if (a > 0.02) { sum += s[i] * s[i]; n++; }
+ }
+ if (!n) return s;
+ let g = target / Math.sqrt(sum / n);
+ if (peak * g > 0.95) g = 0.95 / peak;
+ for (let i = 0; i < s.length; i++) s[i] *= g;
+ return s;
+}
+
+// time-stretch a wav (slower, same pitch) via ffmpeg atempo. factor 1.5 =
+// "cawwwmputerrrr" — the word takes 1.5× as long to say.
+function stretchWav(absIn, factor) {
+ const tmp = join(tmpdir(), `acd-stretch-${process.pid}-${Math.random().toString(36).slice(2, 8)}.wav`);
+ const res = spawnSync("ffmpeg", [
+ "-hide_banner", "-y", "-loglevel", "error",
+ "-i", absIn, "-af", chainAtempo(1 / factor),
+ "-ac", "1", "-ar", String(SR), tmp,
+ ], { stdio: ["ignore", "ignore", "inherit"] });
+ if (res.status !== 0) throw new Error(`stretch failed for ${absIn}`);
+ const { samples } = readWavMono(tmp);
+ try { unlinkSync(tmp); } catch {}
+ return samples;
+}
+
+function prepareClip(group, relPath, whisper) {
+ const stretch = stretchPins[group];
if (!relPath) return null;
const abs = join(ROOT, relPath);
if (!existsSync(abs)) {
console.warn(` ! ${group}: missing ${relPath}, skipping`);
return null;
}
- const { samples } = readWavMono(abs);
- const hz = detectPitchHz(samples, SR);
- const srcMidi = hz ? hzToMidi(hz) : null;
- console.log(` · ${group}: ${relPath.split("/").pop()} source ≈ ${hz ? hz.toFixed(1) + " Hz / MIDI " + srcMidi.toFixed(1) : "unknown"}`);
- // gather all target midi notes needed across hook variants
+ const name = basename(relPath, ".wav");
const wordIdx = melody.hook.words.indexOf(group);
- if (wordIdx < 0) return { source: samples, srcMidi };
+ if (wordIdx < 0) return null;
const targets = new Set(melody.hook.variants.map((v) => v.notes[wordIdx]));
- const out = { source: samples, srcMidi, byMidi: {} };
+ const out = { byMidi: {} };
+ let srcMidi = null, hz = null, lazySamples = null;
for (const t of targets) {
+ // snapped library first — pre-autotuned + loudnormed by bin/hooks.mjs
+ const snap = join(ROOT, "snapped", group, `${name}__t${t}${whisper ? "__w" : ""}.wav`);
+ if (existsSync(snap)) {
+ let s = stretch ? stretchWav(snap, stretch) : readWavMono(snap).samples;
+ if (decapPins[group]) {
+ // detect the consonant on the VOICED twin (whispered audio has no
+ // periodicity to read), then scale the cut into stretched time.
+ const voiced = join(ROOT, "snapped", group, `${name}__t${t}.wav`);
+ const cutSrc = whisper && existsSync(voiced) ? readWavMono(voiced).samples : (stretch ? readWavMono(snap).samples : s);
+ let cut = vowelOnset(cutSrc);
+ if (stretch) cut = Math.floor(cut * stretch);
+ if (cut > 0 && cut < s.length) {
+ s = s.slice(cut);
+ const fadeN = Math.floor(0.008 * SR);
+ for (let i = 0; i < fadeN && i < s.length; i++) s[i] *= i / fadeN;
+ }
+ }
+ out.byMidi[t] = normalizeRms(s);
+ continue;
+ }
+ // fallback: live detect + shift (no whisper available on this path)
+ if (lazySamples == null) {
+ lazySamples = readWavMono(abs).samples;
+ hz = detectPitchHz(lazySamples, SR);
+ srcMidi = hz ? hzToMidi(hz) : null;
+ }
if (srcMidi == null) {
- // pitch undetected — just place at unity. better than crashing.
- out.byMidi[t] = samples;
+ out.byMidi[t] = normalizeRms(lazySamples); // pitch undetected — unity
continue;
}
- // shift to land on target. clamp to ±18 semitones so we don't time-stretch wildly.
let semis = t - srcMidi;
while (semis > 12) semis -= 12;
while (semis < -12) semis += 12;
- out.byMidi[t] = pitchShiftWav(abs, semis);
+ out.byMidi[t] = normalizeRms(pitchShiftWav(abs, semis));
}
+ console.log(` · ${group}: ${name}${whisper ? " ·whisper" : ""}${stretch ? ` ·stretch×${stretch}` : ""} (${Object.keys(out.byMidi).length} target notes${lazySamples ? ", live-shifted" : ", snapped lib"})`);
return out;
}
-console.log("\n# preparing hook clips (pitch detect + autotune):");
+console.log("\n# preparing hook clips (snapped library / autotune):");
for (const g of ["america", "computer", "dora"]) {
- hookClips[g] = prepareClip(g, arrangement[g]);
+ hookClips[g] = (arrangement[g] || [])
+ .map((p) => prepareClip(g, p, whisperSlots[g]))
+ .filter(Boolean);
}
// ── total length from structure ────────────────────────────────────────
+// reps COUNT: a 4-bar hook section with reps:4 occupies 16 bars. (an old
+// version summed bare sec.bars and silently truncated the back half of the
+// song — verse 2, bridge, stop-time and final chorus never made the buffer.)
let totalBars = 0;
-for (const sec of melody.structure) totalBars += sec.bars;
+for (const sec of melody.structure) totalBars += sec.bars * (sec.reps || 1);
const totalSec = totalBars * bar + 0.5; // tail
const N = Math.ceil(totalSec * SR);
@@ -220,6 +360,51 @@ add(buf, s0 + i, (body + click) * g);
}
}
+// serious kick — four-on-the-floor techno weight. long 42 Hz body, hard
+// click, tanh drive. the thing the sidechain pumps against.
+function kickSerious(buf, t, g = 1.15) {
+ const dur = 0.32, n = Math.floor(dur * SR), s0 = Math.floor(t * SR);
+ let ph = 0;
+ for (let i = 0; i < n; i++) {
+ const tt = i / SR;
+ const f = 42 + (190 - 42) * Math.exp(-tt * 30);
+ ph += (TAU * f) / SR;
+ const body = Math.tanh(Math.sin(ph) * 2.4) * Math.exp(-tt * 7.5);
+ const click = i < SR * 0.003 ? (Math.random() * 2 - 1) * 0.6 * (1 - i / (SR * 0.003)) : 0;
+ add(buf, s0 + i, (body + click) * g);
+ }
+}
+
+// pluck — saw+sine through a fast-closing one-pole lowpass. harpsichord pixel.
+function pluck(buf, t, midi, dur, g = 0.12) {
+ const f = midiToHz(midi);
+ const n = Math.floor(dur * SR), s0 = Math.floor(t * SR);
+ let ph = 0, lp = 0;
+ for (let i = 0; i < n; i++) {
+ const tt = i / SR;
+ ph += (TAU * f) / SR;
+ const saw = 2 * ((ph / TAU) % 1) - 1;
+ const raw = saw * 0.55 + Math.sin(ph) * 0.45;
+ const a = 0.12 + 0.55 * Math.exp(-tt * 14); // brightness dies first
+ lp += a * (raw - lp);
+ add(buf, s0 + i, lp * Math.exp(-tt * 7) * g);
+ }
+}
+
+// bachiamatrixian arp — 16th-note baroque figuration, the matrix rain.
+// chord tones + diatonic ninth sequenced like a two-part invention; the
+// sidechain duck carves the pump into it.
+function bachArp(buf, barStart, root, quality, { density = 16, octave = 12, g = 0.11 } = {}) {
+ const T = quality === "min" ? 3 : 4;
+ const steps = density === 16
+ ? [0, T, 7, 12, 7, T, 0, T, 7, 12, 14, 12, 7, T, 7, 12]
+ : [0, 7, T, 12, 14, 12, 7, T];
+ const stepLen = bar / steps.length;
+ for (let i = 0; i < steps.length; i++) {
+ pluck(buf, barStart + i * stepLen, root + octave + steps[i], stepLen * 1.6, g * (i % 4 === 0 ? 1.15 : 0.85));
+ }
+}
+
// clap — short stack of 4 noise bursts spaced ~7 ms (classic clap envelope).
function clap(buf, t, g = 0.9) {
const burstGap = 0.007, bursts = 4, burstDur = 0.012;
@@ -271,19 +456,35 @@ add(buf, s0 + i, Math.sin(phc) * env * g);
}
}
-// square lead — bright pulse wave with gentle envelope.
-function square(buf, t, midi, dur, g = 0.16) {
+// sine lead — soft, breathing, slow vibrato. shadows the vocal melody
+// without the chiptune edge a square brings.
+function sineLead(buf, t, midi, dur, g = 0.11) {
const f = midiToHz(midi);
const n = Math.floor(dur * SR), s0 = Math.floor(t * SR);
- const att = Math.floor(0.008 * SR), rel = Math.floor(0.04 * SR);
+ const att = Math.floor(0.05 * SR), rel = Math.floor(0.12 * SR);
let ph = 0;
for (let i = 0; i < n; i++) {
- ph += (TAU * f) / SR;
- const sq = Math.sin(ph) > 0 ? 1 : -1;
+ const tt = i / SR;
+ const vib = tt > 0.25 ? Math.sin(TAU * 5.2 * tt) * 0.004 * Math.min(1, (tt - 0.25) * 3) : 0;
+ ph += (TAU * f * (1 + vib)) / SR;
let env = 1;
if (i < att) env = i / att;
else if (i > n - rel) env = Math.max(0, (n - i) / rel);
- add(buf, s0 + i, sq * env * g);
+ add(buf, s0 + i, Math.sin(ph) * env * g);
+ }
+}
+
+// snare — noise crack + 190 Hz body. the rock-ballad backbeat.
+function snare(buf, t, g = 0.85) {
+ const n = Math.floor(0.22 * SR), s0 = Math.floor(t * SR);
+ let prev = 0, ph = 0;
+ for (let i = 0; i < n; i++) {
+ const tt = i / SR;
+ const nz = Math.random() * 2 - 1;
+ const hp = nz - prev; prev = nz;
+ ph += (TAU * 190) / SR;
+ const body = Math.sin(ph) * Math.exp(-tt * 30) * 0.7;
+ add(buf, s0 + i, (hp * Math.exp(-tt * 24) + body) * g);
}
}
@@ -318,20 +519,75 @@ add(buf, s0 + i, s * g);
}
}
-// paint a sample buffer into the mix at startSec with gain.
-function paint(buf, sampBuf, startSec, g = 1.0) {
+// supersaw pad — 3 detuned saws per note through a one-pole lowpass. the
+// pop glue: holds the chord under the hook, pumped by the sidechain.
+function pad(buf, t, midis, dur, g = 0.06) {
+ const n = Math.floor(dur * SR), s0 = Math.floor(t * SR);
+ const att = Math.floor(0.06 * SR), rel = Math.floor(0.12 * SR);
+ for (const midi of midis) {
+ const f0 = midiToHz(midi);
+ for (const det of [-0.006, 0, 0.007]) {
+ const f = f0 * (1 + det);
+ let ph = Math.random() * TAU, lp = 0;
+ for (let i = 0; i < n; i++) {
+ ph += (TAU * f) / SR;
+ const saw = 2 * ((ph / TAU) % 1) - 1;
+ lp += 0.10 * (saw - lp); // dark enough to sit behind the vocal
+ let env = 1;
+ if (i < att) env = i / att;
+ else if (i > n - rel) env = Math.max(0, (n - i) / rel);
+ add(buf, s0 + i, lp * env * g);
+ }
+ }
+ }
+}
+
+// crash — bright noise wash with a long tail, marks phrase arrivals.
+function crash(buf, t, g = 0.22) {
+ const dur = 1.4, n = Math.floor(dur * SR), s0 = Math.floor(t * SR);
+ let prev = 0;
+ for (let i = 0; i < n; i++) {
+ const nz = Math.random() * 2 - 1;
+ const hp = nz - prev; prev = nz;
+ add(buf, s0 + i, hp * Math.exp(-(i / SR) * 3.2) * g);
+ }
+}
+
+// paint with a piecewise gain envelope (clip-relative breakpoints).
+// breaks: [{at: sec, to: mult, over: sec}] — cosine ramp from the current
+// level to `to` starting at `at`; attack is a fade-in from silence. this is
+// how the phrase elides: each word crossfades into the next instead of
+// piling up — "america aaaaawwwmputerrrrr dora!".
+function paintShaped(buf, sampBuf, startSec, g, breaks, attack = 0) {
const s0 = Math.floor(startSec * SR);
+ let level = attack > 0 ? 0 : 1, bi = 0;
+ let from = level, rampStart = 0, rampLen = attack, target = 1;
for (let i = 0; i < sampBuf.length; i++) {
- add(buf, s0 + i, sampBuf[i] * g);
+ const tt = i / SR;
+ if (bi < breaks.length && tt >= breaks[bi].at) {
+ from = level; target = breaks[bi].to;
+ rampStart = tt; rampLen = Math.max(breaks[bi].over, 1e-4);
+ bi++;
+ }
+ if (level !== target) {
+ const w = Math.min(1, (tt - rampStart) / rampLen);
+ level = from + (target - from) * (0.5 - 0.5 * Math.cos(Math.PI * w));
+ if (w >= 1) level = target;
+ }
+ if (level === 0 && bi >= breaks.length) break; // fully choked, rest is silence
+ add(buf, s0 + i, sampBuf[i] * g * level);
}
}
// ── render ──────────────────────────────────────────────────────────────
const drm = new Float32Array(N);
+const snr = new Float32Array(N); // snare on its own bus → reverb send
const bel = new Float32Array(N);
const sqr = new Float32Array(N);
const sb = new Float32Array(N);
const toy = new Float32Array(N);
+const arp = new Float32Array(N);
+const pds = new Float32Array(N);
const voc = new Float32Array(N);
const prog = melody.bed.chord_progression; // 8 bars of I-V-vi-IV
@@ -349,8 +605,12 @@
// schedule everything
let barCursor = 0;
let hookCount = 0;
+let phraseCounter = 0; // rotates the per-slot clip rosters across the song
+// total hook reps in the structure — the percussion ramp's denominator
+const totalHookReps = melody.structure
+ .filter((s) => s.section === "hook")
+ .reduce((a, s) => a + (s.reps || 1), 0);
const kickTimes = [];
-const beat16 = beat / 4;
for (const sec of melody.structure) {
const reps = sec.reps || 1;
@@ -377,11 +637,11 @@ // soft kick on 1 only, light tambourine
kick(drm, bt, 0.55);
for (let i = 0; i < 8; i++) tamb(drm, bt + i * (beat / 2), 0.05);
} else if (sec.section === "verse") {
- // verse: thinned — kick on 1 & 3, clap on 2 & 4 (quieter), no tamb
- kick(drm, bt, 0.75); kickTimes.push(bt);
- kick(drm, bt + beat * 2, 0.75); kickTimes.push(bt + beat * 2);
- clap(drm, bt + beat, 0.55);
- clap(drm, bt + beat * 3, 0.55);
+ // verse: thinned — kick on 1 & 3, soft snare backbeat, no tamb
+ kick(drm, bt, 0.75); kickTimes.push({ t: bt });
+ kick(drm, bt + beat * 2, 0.75); kickTimes.push({ t: bt + beat * 2 });
+ snare(snr, bt + beat, 0.5);
+ snare(snr, bt + beat * 3, 0.5);
// verse melody on toy piano — outline I-V-vi-IV with arpeggios
if (sec.feel === "thin") {
toyPiano(toy, bt, third, beat * 0.9, 0.16);
@@ -394,19 +654,46 @@ toyPiano(toy, bt, third, beat * 1.5, 0.14);
toyPiano(toy, bt + beat * 2, root + 12, beat * 1.5, 0.14);
}
} else if (sec.section === "bridge") {
- // bridge: just sub + soft kick + atmospheric bell on each downbeat
- kick(drm, bt, 0.65); kickTimes.push(bt);
- bell(bel, bt, root + 12, bar * 0.9, 0.18);
+ // bridge: the matrix breakdown — serious kick on the downbeat, sub,
+ // and the bach arp alone in 8ths an octave down. green rain.
+ kickSerious(drm, bt, 0.9); kickTimes.push({ t: bt, serious: true });
+ bachArp(arp, bt, root, ch.quality, { density: 8, octave: 0, g: 0.15 });
} else {
- // hook — full bubblegum kit
- kick(drm, bt, 1.0); kickTimes.push(bt);
- kick(drm, bt + beat * 2, 1.0); kickTimes.push(bt + beat * 2);
- clap(drm, bt + beat, 0.95);
- clap(drm, bt + beat * 3, 0.95);
- // 16th tambourine
- for (let i = 0; i < 16; i++) tamb(drm, bt + i * beat16, i % 4 === 0 ? 0.14 : 0.09);
- // DX bell on downbeat — chord root up an octave
- bell(bel, bt, root + 12, bar * 0.95, 0.22);
+ // hook — percussion RAMPS across the song. the first chorus is the
+ // minimal verse kit; each chorus gains weight until the finale hits
+ // the full rock backbeat (and the very last rep goes four-on-the-
+ // floor). tIns: 0 → 1 across all hook reps in the structure.
+ const tIns = totalHookReps > 1 ? hookCount / (totalHookReps - 1) : 1;
+ if (tIns < 0.34) {
+ // chorus 1 — minimal: soft kick 1 & 3, quiet snare, no hats
+ kick(drm, bt, 0.8); kickTimes.push({ t: bt });
+ kick(drm, bt + beat * 2, 0.8); kickTimes.push({ t: bt + beat * 2 });
+ snare(snr, bt + beat, 0.55);
+ snare(snr, bt + beat * 3, 0.55);
+ } else if (tIns < 0.67) {
+ // chorus 2 — the pump arrives: serious kicks, fuller snare
+ kickSerious(drm, bt, 0.95); kickTimes.push({ t: bt, serious: true });
+ kickSerious(drm, bt + beat * 2, 0.95); kickTimes.push({ t: bt + beat * 2, serious: true });
+ snare(snr, bt + beat, 0.8);
+ snare(snr, bt + beat * 3, 0.8);
+ for (let i = 0; i < 8; i++) tamb(drm, bt + i * (beat / 2), 0.04);
+ } else {
+ // finale — full rock backbeat; last rep goes four-on-the-floor
+ const floor = tIns > 0.95;
+ for (let i = 0; i < 4; i++) {
+ if (i % 2 === 0 || floor) {
+ kickSerious(drm, bt + beat * i, floor ? 1.1 : 1.05);
+ kickTimes.push({ t: bt + beat * i, serious: true });
+ }
+ }
+ snare(snr, bt + beat, 0.95);
+ snare(snr, bt + beat * 3, 0.95);
+ for (let i = 0; i < 8; i++) tamb(drm, bt + i * (beat / 2), 0.055);
+ }
+ // bell / arp / pad swell with the ramp
+ bell(bel, bt, root + 12, bar * 0.95, 0.10 + 0.05 * tIns);
+ bachArp(arp, bt, root, ch.quality, { density: 16, octave: 12, g: 0.07 + 0.04 * tIns });
+ pad(pds, bt, [root - 12, root, third, fifth], bar * 1.02, 0.05 + 0.02 * tIns);
}
}
// hook reps: pick a melody variant and paint the syllables + square lead
@@ -414,23 +701,59 @@ if (sec.section === "hook") {
const variant = melody.hook.variants[hookCount % melody.hook.variants.length];
const words = melody.hook.words;
const lens = melody.hook.beats_per_word;
- // hook is 4 bars × 4 beats = 16 beats, repeats per bar. each bar plays the
- // 3-word hook once: america(1 beat) + computer(1 beat) + dora(2 beats).
- for (let b = 0; b < sec.bars; b++) {
- const barStart = (barCursor + b) * bar;
- let beatOff = 0;
+ // the phrase spans phraseBars bars (8 beats = 2 bars) so the words
+ // actually get to speak — a 4-bar hook section plays it twice.
+ const phraseBeats = lens.reduce((a, c) => a + c, 0);
+ const phraseBars = Math.max(1, Math.round(phraseBeats / 4));
+ const phraseLen = phraseBeats * beat;
+ for (let p = 0; p + phraseBars <= sec.bars; p += phraseBars) {
+ const phraseStart = (barCursor + p) * bar;
+ const phraseIdx = phraseCounter++;
+ crash(drm, phraseStart, p === 0 ? 0.26 : 0.16);
+ // word onsets within the phrase
+ const onsets = [0];
+ for (let w = 0; w < words.length - 1; w++) onsets.push(onsets[w] + lens[w] * beat);
for (let w = 0; w < words.length; w++) {
const word = words[w];
const noteLen = lens[w] * beat;
const targetMidi = variant.notes[w];
- // paint the autotuned vocal sample
- const clip = hookClips[word];
+ // layered merge — every word FINISHES its word: when the next one
+ // enters it ducks underneath (still audibly singing), and is only
+ // choked one word later. "america" completes under "computer",
+ // "computer" rolls under "dora!", dora rings past the phrase end.
+ // a roster of clips per slot rotates voice per phrase.
+ const roster = hookClips[word];
+ const clip = roster && roster.length ? roster[phraseIdx % roster.length] : null;
if (clip && clip.byMidi && clip.byMidi[targetMidi]) {
- paint(voc, clip.byMidi[targetMidi], barStart + beatOff, 0.95);
+ const last = w === words.length - 1;
+ // BLEND: the middle word blooms in 0.3 s EARLY, swelling from
+ // inside america's open vowel while america eases down slowly —
+ // "americaaaaa" and "ahmputer" morph instead of trading places.
+ const BLEND = 0.30;
+ const lead = !last && w > 0 ? BLEND : 0;
+ let breaks, attack, gain;
+ if (last) {
+ // dora — the payoff: sharp attack, hot, rings over the line
+ breaks = [{ at: phraseLen - onsets[w] + 0.06, to: 0, over: 0.22 }];
+ attack = 0.008; gain = 1.15;
+ } else {
+ const duckAt = onsets[w + 1] - onsets[w] + lead;
+ const chokeAt = (w + 2 < words.length ? onsets[w + 2] : phraseLen) - onsets[w] + lead;
+ breaks = w === 0
+ ? [
+ { at: duckAt - BLEND, to: 0.45, over: 0.40 }, // ease down through the morph
+ { at: chokeAt - 0.06, to: 0, over: 0.30 },
+ ]
+ : [
+ { at: duckAt - 0.06, to: 0.30, over: 0.20 },
+ { at: chokeAt - 0.06, to: 0, over: 0.22 },
+ ];
+ attack = w === 0 ? 0 : BLEND; gain = 0.95;
+ }
+ paintShaped(voc, clip.byMidi[targetMidi], phraseStart + onsets[w] - lead, gain, breaks, attack);
}
- // square lead shadows the melody an octave up — adds the candy
- square(sqr, barStart + beatOff, targetMidi + 12, noteLen * 0.9, 0.12);
- beatOff += noteLen;
+ // sine lead shadows the melody an octave up — soft, not chippy
+ sineLead(sqr, phraseStart + onsets[w], targetMidi + 12, noteLen * 0.95, 0.10);
}
}
hookCount++;
@@ -439,33 +762,90 @@ barCursor += sec.bars;
}
}
-// ── light sidechain duck on bell + square under each kick ─────────────
+// ── sidechain duck under each kick ──────────────────────────────────────
+// verse kicks duck lightly (the old polite 0.78); serious four-on-the-floor
+// kicks slam to 0.22 and recover over most of the beat — the pump.
const duck = new Float32Array(N).fill(1);
-for (const kt of kickTimes) {
- const s0 = Math.floor(kt * SR), len = Math.floor(0.10 * SR);
+for (const k of kickTimes) {
+ const depth = k.serious ? 0.32 : 0.78; // ballad breath, not techno slam
+ const len = Math.floor((k.serious ? beat * 0.85 : 0.10) * SR);
+ const s0 = Math.floor(k.t * SR);
for (let i = 0; i < len && s0 + i < N; i++) {
const w = i / len;
- const d = 0.78 + 0.22 * w;
+ const d = depth + (1 - depth) * (0.5 - 0.5 * Math.cos(Math.PI * w)); // cosine recovery
if (d < duck[s0 + i]) duck[s0 + i] = d;
}
}
-// ── fade-out on the outro section (last 4 bars) ────────────────────────
-const fadeStart = (totalBars - 4) * bar;
+// ── fade-out across the outro (last 8 bars — long ballad fade) ─────────
+const fadeStart = (totalBars - 8) * bar;
const fadeStartSamp = Math.floor(fadeStart * SR);
const fadeMaster = new Float32Array(N).fill(1);
for (let i = fadeStartSamp; i < N; i++) {
fadeMaster[i] = Math.max(0, 1 - (i - fadeStartSamp) / (N - fadeStartSamp));
}
+// ── reverb: schroeder combs + allpass, the room everything sits in ─────
+function reverberate(send, decay = 0.62) {
+ const wet = new Float32Array(N);
+ const combs = [0.0297, 0.0371, 0.0411, 0.0437].map((d) => ({
+ buf: new Float32Array(Math.floor(d * SR)), i: 0,
+ }));
+ for (let i = 0; i < N; i++) {
+ let s = 0;
+ for (const c of combs) {
+ const y = c.buf[c.i];
+ c.buf[c.i] = send[i] + y * decay;
+ c.i = (c.i + 1) % c.buf.length;
+ s += y;
+ }
+ wet[i] = s * 0.25;
+ }
+ // two allpass stages diffuse the comb ringing into a wash
+ for (const d of [0.005, 0.0017]) {
+ const ap = new Float32Array(Math.floor(d * SR));
+ let ai = 0;
+ for (let i = 0; i < N; i++) {
+ const y = ap[ai];
+ const x = wet[i];
+ ap[ai] = x + y * 0.5;
+ wet[i] = y - x * 0.5;
+ ai = (ai + 1) % ap.length;
+ }
+ }
+ return wet;
+}
+
+// ── vocal glue: tempo-synced dotted-8th feedback delay ─────────────────
+// the echo tail tucks the words into the groove instead of sitting dry on
+// top — the cheapest "vocals and instruments blend" move in pop.
+const dSamp = Math.floor(beat * 0.75 * SR);
+const echo = new Float32Array(N);
+for (let i = 0; i < N; i++) {
+ echo[i] = voc[i] + (i >= dSamp ? echo[i - dSamp] * 0.40 : 0);
+}
+
+// ── reverb send: vocals, snare, bells, lead, arps go to the room ───────
+const bedOnly = !!flags["bed-only"];
+const send = new Float32Array(N);
+for (let i = 0; i < N; i++) {
+ send[i] = (bedOnly ? 0 : voc[i] * 0.85) + snr[i] * 0.65 + bel[i] * 0.55 + sqr[i] * 0.5 + arp[i] * 0.3;
+}
+const room = reverberate(send);
+
// ── master ─────────────────────────────────────────────────────────────
const mix = new Float32Array(N);
-const bedOnly = !!flags["bed-only"];
for (let i = 0; i < N; i++) {
- const drumsBus = drm[i];
- const melodicBus = (bel[i] + sqr[i] + toy[i]) * duck[i];
- const vocalBus = bedOnly ? 0 : voc[i];
- const s = drumsBus + melodicBus + sb[i] + vocalBus * 0.85;
+ const drumsBus = (drm[i] + snr[i]) * 0.92;
+ // arps + bells + leads + pad take the full pump; sub and vocals duck
+ // partially so the kick owns the low end but the words stay legible.
+ const melodicBus = (bel[i] + sqr[i] + toy[i] + arp[i] + pds[i]) * duck[i];
+ const subBus = sb[i] * (0.55 + 0.45 * duck[i]);
+ const vocalDry = voc[i];
+ const vocalTail = (echo[i] - voc[i]) * 0.28; // pure delay tail, ducked with the band
+ const vocalBus = bedOnly ? 0 : (vocalDry * (0.75 + 0.25 * duck[i]) + vocalTail * duck[i]);
+ const roomBus = room[i] * 0.55 * (0.7 + 0.3 * duck[i]); // the space breathes a little
+ const s = drumsBus + melodicBus + subBus + vocalBus * 0.85 + roomBus;
mix[i] = Math.tanh(s * 0.95) * fadeMaster[i];
}
let peak = 0;
diff --git a/pop/americomputadora/bin/say-computer.mjs b/pop/americomputadora/bin/say-computer.mjs
--- a/pop/americomputadora/bin/say-computer.mjs
+++ b/pop/americomputadora/bin/say-computer.mjs
@@ -27,6 +27,10 @@ "Whisper", "Zarvox",
// "Pipe Organ" sometimes registers as "Organ"; "Bad News" / "Good News"
// also work when present on the host
"Bad News", "Good News", "Pipe Organ", "Princess",
+ // modern conversational voices (Siri-generation; actual Siri voices are
+ // not exposed to `say`) — the smooth end of the spectrum
+ "Flo (English (US))", "Sandy (English (US))", "Shelley (English (US))",
+ "Reed (English (US))", "Eddy (English (US))",
];
const RATES = [
@@ -45,8 +49,10 @@ if (_voices) return _voices;
const out = execSync("say -v '?'", { encoding: "utf8" });
const set = new Set();
for (const line of out.split("\n")) {
- // line format: " # comment"
- const m = line.match(/^(.+?)\s{2,}[a-z]{2}_[A-Z]{2}\b/);
+ // line format: " # comment" — parenthesized names
+ // ("Eddy (English (US))") sit only ONE space from the locale, so match
+ // lazily up to any whitespace + locale.
+ const m = line.match(/^(.+?)\s+[a-z]{2}_[A-Z]{2}\b/);
if (m) set.add(m[1].trim());
}
_voices = set;
diff --git a/pop/americomputadora/melody.json b/pop/americomputadora/melody.json
--- a/pop/americomputadora/melody.json
+++ b/pop/americomputadora/melody.json
@@ -1,39 +1,169 @@
{
- "_comment": "americomputadora — hook melody. D major, 112 BPM. The three vocal samples (whole-word clips, not per-syllable) get autotuned to these target MIDI notes at render time. Each hook is 4 beats: america fills 1 beat, computer 1 beat, dora 2 beats (the resolution lands and rings).",
+ "_comment": "americomputadora — hook melody. D major, 112 BPM. The three vocal samples (whole-word clips, not per-syllable) get autotuned to these target MIDI notes at render time. The hook phrase spans TWO bars (8 beats) so the words can actually speak — 'americaaaaa cawwwmputer dora!': america sings a full bar (4 beats) BEFORE computer enters, computer 2 beats, dora 2 beats as the exclamation point with ring-out.",
"bpm": 112,
"key": "D",
"mode": "major",
"tonic_midi": 62,
- "scale_midi": [62, 64, 66, 67, 69, 71, 73],
+ "scale_midi": [
+ 62,
+ 64,
+ 66,
+ 67,
+ 69,
+ 71,
+ 73
+ ],
"hook": {
- "words": ["america", "computer", "dora"],
- "beats_per_word": [1, 1, 2],
- "_variant_note": "4 melodic variants used across the 4 hook reps per chorus so the hook isn't identical 4× in a row. classic bubblegum trick — same rhythm, slightly different melody contour.",
+ "words": [
+ "america",
+ "computer",
+ "dora"
+ ],
+ "beats_per_word": [
+ 4,
+ 2,
+ 2
+ ],
+ "_variant_note": "ballad melody set (2026-06-09) — variants rotate per hook rep; ordered so the gentle contours open the song and the climax contour arrives as the percussion ramp peaks. bach contours kept from the bachiamatrixian pass.",
"variants": [
- { "label": "5-4-1", "notes": [69, 67, 62], "tone": "establishing — A→G→D, gentle step-down" },
- { "label": "5-3-1", "notes": [69, 66, 62], "tone": "sweeter — A→F#→D, mediant resolution" },
- { "label": "3-2-1", "notes": [66, 64, 62], "tone": "intimate — F#→E→D, all stepwise down" },
- { "label": "5-4-1", "notes": [69, 67, 62], "tone": "back to home" }
+ {
+ "label": "rise-rest",
+ "notes": [
+ 62,
+ 69,
+ 66
+ ],
+ "tone": "ballad opener — D4→A4, resting on the third"
+ },
+ {
+ "label": "step-home",
+ "notes": [
+ 64,
+ 69,
+ 62
+ ],
+ "tone": "E4→A4→D4 — supertonic pull home"
+ },
+ {
+ "label": "lift",
+ "notes": [
+ 66,
+ 71,
+ 69
+ ],
+ "tone": "F#4→B4→A4 — the lift"
+ },
+ {
+ "label": "bach-circle",
+ "notes": [
+ 71,
+ 69,
+ 66
+ ],
+ "tone": "bachiamatrixian — B4→A4→F#4, lament step-down onto the third"
+ },
+ {
+ "label": "bach-arc",
+ "notes": [
+ 66,
+ 69,
+ 74
+ ],
+ "tone": "bachiamatrixian — F#4→A4→D5, rising tonic arpeggio, fanfare resolution"
+ },
+ {
+ "label": "bach-fall",
+ "notes": [
+ 74,
+ 71,
+ 62
+ ],
+ "tone": "bachiamatrixian — D5→B4→D4, falling sixth through the vi, lands tonic"
+ },
+ {
+ "label": "climax",
+ "notes": [
+ 69,
+ 74,
+ 71
+ ],
+ "tone": "A4→D5→B4 — top of the ramp"
+ }
]
},
"bed": {
"chord_progression": [
- { "bars": 2, "root_midi": 62, "quality": "maj" },
- { "bars": 2, "root_midi": 69, "quality": "maj" },
- { "bars": 2, "root_midi": 71, "quality": "min" },
- { "bars": 2, "root_midi": 67, "quality": "maj" }
+ {
+ "bars": 2,
+ "root_midi": 62,
+ "quality": "maj"
+ },
+ {
+ "bars": 2,
+ "root_midi": 69,
+ "quality": "maj"
+ },
+ {
+ "bars": 2,
+ "root_midi": 71,
+ "quality": "min"
+ },
+ {
+ "bars": 2,
+ "root_midi": 67,
+ "quality": "maj"
+ }
],
"_progression_note": "I — V — vi — IV. canonical bubblegum loop (Sugar Sugar / Baby One More Time pre-chorus / countless others). 8 bars total, repeats."
},
"structure": [
- { "section": "intro", "bars": 4, "feel": "soft" },
- { "section": "hook", "bars": 4, "feel": "full", "reps": 4 },
- { "section": "verse", "bars": 8, "feel": "thin" },
- { "section": "hook", "bars": 4, "feel": "full", "reps": 4 },
- { "section": "verse", "bars": 8, "feel": "sparse" },
- { "section": "bridge", "bars": 4, "feel": "breakdown" },
- { "section": "stop", "bars": 1, "feel": "claps-only" },
- { "section": "hook", "bars": 4, "feel": "full", "reps": 4 },
- { "section": "outro", "bars": 4, "feel": "fade" }
+ {
+ "section": "intro",
+ "bars": 10,
+ "feel": "soft"
+ },
+ {
+ "section": "hook",
+ "bars": 4,
+ "feel": "full",
+ "reps": 4
+ },
+ {
+ "section": "verse",
+ "bars": 8,
+ "feel": "thin"
+ },
+ {
+ "section": "hook",
+ "bars": 4,
+ "feel": "full",
+ "reps": 4
+ },
+ {
+ "section": "verse",
+ "bars": 8,
+ "feel": "sparse"
+ },
+ {
+ "section": "bridge",
+ "bars": 4,
+ "feel": "breakdown"
+ },
+ {
+ "section": "stop",
+ "bars": 1,
+ "feel": "claps-only"
+ },
+ {
+ "section": "hook",
+ "bars": 4,
+ "feel": "full",
+ "reps": 4
+ },
+ {
+ "section": "outro",
+ "bars": 8,
+ "feel": "fade"
+ }
]
-}
+}
\ No newline at end of file
diff --git a/pop/bin/dj-usb-collect.sh b/pop/bin/dj-usb-collect.sh
--- a/pop/bin/dj-usb-collect.sh
+++ b/pop/bin/dj-usb-collect.sh
@@ -38,7 +38,7 @@ jungle/out/jungleton.mp3|jungleton
jungle/out/rodando.mp3|rodando
maytrax/out/maytrax.mp3|maytrax
hippyhayzard/out/hippyhayzard.mp3|hippyhayzard
-moronboba/out/moronbobasleep.mp3|moronbobasleep
+momboba/out/mombobasleep.mp3|mombobasleep
booch/out/visualize-my-booch-song.mp3|visualize-my-booch
"
echo "${LOCAL}" | while IFS='|' read -r src name; do
diff --git a/slab/bin/claude-prompt-log.sh b/slab/bin/claude-prompt-log.sh
--- a/slab/bin/claude-prompt-log.sh
+++ b/slab/bin/claude-prompt-log.sh
@@ -51,7 +51,11 @@ gsub(/^ +| +$/, "");
n = (NF > 7) ? 7 : NF;
out = "";
for (i = 1; i <= n; i++) out = (i == 1 ? $i : out " " $i);
+ # Elliptical, always: any truncation (by word count or by
+ # width) trails off in an ellipsis so the title reads as a
+ # trailing thought, never a mid-sentence chop.
if (length(out) > 48) out = substr(out, 1, 45) "…";
+ else if (NF > n) out = out "…";
print out;
}')
diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift
--- a/slab/menuband/Sources/MenuBand/AppDelegate.swift
+++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift
@@ -711,6 +711,17 @@ name: NSNotification.Name("computer.aestheticcomputer.menuband.showPopover"),
object: nil
)
+ // Sibling remote: toggle the popover's instrument-chart
+ // disclosure (same path as pressing the instrument name).
+ // Lets the shell exercise the expand/collapse resize without
+ // clicking.
+ DistributedNotificationCenter.default().addObserver(
+ self,
+ selector: #selector(handleToggleChartNotification(_:)),
+ name: NSNotification.Name("computer.aestheticcomputer.menuband.toggleChart"),
+ object: nil
+ )
+
// Sibling remote: open the About window directly. Lets the
// shell verify (or screenshot) that the About panel renders
// correctly without first walking through the popover.
@@ -2806,6 +2817,12 @@ guard let self = self else { return }
if !self.isPopoverPanelShown {
self.showPopover()
}
+ }
+ }
+
+ @objc private func handleToggleChartNotification(_ note: Notification) {
+ DispatchQueue.main.async { [weak self] in
+ self?.popoverVC?.debugToggleChart()
}
}
diff --git a/slab/menuband/Sources/MenuBand/MenuBandGMSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandGMSynth.swift
--- a/slab/menuband/Sources/MenuBand/MenuBandGMSynth.swift
+++ b/slab/menuband/Sources/MenuBand/MenuBandGMSynth.swift
@@ -77,10 +77,25 @@ /// stream (`gm_voice_init` seeds its xorshift from this). Bumped on the
/// control thread.
private var seedCounter: UInt32 = 0x9E3779B9
- /// Global pitch multiplier from the trackpad bend (1.0 = none, 2.0 =
- /// +1 octave). Written main-thread, read render-thread; a plain Double
- /// is atomic enough (worst case one block reads a half-stale value).
+ /// Global pitch multiplier TARGET from the trackpad bend (1.0 = none,
+ /// 2.0 = +1 octave). Written main-thread, read render-thread; a plain
+ /// Double is atomic enough (worst case one block reads a half-stale
+ /// value).
private var pitchScale: Double = 1.0
+
+ /// Render-thread-owned smoothed pitch that glides toward `pitchScale`.
+ /// The trackpad hands us a NEW target on every mouse-moved tick, so
+ /// applying it as a hard per-block step makes the native voices'
+ /// pitch-derived state jump — a Karplus-Strong delay line resizes,
+ /// modal banks re-tune — and that discontinuity reads as a skip/pop
+ /// while sliding. Easing per-sample toward the target keeps the bend
+ /// continuous so the slide is smooth. ~4 ms time constant: fast enough
+ /// to feel immediate, slow enough to declick.
+ private var glidePitch: Double = 1.0
+ /// Per-sample glide coefficient, set from the real sample rate in
+ /// `attach` (before the render thread runs) so render never has to
+ /// derive it. ~4 ms one-pole time constant.
+ private var pitchGlideCoeff: Double = 1.0 - exp(-1.0 / (48_000 * 0.004))
// MARK: Control → render handoff
@@ -108,6 +123,7 @@ guard !attached else { return }
self.engine = engine
let outRate = engine.outputNode.outputFormat(forBus: 0).sampleRate
sampleRate = outRate > 0 ? outRate : 48_000
+ pitchGlideCoeff = 1.0 - exp(-1.0 / (sampleRate * 0.004))
format = AVAudioFormat(standardFormatWithSampleRate: sampleRate,
channels: 2)!
sourceNode = AVAudioSourceNode(format: format) {
@@ -243,36 +259,69 @@ }
}
let dt = 1.0 / sampleRate
- let pitch = pitchScale
let g = masterGain
+ // Smooth the trackpad bend into a per-sample glide so frequency
+ // never steps between blocks. Each voice rides the SAME trajectory,
+ // so we replay the identical recurrence (same start, target, coeff)
+ // per voice and commit the final value once at the end.
+ let pitchStart = glidePitch
+ let pitchTarget = pitchScale
+ let pitchCoeff = pitchGlideCoeff
for idx in 0.. 0 ? dt / voices[idx].attack : 1.0
let releaseDec = voices[idx].release > 0 ? dt / voices[idx].release : 1.0
+ let releasing = voices[idx].releasing
+ var env = voices[idx].env
+ var active = true
+ var pitch = pitchStart
withUnsafeMutablePointer(to: &voices[idx].core) { ptr in
for i in 0.. 1.0 { voices[idx].env = 1.0 }
+ if releasing {
+ env -= releaseDec
+ if env <= 0 { env = 0; active = false; break }
+ } else if env < 1.0 {
+ env += attackInc
+ if env > 1.0 { env = 1.0 }
}
- let s = gm_voice_render(ptr, sampleRate, voices[idx].env, f)
+ let s = gm_voice_render(ptr, sampleRate, env, f)
+ // Belt-and-suspenders against a divergent C voice: a NaN/Inf
+ // sample summed into the mix poisons the whole buffer and
+ // takes down the downstream limiter/engine. The C core now
+ // traps this at the source, but never trust a render-thread
+ // value blindly — kill the voice and stop mixing it.
+ if !s.isFinite { active = false; break }
let amp = s * vGain * g
- left[i] += Float(amp * Double(voices[idx].gainL))
- right[i] += Float(amp * Double(voices[idx].gainR))
+ left[i] += Float(amp * gainL)
+ right[i] += Float(amp * gainR)
}
}
+ voices[idx].env = env
+ voices[idx].active = active
}
+
+ // Advance the shared glide once for this block (covers the
+ // no-voices-active case too, so the bend is current when the next
+ // note starts). Mirrors the per-sample recurrence above exactly.
+ var p = pitchStart
+ for _ in 0.. 0, target.height > 0 else { return }
+ preferredContentSize = target
+ onRequestResize?(target)
+ }
+
+ /// Dev affordance — drives the instrument-chart disclosure the same
+ /// way pressing the instrument name does. Used by the
+ /// `…menuband.toggleChart` distributed notification for remote
+ /// debugging/screenshots.
+ func debugToggleChart() {
+ instrumentCluster?.toggleChart()
+ }
+
/// Re-measure the stack's intrinsic fitting size and update
/// `preferredContentSize` to match. Run after any change that can
/// add/remove rows or change wrapping height (crash status,
@@ -1570,6 +1629,31 @@ let box = NSBox()
box.boxType = .separator
return box
}
+
+ /// Hairline outline ring on the plain footer buttons (About /
+ /// Keymap) — the default rounded bezel renders nearly flat on the
+ /// glass, so without it they don't read as pressable. Mid-gray
+ /// works against both light and dark appearances.
+ /// Footer chip outline. Each footer peer (About / Keymap / Quit)
+ /// passes its own subtle brand hue so the trio reads as three
+ /// distinct links rather than one repeated control. Default keeps
+ /// the original neutral gray for any other caller.
+ static func outlineFooterButton(_ button: NSButton,
+ color: NSColor = NSColor(white: 0.5, alpha: 0.55)) {
+ button.wantsLayer = true
+ button.layer?.cornerRadius = 6
+ button.layer?.borderWidth = 1
+ button.layer?.borderColor = color.cgColor
+ }
+
+ /// Subtle brand hues for the three footer chips — AC violet, teal,
+ /// and pink at low alpha so the outlines whisper rather than shout.
+ static let aboutOutlineColor =
+ NSColor(red: 167/255, green: 139/255, blue: 250/255, alpha: 0.55) // AC violet
+ static let keymapOutlineColor =
+ NSColor(red: 100/255, green: 210/255, blue: 220/255, alpha: 0.55) // teal
+ static let quitOutlineColor =
+ NSColor(red: 255/255, green: 107/255, blue: 157/255, alpha: 0.55) // AC pink
/// Badge-style link button — flat HoverLinkButton with a layer-painted
/// fill + optional border, so the per-link attributed title sits
diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift b/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift
--- a/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift
+++ b/slab/menuband/Sources/MenuBand/MenuBandPopoverPanel.swift
@@ -96,6 +96,8 @@ /// the entire popover height changes, not just an inner row.
func resizeContent(to contentSize: NSSize, animated: Bool) {
let newH = contentSize.height + Self.arrowHeight
let newW = max(contentSize.width, frame.width)
+ NSLog("MenuBand panel resize: %.0f×%.0f → %.0f×%.0f",
+ frame.width, frame.height, newW, newH)
let top = frame.maxY // pin top edge
let originX = frame.minX // keep left edge
let newFrame = NSRect(x: originX, y: top - newH,
diff --git a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift
--- a/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift
+++ b/slab/menuband/Sources/MenuBand/PianoWaveformWindow/CollapsedPianoWaveformView.swift
@@ -24,11 +24,10 @@ private let instrumentGridContainer = NSView()
/// Active program number + name, sitting right above the GM
/// chooser grid. Uses YWFT Processing so the readout matches
/// the popover's title typography. Color-keyed to the family.
- private let instrumentReadoutLabel = NSTextField(labelWithString: "")
- /// Dark rounded "pill" behind the readout for contrast. A SIBLING behind
- /// the label (not its parent) so the label never gets layer-backed — that
- /// would soften the 1px Riso-misregister shadow (see note in init).
- private let readoutBackground = NSView()
+ /// A HoverLinkButton (pointing-hand cursor) — pressing the name
+ /// shows/hides the chooser grid below, so the popover opens compact
+ /// and only grows when the user wants to browse instruments.
+ private let instrumentReadoutButton = HoverLinkButton()
/// QWERTY keymap visualization — moved out of the popover so
/// the user can see which physical keys play which notes while
/// the chooser is open. Lit cells reflect held keys.
@@ -64,6 +63,21 @@ var onStepUp: (() -> Void)?
var onStepDown: (() -> Void)?
/// Fired when the Keymap button is clicked — opens the full-screen view.
var onOpenKeymap: (() -> Void)?
+ /// Fired after the chooser grid is shown/hidden via the instrument
+ /// name — the host popover re-fits its panel height in response.
+ var onChartToggled: (() -> Void)?
+
+ /// Whether the GM chooser grid is visible. Collapsed by default —
+ /// the instrument name is the disclosure control. Persisted so the
+ /// popover reopens the way the user left it.
+ private static let chartExpandedKey = "MBInstrumentChartExpanded"
+ private(set) var chartExpanded =
+ UserDefaults.standard.bool(forKey: CollapsedPianoWaveformView.chartExpandedKey)
+ /// Bottom edge of the cluster tracks the grid (expanded) or the
+ /// readout (collapsed). Exactly one is active at a time — both at
+ /// once over-constrains the fixed-height grid.
+ private var gridExpandedBottom: NSLayoutConstraint!
+ private var gridCollapsedBottom: NSLayoutConstraint!
/// Light up an arrow cap as if the keyboard was pressing it.
/// Direction indices match `ArrowKeysIndicator`:
@@ -227,25 +241,17 @@ break
}
}
- instrumentReadoutLabel.translatesAutoresizingMaskIntoConstraints = false
- instrumentReadoutLabel.alignment = .center
- instrumentReadoutLabel.maximumNumberOfLines = 1
- instrumentReadoutLabel.lineBreakMode = .byTruncatingTail
- instrumentReadoutLabel.drawsBackground = false
- instrumentReadoutLabel.isBordered = false
- instrumentReadoutLabel.isEditable = false
- instrumentReadoutLabel.isSelectable = false
- // Don't layer-back — a CA rasterization step softens the
- // 1px Riso-misregister shadow we apply in refresh().
- instrumentReadoutLabel.setContentHuggingPriority(.required, for: .vertical)
- instrumentReadoutLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
-
- // Dark contrast pill behind the readout. Fill + border are themed in
- // refresh(); here just the shape.
- readoutBackground.translatesAutoresizingMaskIntoConstraints = false
- readoutBackground.wantsLayer = true
- readoutBackground.layer?.cornerRadius = 7
- readoutBackground.layer?.masksToBounds = true
+ instrumentReadoutButton.translatesAutoresizingMaskIntoConstraints = false
+ instrumentReadoutButton.isBordered = false
+ instrumentReadoutButton.setButtonType(.momentaryPushIn)
+ instrumentReadoutButton.alignment = .center
+ instrumentReadoutButton.target = self
+ instrumentReadoutButton.action = #selector(readoutClicked(_:))
+ // No backing pill, no wantsLayer — a CA rasterization step would
+ // soften the 1px Riso-misregister shadow we apply in refresh(),
+ // and the name reads fine straight on the glass.
+ instrumentReadoutButton.setContentHuggingPriority(.required, for: .vertical)
+ instrumentReadoutButton.setContentCompressionResistancePriority(.required, for: .horizontal)
// Single accent "Keymap" button — opens the full-screen keymap
// view. The QWERTY graphic + Notepat/Conventional mode toggle now
@@ -274,8 +280,7 @@ addSubview(contentContainer)
contentContainer.addSubview(instrumentGridContainer)
instrumentGridContainer.addSubview(instrumentList)
contentContainer.addSubview(waveformStrip)
- contentContainer.addSubview(readoutBackground) // behind the label
- contentContainer.addSubview(instrumentReadoutLabel)
+ contentContainer.addSubview(instrumentReadoutButton)
// [v1] Skip our own glass backdrop when embedded in the popover's
// glass surface — otherwise the two stack into a doubled sheet.
if !embedded { installLiquidGlassBackgrounds() }
@@ -300,30 +305,20 @@ equalToConstant: InstrumentListView.preferredWidth),
waveformStrip.heightAnchor.constraint(equalToConstant: 30),
// Active-instrument readout below the strip.
- instrumentReadoutLabel.leadingAnchor.constraint(
+ instrumentReadoutButton.leadingAnchor.constraint(
greaterThanOrEqualTo: contentContainer.leadingAnchor,
constant: Self.edgePadding),
- instrumentReadoutLabel.trailingAnchor.constraint(
+ instrumentReadoutButton.trailingAnchor.constraint(
lessThanOrEqualTo: contentContainer.trailingAnchor,
constant: -Self.edgePadding),
- instrumentReadoutLabel.centerXAnchor.constraint(
+ instrumentReadoutButton.centerXAnchor.constraint(
equalTo: contentContainer.centerXAnchor),
- instrumentReadoutLabel.topAnchor.constraint(
+ instrumentReadoutButton.topAnchor.constraint(
equalTo: waveformStrip.bottomAnchor, constant: Self.rowGap),
-
- // Pill hugs the label with a little padding (h:10, v:4).
- readoutBackground.leadingAnchor.constraint(
- equalTo: instrumentReadoutLabel.leadingAnchor, constant: -10),
- readoutBackground.trailingAnchor.constraint(
- equalTo: instrumentReadoutLabel.trailingAnchor, constant: 10),
- readoutBackground.topAnchor.constraint(
- equalTo: instrumentReadoutLabel.topAnchor, constant: -4),
- readoutBackground.bottomAnchor.constraint(
- equalTo: instrumentReadoutLabel.bottomAnchor, constant: 4),
// Instrument chooser grid below the readout.
instrumentGridContainer.centerXAnchor.constraint(equalTo: contentContainer.centerXAnchor),
- instrumentGridContainer.topAnchor.constraint(equalTo: readoutBackground.bottomAnchor, constant: Self.rowGap),
+ instrumentGridContainer.topAnchor.constraint(equalTo: instrumentReadoutButton.bottomAnchor, constant: Self.rowGap + 2),
instrumentGridContainer.widthAnchor.constraint(
equalToConstant: InstrumentListView.preferredWidth + Self.gridPadding * 2
),
@@ -337,14 +332,48 @@ instrumentList.topAnchor.constraint(equalTo: instrumentGridContainer.topAnchor, constant: Self.gridPadding),
instrumentList.bottomAnchor.constraint(equalTo: instrumentGridContainer.bottomAnchor, constant: -Self.gridPadding),
instrumentList.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth),
instrumentList.heightAnchor.constraint(equalToConstant: InstrumentListView.preferredHeight),
+ ])
- // Grid is the bottom element — the Keymap button now lives in
- // the popover footer alongside About / Quit.
- instrumentGridContainer.bottomAnchor.constraint(
- equalTo: contentContainer.bottomAnchor, constant: -Self.bottomInset),
- ])
+ // Bottom edge: grid (expanded) or readout (collapsed). Built
+ // outside the activate() block — applyChartVisibility() flips
+ // exactly one of the pair on.
+ gridExpandedBottom = instrumentGridContainer.bottomAnchor.constraint(
+ equalTo: contentContainer.bottomAnchor, constant: -Self.bottomInset)
+ gridCollapsedBottom = instrumentReadoutButton.bottomAnchor.constraint(
+ equalTo: contentContainer.bottomAnchor, constant: -Self.bottomInset)
+ applyChartVisibility()
refresh()
+ }
+
+ /// Show/hide the chooser grid and re-anchor the cluster's bottom
+ /// edge. Deactivate before activate — both bottoms at once
+ /// over-constrain the fixed-height grid.
+ private func applyChartVisibility() {
+ instrumentGridContainer.isHidden = !chartExpanded
+ if chartExpanded {
+ gridCollapsedBottom.isActive = false
+ gridExpandedBottom.isActive = true
+ } else {
+ gridExpandedBottom.isActive = false
+ gridCollapsedBottom.isActive = true
+ }
+ }
+
+ @objc private func readoutClicked(_ sender: NSButton) {
+ toggleChart()
+ }
+
+ /// Shared toggle path — the readout press and the dev
+ /// `toggleChart` distributed notification both land here.
+ func toggleChart() {
+ chartExpanded.toggle()
+ UserDefaults.standard.set(chartExpanded, forKey: Self.chartExpandedKey)
+ applyChartVisibility()
+ refresh() // chevron direction in the title
+ NSLog("MenuBand chart: %@ — cluster fitting h=%.0f",
+ chartExpanded ? "expanded" : "collapsed", fittingSize.height)
+ onChartToggled?()
}
@objc private func keymapButtonClicked(_ sender: NSButton) {
@@ -499,13 +528,14 @@ }
NSLog("MenuBand: YWFT bold descriptor unavailable; collapsed-panel readout falling back to system font")
return NSFont.systemFont(ofSize: 16, weight: .black)
}()
- // Always light text — the readout now sits on a dark contrast pill.
- let textColor: NSColor = .white
+ // Max-contrast text with the family-colored hard 1px Riso shadow —
+ // the contrast pill is gone, the name sits straight on the glass.
+ let textColor: NSColor = isDark ? .white : .black
let shadow = NSShadow()
shadow.shadowColor = (badgeColor.highlight(withLevel: 0.4) ?? badgeColor)
shadow.shadowOffset = NSSize(width: 1, height: -1)
shadow.shadowBlurRadius = 0
- instrumentReadoutLabel.attributedStringValue = NSAttributedString(
+ let attr = NSMutableAttributedString(
string: title,
attributes: [
.font: titleFont,
@@ -513,14 +543,20 @@ .foregroundColor: textColor,
.shadow: shadow,
]
)
- instrumentReadoutLabel.toolTip = title
- // Dark pill + a 1px family-colored hairline so the readout keeps its
- // instrument identity while gaining contrast.
- readoutBackground.layer?.backgroundColor =
- NSColor.black.withAlphaComponent(isDark ? 0.62 : 0.78).cgColor
- readoutBackground.layer?.borderColor =
- badgeColor.withAlphaComponent(0.85).cgColor
- readoutBackground.layer?.borderWidth = 1.0
+ // Disclosure chevron — the name doubles as the show/hide control
+ // for the chooser grid below.
+ attr.append(NSAttributedString(
+ string: chartExpanded ? " ▴" : " ▾",
+ attributes: [
+ .font: NSFont.systemFont(ofSize: 11, weight: .bold),
+ .foregroundColor: textColor.withAlphaComponent(0.55),
+ .baselineOffset: 2,
+ ]
+ ))
+ instrumentReadoutButton.attributedTitle = attr
+ instrumentReadoutButton.toolTip = chartExpanded
+ ? "Hide the instrument chart"
+ : "Show the instrument chart"
}
@objc private func whyKeymapClicked(_ sender: NSButton) {
diff --git a/slab/menubar-swift/Sources/SlabMenubar/AXTiler.swift b/slab/menubar-swift/Sources/SlabMenubar/AXTiler.swift
new file mode 100644
--- /dev/null
+++ b/slab/menubar-swift/Sources/SlabMenubar/AXTiler.swift
@@ -0,0 +1,68 @@
+import AppKit
+import ApplicationServices
+
+/// In-process window placement via the Accessibility API. This is what
+/// makes "Tile now" feel instant: the old path forked `osascript` three
+/// times (two synchronous window-count probes + a bounds script that
+/// `activate`d the terminal), each a process spawn plus an Apple Events
+/// round-trip into the app's main thread. AX is a direct Mach call —
+/// enumerating and re-framing a dozen windows lands in single-digit
+/// milliseconds, steals no focus, and needs no script compilation.
+///
+/// Requires Accessibility trust, which the menubar app already holds for
+/// its System Events font-menu clicking; `trusted` gates every caller so
+/// an untrusted install falls back to the legacy osascript path instead
+/// of silently doing nothing.
+///
+/// Known AX caveat: `kAXWindowsAttribute` only lists windows on the
+/// current Space — which is the right behavior for a tiler (windows parked
+/// on another Space shouldn't be yanked into this screen's grid).
+enum AXTiler {
+ static var trusted: Bool { AXIsProcessTrusted() }
+
+ /// Tileable windows of `bundleId`, front-to-back: standard windows
+ /// only (no panels/sheets/hotkey drawers), minimized excluded — the
+ /// same filter the AppleScript tiler applied. App not running → [].
+ static func windows(bundleId: String) -> [AXUIElement] {
+ var out: [AXUIElement] = []
+ for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) {
+ let el = AXUIElementCreateApplication(app.processIdentifier)
+ var ref: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(el, kAXWindowsAttribute as CFString, &ref) == .success,
+ let list = ref as? [AXUIElement] else { continue }
+ for w in list {
+ if boolAttr(w, kAXMinimizedAttribute) == true { continue }
+ if let sub = stringAttr(w, kAXSubroleAttribute),
+ sub != kAXStandardWindowSubrole as String { continue }
+ out.append(w)
+ }
+ }
+ return out
+ }
+
+ /// Pin a window to AppleScript-style bounds (global top-left-origin
+ /// pixels — AX shares that coordinate space). Position before size so
+ /// a window clamped by its old frame still lands in its cell.
+ static func setFrame(_ w: AXUIElement, left: Int, top: Int, right: Int, bottom: Int) {
+ var pos = CGPoint(x: left, y: top)
+ var size = CGSize(width: right - left, height: bottom - top)
+ if let v = AXValueCreate(.cgPoint, &pos) {
+ AXUIElementSetAttributeValue(w, kAXPositionAttribute as CFString, v)
+ }
+ if let v = AXValueCreate(.cgSize, &size) {
+ AXUIElementSetAttributeValue(w, kAXSizeAttribute as CFString, v)
+ }
+ }
+
+ private static func boolAttr(_ el: AXUIElement, _ attr: String) -> Bool? {
+ var ref: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(el, attr as CFString, &ref) == .success else { return nil }
+ return ref as? Bool
+ }
+
+ private static func stringAttr(_ el: AXUIElement, _ attr: String) -> String? {
+ var ref: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(el, attr as CFString, &ref) == .success else { return nil }
+ return ref as? String
+ }
+}
diff --git a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift
--- a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift
+++ b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift
@@ -17,7 +17,11 @@ /// fires NO hook on interrupt, so we infer it from staleness
/// + the running-tool heartbeat (a long real tool keeps the
/// marker fresh and stays `working`/green).
/// `stale` — claude_pid is gone; about to be reaped.
- enum State { case blank, working, complete, awaiting, interrupted, stale }
+ /// `rendering` — the turn is done but a long render this session launched
+ /// (a ~/.ac-pop-renders heartbeat carrying its sessionId) is
+ /// still running. Pink — between working-green and
+ /// awaiting-amber: not idle, the machine is cooking.
+ enum State { case blank, working, rendering, complete, awaiting, interrupted, stale }
/// Seconds of hook silence (no tool start/stop, no prompt) before a
/// `working` session is treated as interrupted. Generous so normal
@@ -44,6 +48,10 @@ /// Absolute path to this session's iTerm2 background-image wallpaper,
/// resolved off-main during refresh (instant cache probe; empty until
/// the async generator has produced one). Empty → leave bg image unset.
var wallpaper: String = ""
+ /// Sticky per-session emoji (see TitleEmoji) prefixed to the window
+ /// title and menu row so the eye can re-find a session after the tiler
+ /// shuffles the grid. Stamped during refresh; "" until the first prompt.
+ var emoji: String = ""
/// Number of subagents currently in-flight under this session — both
/// `Task`-tool agents (per-session markers from `claude-tool-pre.sh`) and
/// live Workflow-tool agents (counted from the workflow journals). Drawn
@@ -158,9 +166,10 @@ switch st {
case .awaiting: return 0
case .interrupted: return 1
case .complete: return 2
- case .working: return 3
- case .blank: return 4
- case .stale: return 5
+ case .rendering: return 3 // busy, nothing to read yet
+ case .working: return 4
+ case .blank: return 5
+ case .stale: return 6
}
}
let ra = rank(a.state), rb = rank(b.state)
diff --git a/slab/menubar-swift/Sources/SlabMenubar/PdfViewer.swift b/slab/menubar-swift/Sources/SlabMenubar/PdfViewer.swift
new file mode 100644
--- /dev/null
+++ b/slab/menubar-swift/Sources/SlabMenubar/PdfViewer.swift
@@ -0,0 +1,288 @@
+// PdfViewer — slab's minimal PDF scroller, so "open this PDF" doesn't mean
+// Preview.app. A borderless-feeling panel: just the pages, a thin chip with
+// the filename and a single "Preview" escape hatch, Esc or ⌘W to dismiss.
+//
+// How it gets asked: anything (Claude, a script, the menu) appends an
+// absolute path per line to $SLAB_HOME/state/open-pdf; the menubar's 2 s
+// tick consumes the file on the main thread (tiny stat — no shell-outs,
+// per slab-menubar-perf). The `slab-pdf` wrapper in slab/bin does exactly
+// that. Re-requesting an open path brings its window forward. The viewer
+// watches the file and reloads on rewrite (xelatex loops feel live).
+import AppKit
+import PDFKit
+
+extension Paths {
+ /// One absolute PDF path per line; consumed (deleted) each tick.
+ static var pdfRequestFile: String { "\(slabHome)/state/open-pdf" }
+}
+
+final class PdfViewer {
+ static let shared = PdfViewer()
+ private var controllers: [String: PdfWindowController] = [:]
+
+ var openPaths: [String] { controllers.keys.sorted() }
+
+ /// Called from the main-thread side of AppDelegate.refresh() every tick.
+ /// Request lines are "path\tsession_id" (the session id may be empty);
+ /// `emojiFor` maps a live Claude session to its sticky TitleEmoji so the
+ /// chip wears the mark of the prompt that asked.
+ func consumeRequests(emojiFor: (String) -> String = { _ in "" }) {
+ let file = Paths.pdfRequestFile
+ guard FileManager.default.fileExists(atPath: file) else { return }
+ let text = (try? String(contentsOfFile: file, encoding: .utf8)) ?? ""
+ try? FileManager.default.removeItem(atPath: file)
+ for line in text.split(separator: "\n") {
+ let parts = line.split(separator: "\t", maxSplits: 1, omittingEmptySubsequences: false)
+ let path = parts[0].trimmingCharacters(in: .whitespaces)
+ let sid = parts.count > 1 ? parts[1].trimmingCharacters(in: .whitespaces) : ""
+ if !path.isEmpty { open(path, emoji: sid.isEmpty ? "" : emojiFor(sid)) }
+ }
+ }
+
+ func open(_ rawPath: String, emoji: String = "") {
+ let path = (rawPath as NSString).expandingTildeInPath
+ if let existing = controllers[path] { existing.focus(); return }
+ guard FileManager.default.fileExists(atPath: path),
+ let controller = PdfWindowController(path: path, emoji: emoji, onClose: { [weak self] in
+ self?.controllers.removeValue(forKey: path)
+ })
+ else { return }
+ controllers[path] = controller
+ controller.focus()
+ }
+
+ func focus(_ path: String) { controllers[path]?.focus() }
+
+ func closeAll() {
+ // close() triggers onClose which mutates the dictionary — iterate a copy.
+ for controller in Array(controllers.values) { controller.close() }
+ }
+}
+
+/// One panel per document. Owns the PDFView, the chip, and a file monitor
+/// that reloads the document in place when the PDF is rewritten on disk.
+private final class PdfWindowController: NSObject, NSWindowDelegate {
+ private let path: String
+ private let emoji: String
+ private let panel: PdfPanel
+ private let pdfView = DraggablePdfView()
+ private let onClose: () -> Void
+ private var monitor: DispatchSourceFileSystemObject?
+ private var reloadPending = false
+
+ init?(path: String, emoji: String = "", onClose: @escaping () -> Void) {
+ guard let document = PDFDocument(url: URL(fileURLWithPath: path)) else { return nil }
+ self.path = path
+ self.emoji = emoji
+ self.onClose = onClose
+
+ panel = PdfPanel(
+ contentRect: PdfWindowController.idealFrame(for: document),
+ styleMask: [.titled, .closable, .resizable, .fullSizeContentView],
+ backing: .buffered, defer: false)
+ super.init()
+
+ panel.titleVisibility = .hidden
+ panel.titlebarAppearsTransparent = true
+ panel.isMovableByWindowBackground = true
+ panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
+ panel.standardWindowButton(.zoomButton)?.isHidden = true
+ panel.isFloatingPanel = false
+ panel.hidesOnDeactivate = false
+ panel.isReleasedWhenClosed = false
+ panel.delegate = self
+ panel.title = (path as NSString).lastPathComponent // for Mission Control, not chrome
+ // Liquid glass: the window itself is clear; a glass backdrop fills
+ // it and the PDF pages float on top (PDFView's own background is
+ // cleared below, so the margins/gutters show glass, not gray).
+ panel.isOpaque = false
+ panel.backgroundColor = .clear
+
+ let content = panel.contentView!
+ let backdrop = PdfWindowController.makeGlassBackdrop()
+ backdrop.translatesAutoresizingMaskIntoConstraints = false
+ content.addSubview(backdrop)
+
+ pdfView.document = document
+ pdfView.autoScales = true
+ pdfView.displayMode = .singlePageContinuous
+ pdfView.displaysPageBreaks = true
+ pdfView.pageBreakMargins = NSEdgeInsets(top: 6, left: 0, bottom: 6, right: 0)
+ pdfView.translatesAutoresizingMaskIntoConstraints = false
+ pdfView.backgroundColor = .clear
+ content.addSubview(pdfView)
+
+ NSLayoutConstraint.activate([
+ backdrop.topAnchor.constraint(equalTo: content.topAnchor),
+ backdrop.bottomAnchor.constraint(equalTo: content.bottomAnchor),
+ backdrop.leadingAnchor.constraint(equalTo: content.leadingAnchor),
+ backdrop.trailingAnchor.constraint(equalTo: content.trailingAnchor),
+ pdfView.topAnchor.constraint(equalTo: content.topAnchor),
+ pdfView.bottomAnchor.constraint(equalTo: content.bottomAnchor),
+ pdfView.leadingAnchor.constraint(equalTo: content.leadingAnchor),
+ pdfView.trailingAnchor.constraint(equalTo: content.trailingAnchor),
+ ])
+ installChip()
+ watchFile()
+ }
+
+ /// Liquid glass on macOS 26+, vibrancy on anything older. Both read the
+ /// desktop through the window, which is the whole point of the look.
+ private static func makeGlassBackdrop() -> NSView {
+ if #available(macOS 26.0, *) {
+ return NSGlassEffectView()
+ }
+ let vibrancy = NSVisualEffectView()
+ vibrancy.material = .underWindowBackground
+ vibrancy.blendingMode = .behindWindow
+ vibrancy.state = .active
+ return vibrancy
+ }
+
+ // MARK: chip — filename + the one escape hatch
+
+ private func installChip() {
+ let chip = NSVisualEffectView()
+ chip.material = .hudWindow
+ chip.blendingMode = .withinWindow
+ chip.state = .active
+ chip.wantsLayer = true
+ chip.layer?.cornerRadius = 8
+ chip.translatesAutoresizingMaskIntoConstraints = false
+
+ // the launching Claude session's sticky emoji leads the filename so
+ // a wall of preview panels reads back to its prompts at a glance
+ let label = (emoji.isEmpty ? "" : emoji + " ") + (path as NSString).lastPathComponent
+ let name = NSTextField(labelWithString: label)
+ name.font = .monospacedSystemFont(ofSize: 10.5, weight: .regular)
+ name.textColor = .secondaryLabelColor
+ name.lineBreakMode = .byTruncatingMiddle
+ name.translatesAutoresizingMaskIntoConstraints = false
+ name.toolTip = path
+
+ chip.addSubview(name)
+ // The chip must be IN the hierarchy before any chip↔content
+ // constraint activates, or AppKit throws (no common ancestor).
+ let content = panel.contentView!
+ content.addSubview(chip)
+ NSLayoutConstraint.activate([
+ chip.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -12),
+ chip.topAnchor.constraint(equalTo: content.topAnchor, constant: 10),
+ name.leadingAnchor.constraint(equalTo: chip.leadingAnchor, constant: 10),
+ name.trailingAnchor.constraint(equalTo: chip.trailingAnchor, constant: -10),
+ name.centerYAnchor.constraint(equalTo: chip.centerYAnchor),
+ name.widthAnchor.constraint(lessThanOrEqualToConstant: 280),
+ chip.heightAnchor.constraint(equalToConstant: 24),
+ ])
+ }
+
+ // MARK: live reload — xelatex/print loops rewrite the file in place
+
+ private func watchFile() {
+ let fd = Darwin.open(path, O_EVTONLY)
+ guard fd >= 0 else { return }
+ let source = DispatchSource.makeFileSystemObjectSource(
+ fileDescriptor: fd, eventMask: [.write, .rename, .delete, .extend],
+ queue: .main)
+ source.setEventHandler { [weak self] in self?.scheduleReload() }
+ source.setCancelHandler { Darwin.close(fd) }
+ source.resume()
+ monitor = source
+ }
+
+ /// Writers replace PDFs non-atomically (xelatex truncates then appends),
+ /// so debounce and re-arm rather than reloading per event.
+ private func scheduleReload() {
+ if reloadPending { return }
+ reloadPending = true
+ monitor?.cancel()
+ monitor = nil
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
+ guard let self = self else { return }
+ self.reloadPending = false
+ guard FileManager.default.fileExists(atPath: self.path) else { return }
+ let page = self.pdfView.currentPage.flatMap { self.pdfView.document?.index(for: $0) }
+ if let fresh = PDFDocument(url: URL(fileURLWithPath: self.path)) {
+ self.pdfView.document = fresh
+ if let page = page, page < fresh.pageCount, let p = fresh.page(at: page) {
+ self.pdfView.go(to: p)
+ }
+ }
+ self.watchFile()
+ }
+ }
+
+ // MARK: window plumbing
+
+ func focus() {
+ NSApp.activate(ignoringOtherApps: true)
+ panel.makeKeyAndOrderFront(nil)
+ }
+
+ func close() { panel.close() }
+
+ func windowWillClose(_ notification: Notification) {
+ monitor?.cancel()
+ monitor = nil
+ onClose()
+ }
+
+ /// Size the window to the first page's aspect, ~3/4 of the screen tall.
+ private static func idealFrame(for document: PDFDocument) -> NSRect {
+ let screen = NSScreen.main?.visibleFrame
+ ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
+ let bounds = document.page(at: 0)?.bounds(for: .mediaBox)
+ ?? NSRect(x: 0, y: 0, width: 612, height: 792)
+ let height = min(screen.height * 0.78, 980)
+ let width = min(height * (bounds.width / max(bounds.height, 1)), screen.width * 0.9)
+ return NSRect(x: screen.midX - width / 2, y: screen.midY - height / 2,
+ width: width, height: height)
+ }
+}
+
+/// A glance-pane, not an editor: any single click-drag moves the WINDOW
+/// (no text selection — jeffrey's call), except clicks on link annotations,
+/// which pass through so a PDF's hyperlinks keep working. Scrolling is
+/// unaffected (wheel/trackpad never enters mouseDown).
+private final class DraggablePdfView: PDFView {
+ override func mouseDown(with event: NSEvent) {
+ let viewPoint = convert(event.locationInWindow, from: nil)
+ if let page = page(for: viewPoint, nearest: false) {
+ let pagePoint = convert(viewPoint, to: page)
+ if let annotation = page.annotation(at: pagePoint),
+ annotation.type == "Link" || annotation.url != nil {
+ super.mouseDown(with: event)
+ return
+ }
+ }
+ window?.performDrag(with: event)
+ }
+}
+
+/// Titled-but-chromeless panel: key-able so scrolling and Esc work, and
+/// Esc (cancelOperation) closes — the "just glance and dismiss" contract.
+private final class PdfPanel: NSPanel {
+ override var canBecomeKey: Bool { true }
+ override var canBecomeMain: Bool { true }
+ override func cancelOperation(_ sender: Any?) { close() }
+}
+
+// MARK: - menu management (slab manages the viewers)
+
+extension AppDelegate {
+ @objc func focusPdf(_ sender: NSMenuItem) {
+ if let path = sender.representedObject as? String { PdfViewer.shared.focus(path) }
+ }
+
+ @objc func closeAllPdfs() { PdfViewer.shared.closeAll() }
+
+ @objc func openPdfFromPanel() {
+ NSApp.activate(ignoringOtherApps: true)
+ let panel = NSOpenPanel()
+ panel.allowedContentTypes = [.pdf]
+ panel.allowsMultipleSelection = true
+ if panel.runModal() == .OK {
+ for url in panel.urls { PdfViewer.shared.open(url.path) }
+ }
+ }
+}
diff --git a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift
--- a/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift
+++ b/slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift
@@ -8,6 +8,9 @@ struct PopRender {
var id: String
var type: String // "audio" | "illy" | "video"
var label: String
+ /// Claude session that launched the render (render-progress.mjs resolves
+ /// it from the process tree). Lets that session's row go pink/rendering.
+ var sessionId: String = ""
var pct: Int? // 0…100, or nil for an indeterminate render
var done: Int? // e.g. frame 142, panel 3
var total: Int? // e.g. of 240, of 11
@@ -254,6 +257,7 @@ out.append(PopRender(
id: (obj["id"] as? String) ?? url.lastPathComponent,
type: (obj["type"] as? String) ?? "render",
label: (obj["label"] as? String) ?? "",
+ sessionId: (obj["sessionId"] as? String) ?? "",
pct: (pctRaw is NSNull) ? nil : (pctRaw as? Int),
done: (doneRaw is NSNull) ? nil : (doneRaw as? Int),
total: (totalRaw is NSNull) ? nil : (totalRaw as? Int),
diff --git a/slab/menubar-swift/Sources/SlabMenubar/TitleEmoji.swift b/slab/menubar-swift/Sources/SlabMenubar/TitleEmoji.swift
new file mode 100644
--- /dev/null
+++ b/slab/menubar-swift/Sources/SlabMenubar/TitleEmoji.swift
@@ -0,0 +1,93 @@
+import Foundation
+
+/// Sticky per-session title emoji — the visual anchor that lets the eye
+/// re-find a window after the tiler shuffles the grid. Inferred once from
+/// the session's first real prompt (topic keyword → meaningful emoji),
+/// falling back to a hash-picked entry from a visually-distinct palette,
+/// and deduped across the live wall so no two sessions wear the same mark.
+/// Once assigned it never changes for the session's lifetime: stability is
+/// the whole point.
+///
+/// The cache is only ever touched from `refresh()`'s gather pass, which is
+/// serialized by AppDelegate's `gathering` guard — no locking needed.
+enum TitleEmoji {
+ /// sessionId → assigned emoji. Reaped as sessions disappear.
+ private static var cache: [String: String] = [:]
+
+ /// Topic rules, first match wins. Substring match on the lowercased
+ /// subject (the first prompt's opening 140 chars). Every emoji here has
+ /// default emoji presentation (no U+FE0F variation selector) so it
+ /// renders as a color glyph in window title bars and the menubar alike.
+ private static let rules: [(keys: [String], emoji: String)] = [
+ (["slab", "menubar", "tile", "iterm", "terminal"], "🧱"),
+ (["mail", "email", "inbox", "draft"], "📨"),
+ (["paper", "latex", "arxiv", "dossier"], "📚"),
+ (["song", "music", "track", "melody", "vocal", "synth", "mix", "audio", "mp3"], "🎶"),
+ (["video", "mp4", "ffmpeg", "motion", "film", "seedance"], "🎬"),
+ (["oven", "ota", "deploy", "release", "ship", "publish"], "🔥"),
+ (["bug", "fix", "debug", "crash", "broken", "error"], "🐛"),
+ (["test", "spec", "verify"], "🧪"),
+ (["kidlisp", "lisp"], "🐢"),
+ (["piece", "disk", "prompt"], "🧩"),
+ (["shop", "stripe", "invoice", "grant", "tax", "money"], "💸"),
+ (["server", "lith", "droplet", "ssh", "dns", "cdn", "domain"], "🌐"),
+ (["wallpaper", "image", "photo", "cover", "illy"], "📷"),
+ (["fedac", "native", "kernel", "boot"], "💾"),
+ (["git", "commit", "push", "branch", "merge", "rebase"], "🌿"),
+ (["readme", "notes", "memo", "write", "doc"], "📝"),
+ (["chat", "imsg", "signal", "message"], "💬"),
+ (["notepat", "menuband", "piano", "instrument"], "🎹"),
+ ]
+
+ /// Fallback marks: fruits, creatures, and objects chosen to stay
+ /// distinguishable at title-bar size and from across the room.
+ private static let palette = [
+ "🍉", "🫐", "🍋", "🍇", "🥝", "🍑", "🍒", "🌵", "🍄", "🌊",
+ "🌙", "🪐", "🔮", "🧊", "🐠", "🦜", "🐸", "🦊", "🐙", "🐝",
+ "🌻", "🍩", "🎈", "🚀", "🛸", "🪩", "🎲", "🌈",
+ ]
+
+ /// Stamp every live session with its sticky emoji. Blank sessions stay
+ /// unmarked (their first real prompt does the inferring — assigning off
+ /// an empty subject would lock in a meaningless fallback). Assignment
+ /// iterates in sessionId order so a menubar restart converges on the
+ /// same wall.
+ static func assign(_ sessions: [ClaudeSession]) -> [ClaudeSession] {
+ let liveIds = Set(sessions.map { $0.sessionId })
+ cache = cache.filter { liveIds.contains($0.key) }
+ for s in sessions.sorted(by: { $0.sessionId < $1.sessionId }) {
+ guard s.state != .blank, cache[s.sessionId] == nil else { continue }
+ cache[s.sessionId] = infer(
+ subject: s.subject,
+ sessionId: s.sessionId,
+ taken: Set(cache.values)
+ )
+ }
+ return sessions.map { s in
+ var out = s
+ out.emoji = cache[s.sessionId] ?? ""
+ return out
+ }
+ }
+
+ private static func infer(subject: String, sessionId: String, taken: Set) -> String {
+ let lower = subject.lowercased()
+ for rule in rules where rule.keys.contains(where: { lower.contains($0) }) {
+ if !taken.contains(rule.emoji) { return rule.emoji }
+ break // topic mark already on the wall — distinctness beats meaning
+ }
+ // FNV-1a over the sessionId: Swift's hashValue is seed-randomized
+ // per launch, and the pick must survive a menubar restart.
+ var h: UInt64 = 0xcbf2_9ce4_8422_2325
+ for b in sessionId.utf8 {
+ h ^= UInt64(b)
+ h = h &* 0x1_0000_0001_b3
+ }
+ var i = Int(h % UInt64(palette.count))
+ for _ in 0..28 live sessions: collisions are forgivable
+ }
+}
diff --git a/slab/menubar-swift/Sources/SlabMenubar/VideoViewer.swift b/slab/menubar-swift/Sources/SlabMenubar/VideoViewer.swift
new file mode 100644
--- /dev/null
+++ b/slab/menubar-swift/Sources/SlabMenubar/VideoViewer.swift
@@ -0,0 +1,288 @@
+// VideoViewer — slab's minimal video player, so "watch this render" doesn't
+// mean QuickTime. The PdfViewer contract, but for movies: a chromeless
+// glass panel with an AVPlayerView (floating controls), a thin chip with
+// the filename and a single "QuickTime" escape hatch, Esc or ⌘W to dismiss.
+// Playback starts immediately.
+//
+// How it gets asked: anything (Claude, a render script, the menu) appends
+// an absolute path per line to $SLAB_HOME/state/open-video; the menubar's
+// 2 s tick consumes the file on the main thread (tiny stat — no shell-outs,
+// per slab-menubar-perf). The `slab-video` wrapper in slab/bin does exactly
+// that. Re-requesting an open path brings its window forward and restarts
+// playback. The viewer watches the file and reloads on rewrite, so a video
+// re-render loop (build.mjs → same mp4 path) feels live.
+import AppKit
+import AVKit
+import AVFoundation
+
+extension Paths {
+ /// One absolute video path per line; consumed (deleted) each tick.
+ static var videoRequestFile: String { "\(slabHome)/state/open-video" }
+}
+
+final class VideoViewer {
+ static let shared = VideoViewer()
+ private var controllers: [String: VideoWindowController] = [:]
+
+ var openPaths: [String] { controllers.keys.sorted() }
+
+ /// Called from the main-thread side of AppDelegate.refresh() every tick.
+ /// Request lines are "path\tsession_id" (the session id may be empty);
+ /// `emojiFor` maps a live Claude session to its sticky TitleEmoji so the
+ /// chip wears the mark of the prompt that asked.
+ func consumeRequests(emojiFor: (String) -> String = { _ in "" }) {
+ let file = Paths.videoRequestFile
+ guard FileManager.default.fileExists(atPath: file) else { return }
+ let text = (try? String(contentsOfFile: file, encoding: .utf8)) ?? ""
+ try? FileManager.default.removeItem(atPath: file)
+ for line in text.split(separator: "\n") {
+ let parts = line.split(separator: "\t", maxSplits: 1, omittingEmptySubsequences: false)
+ let path = parts[0].trimmingCharacters(in: .whitespaces)
+ let sid = parts.count > 1 ? parts[1].trimmingCharacters(in: .whitespaces) : ""
+ if !path.isEmpty { open(path, emoji: sid.isEmpty ? "" : emojiFor(sid)) }
+ }
+ }
+
+ func open(_ rawPath: String, emoji: String = "") {
+ let path = (rawPath as NSString).expandingTildeInPath
+ if let existing = controllers[path] { existing.focusAndRestart(); return }
+ guard FileManager.default.fileExists(atPath: path),
+ let controller = VideoWindowController(path: path, emoji: emoji, onClose: { [weak self] in
+ self?.controllers.removeValue(forKey: path)
+ })
+ else { return }
+ controllers[path] = controller
+ controller.focus()
+ }
+
+ func focus(_ path: String) { controllers[path]?.focus() }
+
+ func closeAll() {
+ // close() triggers onClose which mutates the dictionary — iterate a copy.
+ for controller in Array(controllers.values) { controller.close() }
+ }
+}
+
+/// One panel per movie. Owns the AVPlayerView, the chip, and a file monitor
+/// that reloads the player item when the file is rewritten on disk.
+private final class VideoWindowController: NSObject, NSWindowDelegate {
+ private let path: String
+ private let emoji: String
+ private let panel: VideoPanel
+ private let playerView = AVPlayerView()
+ private let player = AVPlayer()
+ private let onClose: () -> Void
+ private var monitor: DispatchSourceFileSystemObject?
+ private var reloadPending = false
+
+ init?(path: String, emoji: String = "", onClose: @escaping () -> Void) {
+ self.path = path
+ self.emoji = emoji
+ self.onClose = onClose
+
+ let asset = AVURLAsset(url: URL(fileURLWithPath: path))
+ panel = VideoPanel(
+ contentRect: VideoWindowController.idealFrame(for: asset),
+ styleMask: [.titled, .closable, .resizable, .fullSizeContentView],
+ backing: .buffered, defer: false)
+ super.init()
+
+ panel.titleVisibility = .hidden
+ panel.titlebarAppearsTransparent = true
+ panel.isMovableByWindowBackground = true
+ panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
+ panel.standardWindowButton(.zoomButton)?.isHidden = true
+ panel.isFloatingPanel = false
+ panel.hidesOnDeactivate = false
+ panel.isReleasedWhenClosed = false
+ panel.delegate = self
+ panel.title = (path as NSString).lastPathComponent // for Mission Control, not chrome
+ panel.isOpaque = false
+ panel.backgroundColor = .black
+
+ let content = panel.contentView!
+ player.replaceCurrentItem(with: AVPlayerItem(asset: asset))
+ playerView.player = player
+ playerView.controlsStyle = .floating
+ playerView.showsFullScreenToggleButton = false
+ playerView.translatesAutoresizingMaskIntoConstraints = false
+ content.addSubview(playerView)
+
+ NSLayoutConstraint.activate([
+ playerView.topAnchor.constraint(equalTo: content.topAnchor),
+ playerView.bottomAnchor.constraint(equalTo: content.bottomAnchor),
+ playerView.leadingAnchor.constraint(equalTo: content.leadingAnchor),
+ playerView.trailingAnchor.constraint(equalTo: content.trailingAnchor),
+ ])
+ installChip()
+ watchFile()
+ player.play()
+ }
+
+ // MARK: chip — filename + the one escape hatch
+
+ private func installChip() {
+ let chip = NSVisualEffectView()
+ chip.material = .hudWindow
+ chip.blendingMode = .withinWindow
+ chip.state = .active
+ chip.wantsLayer = true
+ chip.layer?.cornerRadius = 8
+ chip.translatesAutoresizingMaskIntoConstraints = false
+
+ // the launching Claude session's sticky emoji leads the filename so
+ // a wall of preview panels reads back to its prompts at a glance
+ let label = (emoji.isEmpty ? "" : emoji + " ") + (path as NSString).lastPathComponent
+ let name = NSTextField(labelWithString: label)
+ name.font = .monospacedSystemFont(ofSize: 10.5, weight: .regular)
+ name.textColor = .secondaryLabelColor
+ name.lineBreakMode = .byTruncatingMiddle
+ name.translatesAutoresizingMaskIntoConstraints = false
+ name.toolTip = path
+
+ let escape = NSButton(title: "QuickTime", target: self, action: #selector(openInQuickTime))
+ escape.bezelStyle = .inline
+ escape.controlSize = .small
+ escape.font = .monospacedSystemFont(ofSize: 10, weight: .regular)
+ escape.translatesAutoresizingMaskIntoConstraints = false
+
+ chip.addSubview(name)
+ chip.addSubview(escape)
+ // The chip must be IN the hierarchy before any chip↔content
+ // constraint activates, or AppKit throws (no common ancestor).
+ let content = panel.contentView!
+ content.addSubview(chip)
+ NSLayoutConstraint.activate([
+ chip.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -12),
+ chip.topAnchor.constraint(equalTo: content.topAnchor, constant: 10),
+ name.leadingAnchor.constraint(equalTo: chip.leadingAnchor, constant: 10),
+ name.centerYAnchor.constraint(equalTo: chip.centerYAnchor),
+ name.widthAnchor.constraint(lessThanOrEqualToConstant: 240),
+ escape.leadingAnchor.constraint(equalTo: name.trailingAnchor, constant: 8),
+ escape.trailingAnchor.constraint(equalTo: chip.trailingAnchor, constant: -8),
+ escape.centerYAnchor.constraint(equalTo: chip.centerYAnchor),
+ chip.heightAnchor.constraint(equalToConstant: 24),
+ ])
+ }
+
+ @objc private func openInQuickTime() {
+ NSWorkspace.shared.open(
+ [URL(fileURLWithPath: path)],
+ withApplicationAt: URL(fileURLWithPath: "/System/Applications/QuickTime Player.app"),
+ configuration: NSWorkspace.OpenConfiguration())
+ close()
+ }
+
+ // MARK: live reload — render loops rewrite the mp4 in place
+
+ private func watchFile() {
+ let fd = Darwin.open(path, O_EVTONLY)
+ guard fd >= 0 else { return }
+ let source = DispatchSource.makeFileSystemObjectSource(
+ fileDescriptor: fd, eventMask: [.write, .rename, .delete, .extend],
+ queue: .main)
+ source.setEventHandler { [weak self] in self?.scheduleReload() }
+ source.setCancelHandler { Darwin.close(fd) }
+ source.resume()
+ monitor = source
+ }
+
+ /// Encoders replace movies non-atomically (ffmpeg writes then moves),
+ /// so debounce well past the last event before reloading from the top.
+ private func scheduleReload() {
+ if reloadPending { return }
+ reloadPending = true
+ monitor?.cancel()
+ monitor = nil
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
+ guard let self = self else { return }
+ self.reloadPending = false
+ guard FileManager.default.fileExists(atPath: self.path) else { return }
+ let asset = AVURLAsset(url: URL(fileURLWithPath: self.path))
+ self.player.replaceCurrentItem(with: AVPlayerItem(asset: asset))
+ self.player.play()
+ self.watchFile()
+ }
+ }
+
+ // MARK: window plumbing
+
+ func focus() {
+ NSApp.activate(ignoringOtherApps: true)
+ panel.makeKeyAndOrderFront(nil)
+ }
+
+ func focusAndRestart() {
+ focus()
+ player.seek(to: .zero)
+ player.play()
+ }
+
+ func close() { panel.close() }
+
+ func windowWillClose(_ notification: Notification) {
+ monitor?.cancel()
+ monitor = nil
+ player.pause()
+ onClose()
+ }
+
+ /// Size the window to the video's aspect, ~3/4 of the screen tall for
+ /// portrait, ~2/3 wide for landscape.
+ private static func idealFrame(for asset: AVURLAsset) -> NSRect {
+ let screen = NSScreen.main?.visibleFrame
+ ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
+ var size = NSSize(width: 1080, height: 1920)
+ if let track = asset.tracks(withMediaType: .video).first {
+ let natural = track.naturalSize.applying(track.preferredTransform)
+ size = NSSize(width: abs(natural.width), height: abs(natural.height))
+ }
+ let aspect = size.width / max(size.height, 1)
+ var height = min(screen.height * 0.78, 980)
+ var width = height * aspect
+ if width > screen.width * 0.85 {
+ width = screen.width * 0.85
+ height = width / max(aspect, 0.001)
+ }
+ return NSRect(x: screen.midX - width / 2, y: screen.midY - height / 2,
+ width: width, height: height)
+ }
+}
+
+/// Titled-but-chromeless panel: key-able so the player controls and Esc
+/// work, and Esc (cancelOperation) closes — the "just glance and dismiss"
+/// contract. Space toggles play/pause like QuickTime.
+private final class VideoPanel: NSPanel {
+ override var canBecomeKey: Bool { true }
+ override var canBecomeMain: Bool { true }
+ override func cancelOperation(_ sender: Any?) { close() }
+ override func keyDown(with event: NSEvent) {
+ if event.charactersIgnoringModifiers == " ",
+ let playerView = contentView?.subviews.compactMap({ $0 as? AVPlayerView }).first,
+ let player = playerView.player {
+ player.rate == 0 ? player.play() : player.pause()
+ return
+ }
+ super.keyDown(with: event)
+ }
+}
+
+// MARK: - menu management (slab manages the viewers)
+
+extension AppDelegate {
+ @objc func focusVideo(_ sender: NSMenuItem) {
+ if let path = sender.representedObject as? String { VideoViewer.shared.focus(path) }
+ }
+
+ @objc func closeAllVideos() { VideoViewer.shared.closeAll() }
+
+ @objc func openVideoFromPanel() {
+ NSApp.activate(ignoringOtherApps: true)
+ let panel = NSOpenPanel()
+ panel.allowedContentTypes = [.movie, .mpeg4Movie, .quickTimeMovie]
+ panel.allowsMultipleSelection = true
+ if panel.runModal() == .OK {
+ for url in panel.urls { VideoViewer.shared.open(url.path) }
+ }
+ }
+}
diff --git a/system/public/aesthetic.computer/disks/prompt.mjs b/system/public/aesthetic.computer/disks/prompt.mjs
--- a/system/public/aesthetic.computer/disks/prompt.mjs
+++ b/system/public/aesthetic.computer/disks/prompt.mjs
@@ -3048,6 +3048,106 @@ packProgress = null;
makeFlash($);
return true;
+ } else if (text.startsWith("packjs ")) {
+ // Generate aesthetic.computer.js — a frozen single-file JS *library*
+ // version of the AC runtime (mirrors `pack`, but emits a .js library
+ // instead of a self-contained HTML document).
+ const pieceCode = params[0];
+ if (!pieceCode) {
+ notice("Usage: packjs $code or packjs piece", ["red"]);
+ flashColor = [255, 0, 0];
+ makeFlash($);
+ return true;
+ }
+
+ const isKidlisp = pieceCode.startsWith("$");
+ const code = isKidlisp ? pieceCode.slice(1) : pieceCode;
+ const displayName = isKidlisp ? `$${code}` : code;
+
+ const timeline = makePackTimeline();
+ advancePackStep(timeline, "fetch", `Fetching ${displayName}...`);
+ packProgress = { timeline, startTime: performance.now(), code: displayName };
+ needsPaint();
+
+ try {
+ // Hit the oven directly (same as `pack`) to avoid proxy timeouts/SSE buffering.
+ const bundleParam = isKidlisp ? `code=$${code}` : `piece=${code}`;
+ const response = await fetch(`https://oven.aesthetic.computer/packjs?${bundleParam}&format=stream`);
+ if (!response.ok) throw new Error(`PackJS API returned ${response.status}`);
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let result = null;
+ let currentEventType = null;
+ let serverError = null;
+
+ const parseSSELines = (lines) => {
+ for (const line of lines) {
+ if (line.startsWith("event: ")) {
+ currentEventType = line.slice(7);
+ } else if (line.startsWith("data: ") && currentEventType) {
+ if (currentEventType === "error") {
+ try { serverError = JSON.parse(line.slice(6)).error || "Unknown server error"; }
+ catch (e) { serverError = line.slice(6); }
+ currentEventType = null;
+ continue;
+ }
+ try {
+ const data = JSON.parse(line.slice(6));
+ if (currentEventType === "progress") {
+ if (data.stage) advancePackStep(packProgress.timeline, data.stage, data.message);
+ needsPaint();
+ } else if (currentEventType === "complete") {
+ result = data;
+ advancePackStep(packProgress.timeline, "complete", "Done!");
+ finalizePackTimeline(packProgress.timeline);
+ needsPaint();
+ }
+ } catch (parseErr) {
+ console.warn("SSE parse error:", parseErr, "line:", line.slice(0, 100));
+ }
+ currentEventType = null;
+ }
+ }
+ };
+
+ const PACK_TIMEOUT = 120_000;
+ const timeoutId = setTimeout(() => reader.cancel(), PACK_TIMEOUT);
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) { buffer += decoder.decode(); break; }
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+ parseSSELines(lines);
+ if (serverError) break;
+ }
+ } finally {
+ clearTimeout(timeoutId);
+ }
+
+ if (buffer.trim()) parseSSELines(buffer.split("\n"));
+
+ if (serverError) throw new Error(serverError);
+ if (!result) throw new Error("No result received from packjs API");
+
+ const jsContent = atob(result.content);
+ download(result.filename, jsContent, { type: "application/javascript" });
+
+ notice("Downloaded " + result.filename + " (" + result.sizeKB + "KB)", ["lime"]);
+ flashColor = [0, 255, 0];
+ } catch (err) {
+ console.error("PackJS error:", err);
+ notice("PackJS failed: " + err.message, ["red"]);
+ flashColor = [255, 0, 0];
+ }
+
+ packProgress = null;
+ makeFlash($);
+ return true;
} else if (text.startsWith("m4d ") || text.startsWith("4d ")) {
// Generate an offline Max for Live device (.amxd) for any piece
const pieceRef = params[0];
diff --git a/toolchain/email/style-guide.md b/toolchain/email/style-guide.md
--- a/toolchain/email/style-guide.md
+++ b/toolchain/email/style-guide.md
@@ -7,6 +7,8 @@
## Defaults
- keep subjects and body copy all lowercase by default
+- URLs are never lowercased — Drive file ids and similar are
+ case-sensitive, and a lowercased link is a dead link
- sign emails as `@jeffrey`
- append signature automatically if missing
diff --git a/toolchain/macos/SCORE.md b/toolchain/macos/SCORE.md
--- a/toolchain/macos/SCORE.md
+++ b/toolchain/macos/SCORE.md
@@ -101,6 +101,25 @@ `du -sh` without sudo undercounts some root-owned dirs (notably
`/Library/Developer/CoreSimulator` — reports ~6 GB when actually
35 GB). Cross-check with `simctl runtime list`.
+### When deleting frees NOTHING — APFS local snapshots
+
+If you `rm` gigabytes and `df` doesn't budge (or the volume is at literally
+**0 bytes** and even Claude Code's task-output writes fail with `ENOSPC`), the
+freed blocks are pinned by **APFS local Time Machine snapshots**. Deletes don't
+reclaim space until the snapshots release it. Fix without sudo:
+
+```bash
+tmutil thinlocalsnapshots / 21474836480 4 # urgency 4 = aggressive; frees ~target bytes
+tmutil listlocalsnapshots / # confirm they're gone
+```
+
+(`tmutil deletelocalsnapshots /` may silently need sudo and appear to no-op;
+`thinlocalsnapshots ... 4` worked unprivileged 2026-06-15.) When the disk is so
+full Bash can't even capture output, redirect to a file and Read it:
+`cmd > /tmp/probe.txt 2>&1` then Read the file. After thinning, THEN clear the
+buckets below — and check `/System/Volumes/Data` (not `/`, the sealed system
+snapshot) for the real usage.
+
### Safe regenerable buckets
Always clear first — fully recover with no judgment call:
@@ -116,6 +135,10 @@ - `~/Library/Caches/pip` — pip download cache
- `~/Library/Messages/Caches` — iMessage media cache (~1 GB,
regenerates from iCloud)
- `npm cache clean --force` — npm content-addressed cache
+- `~/.cache/huggingface/hub/models--*` — model weights, redownloadable
+ but check first: gemma-4-e2b is the ACTIVE local MLX model (keep);
+ sdxl-turbo was a 13 GB dormant experiment (deleted 2026-06-11)
+- `brew cleanup --prune=all` — Homebrew download cache (~200 MB)
### Slab session recordings — trim by age
@@ -177,6 +200,9 @@ attachments + conversation history
- `~/Pictures/Photos Library.photoslibrary` — Apple Photos
- `~/.ac-instagram-profile` / `~/.distrokid-profile` — Chromium
profiles for IG / DK automation; deleting logs you out
+- `~/Developer/fuser-*` — fuser client git worktrees (~3.3 GB each,
+ ~23 GB total as of 2026-06-11); may hold uncommitted client work —
+ ask @jeffrey before pruning even merged-looking ones
### Gotcha — the auto-classifier blocks batched rm