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