Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

notepat: drum as a voice, fix noise synth alias, add piece-logs CLI

- notepat: "drum" joins the wavetypes rotation. When selected, every
note (both octaves, any input source) fires a one-shot from the
shared 12-drum kit in lib/percussion.mjs. Removed the separate drm
button, drumMode state, and the OS-bar drum slot — one less toggle
to reason about and the top bar stays uncluttered.

- synth: alias "noise" → "noise-white" in both lib/sound/synth.mjs and
lib/speaker-bundled.mjs, mirroring fedac/native/src/js-bindings.c
which has accepted both strings all along. Without this the shared
percussion kit dropped every snare / hat / clap / etc. on the web,
because those voices are built from bare type: "noise".

- piece-logs: new system/backend/piece-logs-cli.mjs (ships with lith
on every deploy) plus a fish wrapper at
dotfiles/fish/functions/ac-piece-logs.fish with --slug / --events /
--errors-only / --grep / --json. SCORE.md gets a "Piece-Log
Debugging" section explaining what /api/piece-log captures and how
to query the MongoDB piece-runs collection from the CLI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+278 -55
+27
SCORE.md
··· 174 174 - `ac-dev-logs` — View all dev logs 175 175 - `ac-dev-log-clean` — Clean old logs 176 176 - `ac-dev-log-new` — Create new log 177 + - `ac-piece-logs [slug]` — Recent piece-run telemetry (see [Piece-Log Debugging](#piece-log-debugging-client-side-errors)) 178 + - `ac-piece-logs-events [slug]` — Include captured `console.log`/`warn`/`error` output 179 + - `ac-piece-logs-errors` — Runs with `status=error` in the last 60 minutes 180 + - `ac-piece-logs-grep <regex>` — Search console-event text across recent runs 177 181 178 182 #### Deployment & Distribution 179 183 - `ac-pack` — Package for distribution ··· 236 240 237 241 **Notation:** 238 242 - compush — commit, push 243 + 244 + ### Piece-Log Debugging (client-side errors) 245 + 246 + Every piece load gets a fresh `pieceId` and a 2-second-batched wrapper around `console.log` / `warn` / `error` / `info` is installed in [`system/public/aesthetic.computer/lib/disk.mjs`](system/public/aesthetic.computer/lib/disk.mjs#L970) (~line 970). Events are POSTed to `/api/piece-log` ([`netlify/functions/piece-log.mjs`](system/netlify/functions/piece-log.mjs)) and stored in MongoDB in the `piece-runs` collection with phases `start` / `log` / `error` / `complete`. 247 + 248 + This is the primary debug channel for problems you can't reproduce locally — silent synth failures, "worked for me but not for the user" bugs, hydration issues on specific hosts. Each record carries: 249 + 250 + - `pieceId`, `slug`, `bootId`, `userAgent`, `host`, geo (from CF headers) 251 + - `events[]` — the captured console output with `{level, at, elapsed, message}`, last 500 per run 252 + - `error` — if the piece crashed, `{message, stack}` 253 + - `summary` — on clean exit, `{duration, ...}` 254 + 255 + **Inspecting from the CLI** (SSHes to lith, runs [`system/backend/piece-logs-cli.mjs`](system/backend/piece-logs-cli.mjs) against the deployed env): 256 + 257 + ```fish 258 + ac-piece-logs notepat # recent 20 runs of a slug 259 + ac-piece-logs-events notepat --since 30 # include console events, last 30 min 260 + ac-piece-logs-errors # status=error runs in the last hour 261 + ac-piece-logs-grep "drumMode" # full-text search across captured events 262 + ac-piece-logs-json --slug notepat | jq # raw JSON for scripting 263 + ``` 264 + 265 + The CLI ships with every `fish lith/deploy.fish`. If you add new telemetry, bump the payload in `disk.mjs` and the phase handler in `netlify/functions/piece-log.mjs`; no schema migration needed (MongoDB collection is schemaless). 239 266 240 267 ### Keeps Market Stats (Tezos / Objkt) 241 268
+89
dotfiles/fish/functions/ac-piece-logs.fish
··· 1 + #!/usr/bin/env fish 2 + # ac-piece-logs — Inspect per-piece runtime telemetry (client console capture) 3 + # stored in MongoDB `piece-runs` by /api/piece-log. 4 + # 5 + # Auth: SSHes to lith (same SSH key as lith/deploy.fish) and runs 6 + # system/backend/piece-logs-cli.mjs with the deployed env loaded. 7 + # The piece-logs CLI ships with lith on every deploy. 8 + 9 + set -l LITH_HOST lith.aesthetic.computer 10 + set -l LITH_USER root 11 + set -l REMOTE_DIR /opt/ac 12 + 13 + # Find the vault SSH key relative to wherever aesthetic-computer lives. 14 + # Matches the logic in lith/deploy.fish. 15 + function _ac_piece_logs_ssh_key 16 + for candidate in \ 17 + "$HOME/aesthetic-computer-vault/home/.ssh/id_rsa" \ 18 + "/workspaces/aesthetic-computer-vault/home/.ssh/id_rsa" \ 19 + "$AESTHETIC_VAULT/home/.ssh/id_rsa" 20 + if test -n "$candidate" -a -f "$candidate" 21 + echo $candidate 22 + return 0 23 + end 24 + end 25 + return 1 26 + end 27 + 28 + function _ac_piece_logs_run --no-scope-shadowing 29 + set -l key (_ac_piece_logs_ssh_key) 30 + if test -z "$key" 31 + echo "❌ No vault SSH key found. Set AESTHETIC_VAULT or clone aesthetic-computer-vault beside this repo." >&2 32 + return 1 33 + end 34 + # Source the deployed env so MONGODB_CONNECTION_STRING / MONGODB_NAME 35 + # are available to the CLI, then run it with whatever args came in. 36 + ssh -i $key $LITH_USER@$LITH_HOST \ 37 + "set -a; source $REMOTE_DIR/system/.env; cd $REMOTE_DIR && node system/backend/piece-logs-cli.mjs $argv" 38 + end 39 + 40 + function ac-piece-logs --description "Recent piece-runs (default 20). Pass a slug to filter: ac-piece-logs notepat" 41 + if test (count $argv) -gt 0; and not string match -q -- '--*' $argv[1] 42 + _ac_piece_logs_run --slug $argv[1] $argv[2..-1] 43 + else 44 + _ac_piece_logs_run $argv 45 + end 46 + end 47 + 48 + function ac-piece-logs-events --description "Recent piece-runs with captured console events. ac-piece-logs-events notepat" 49 + if test (count $argv) -gt 0; and not string match -q -- '--*' $argv[1] 50 + _ac_piece_logs_run --events --slug $argv[1] $argv[2..-1] 51 + else 52 + _ac_piece_logs_run --events $argv 53 + end 54 + end 55 + 56 + function ac-piece-logs-errors --description "Piece-runs with status=error (default last 60m, 10 results)" 57 + _ac_piece_logs_run --errors-only --since 60 --limit 10 $argv 58 + end 59 + 60 + function ac-piece-logs-grep --description "Search console-event text across recent piece-runs. ac-piece-logs-grep 'drumMode'" 61 + if test (count $argv) -eq 0 62 + echo "Usage: ac-piece-logs-grep <regex> [extra flags...]" 63 + return 1 64 + end 65 + _ac_piece_logs_run --grep $argv[1] $argv[2..-1] 66 + end 67 + 68 + function ac-piece-logs-json --description "Raw JSON output of recent piece-runs" 69 + _ac_piece_logs_run --json $argv 70 + end 71 + 72 + function ac-piece-logs-help --description "Show piece-logs command help" 73 + echo "piece-logs — client-side console telemetry stored in MongoDB piece-runs" 74 + echo "" 75 + echo "Commands:" 76 + echo " ac-piece-logs [slug] — recent runs (optionally filtered by slug)" 77 + echo " ac-piece-logs-events [slug] — recent runs with captured console output" 78 + echo " ac-piece-logs-errors — runs with status=error (last 60m)" 79 + echo " ac-piece-logs-grep <regex> — runs whose events match regex" 80 + echo " ac-piece-logs-json — raw JSON for scripting" 81 + echo "" 82 + echo "Pass-through flags: --slug, --host, --status, --since, --limit, --events, --json" 83 + echo "" 84 + echo "Examples:" 85 + echo " ac-piece-logs notepat --limit 5" 86 + echo " ac-piece-logs-events notepat --since 30" 87 + echo " ac-piece-logs-errors" 88 + echo " ac-piece-logs-grep 'Invalid note'" 89 + end
+141
system/backend/piece-logs-cli.mjs
··· 1 + // piece-logs-cli.mjs — admin CLI to inspect piece-run telemetry. 2 + // 3 + // Runs on lith (has MONGODB_CONNECTION_STRING in env via 4 + // /opt/ac/system/.env). Reads the `piece-runs` collection populated 5 + // by the /api/piece-log endpoint (see netlify/functions/piece-log.mjs 6 + // and the console-capture wrapper near line 970 of 7 + // system/public/aesthetic.computer/lib/disk.mjs). 8 + // 9 + // Intended invocation: via the ac-piece-logs fish function which 10 + // SSHes to lith and runs `node system/backend/piece-logs-cli.mjs ...` 11 + // with args. Works locally too if your shell has MONGODB_CONNECTION_STRING 12 + // + MONGODB_NAME set (e.g. source /opt/ac/system/.env). 13 + // 14 + // Flags: 15 + // --slug <piece> filter by slug (e.g. notepat) 16 + // --host <hostname> filter by meta.host 17 + // --status <state> started | complete | error 18 + // --since <min> only runs updated in the last N minutes 19 + // --limit <n> cap results (default 20, max 200) 20 + // --events include the captured console events in each run 21 + // --errors-only shorthand for --status error 22 + // --grep <pattern> fetch --events then filter to runs whose events 23 + // include `pattern` (case-insensitive regex) 24 + // --json raw JSON (default is a human summary) 25 + // 26 + // Examples: 27 + // node system/backend/piece-logs-cli.mjs --slug notepat --limit 5 --events 28 + // node system/backend/piece-logs-cli.mjs --errors-only --since 60 29 + // node system/backend/piece-logs-cli.mjs --grep "drumMode" --limit 3 30 + 31 + import { connect } from "./database.mjs"; 32 + 33 + const args = process.argv.slice(2); 34 + const opts = { 35 + slug: null, 36 + host: null, 37 + status: null, 38 + since: null, 39 + limit: 20, 40 + events: false, 41 + grep: null, 42 + json: false, 43 + }; 44 + 45 + for (let i = 0; i < args.length; i++) { 46 + const a = args[i]; 47 + const take = () => args[++i]; 48 + if (a === "--slug") opts.slug = take(); 49 + else if (a === "--host") opts.host = take(); 50 + else if (a === "--status") opts.status = take(); 51 + else if (a === "--since") opts.since = Number(take()); 52 + else if (a === "--limit") opts.limit = Math.min(200, Math.max(1, Number(take()))); 53 + else if (a === "--events") opts.events = true; 54 + else if (a === "--errors-only") opts.status = "error"; 55 + else if (a === "--grep") { opts.grep = take(); opts.events = true; } 56 + else if (a === "--json") opts.json = true; 57 + else if (a === "-h" || a === "--help") { printHelp(); process.exit(0); } 58 + else { 59 + console.error(`Unknown flag: ${a}`); 60 + printHelp(); 61 + process.exit(2); 62 + } 63 + } 64 + 65 + function printHelp() { 66 + console.log(`piece-logs-cli — inspect piece-run telemetry 67 + 68 + Usage: node system/backend/piece-logs-cli.mjs [flags] 69 + 70 + Flags: 71 + --slug <piece> filter by slug (e.g. notepat) 72 + --host <hostname> filter by meta.host 73 + --status <state> started | complete | error 74 + --since <min> only runs updated in the last N minutes 75 + --limit <n> cap results (default 20, max 200) 76 + --events include captured console events 77 + --errors-only shorthand for --status error 78 + --grep <regex> filter to runs whose events match regex (implies --events) 79 + --json raw JSON output 80 + `); 81 + } 82 + 83 + const query = {}; 84 + if (opts.slug) query["meta.slug"] = opts.slug; 85 + if (opts.host) query["meta.host"] = opts.host; 86 + if (opts.status) query.status = opts.status; 87 + if (opts.since) query.updatedAt = { $gte: new Date(Date.now() - opts.since * 60_000) }; 88 + 89 + const projection = { _id: 0 }; 90 + if (!opts.events) projection.events = 0; 91 + 92 + async function main() { 93 + const database = await connect(); 94 + const runs = database.db.collection("piece-runs"); 95 + let results = await runs.find(query, { projection }).sort({ updatedAt: -1 }).limit(opts.limit).toArray(); 96 + await database.disconnect(); 97 + 98 + if (opts.grep) { 99 + const re = new RegExp(opts.grep, "i"); 100 + results = results.filter((run) => (run.events || []).some((ev) => re.test(ev.message || ""))); 101 + } 102 + 103 + if (opts.json) { 104 + console.log(JSON.stringify(results, null, 2)); 105 + return; 106 + } 107 + 108 + if (results.length === 0) { 109 + console.log("no matching runs"); 110 + return; 111 + } 112 + 113 + for (const r of results) { 114 + const when = r.updatedAt ? new Date(r.updatedAt).toISOString() : "?"; 115 + const status = r.status || "?"; 116 + const slug = r.meta?.slug || r.slug || "?"; 117 + const host = r.meta?.host || r.server?.country || ""; 118 + console.log(`── ${when} [${status.padEnd(8)}] ${slug.padEnd(20)} ${host}`); 119 + console.log(` pieceId: ${r.pieceId}`); 120 + if (r.meta?.userAgent) console.log(` ua: ${truncate(r.meta.userAgent, 100)}`); 121 + if (r.error) console.log(` error: ${r.error.message || JSON.stringify(r.error).slice(0, 200)}`); 122 + if (opts.events && r.events?.length) { 123 + console.log(` events: (${r.events.length})`); 124 + for (const ev of r.events.slice(-30)) { 125 + const elapsed = `${String(ev.elapsed ?? "?").padStart(6)}ms`; 126 + console.log(` ${elapsed} ${ev.level.padEnd(5)} ${truncate(ev.message, 200)}`); 127 + } 128 + } 129 + console.log(); 130 + } 131 + } 132 + 133 + function truncate(s, n) { 134 + s = String(s ?? ""); 135 + return s.length > n ? s.slice(0, n - 1) + "…" : s; 136 + } 137 + 138 + main().catch((err) => { 139 + console.error("piece-logs-cli failed:", err?.stack || err); 140 + process.exit(1); 141 + });
+12 -55
system/public/aesthetic.computer/disks/notepat.mjs
··· 385 385 "noise", // 4 - white noise filtered by pitch 386 386 "composite", // 5 387 387 "stample", // 6 388 + "drum", // 7 - shared 12-drum kit (lib/percussion.mjs), both octaves 388 389 ]; 389 390 let waveIndex = 0; // 0; 390 391 const STARTING_WAVE = wavetypes[waveIndex]; //"sine"; ··· 683 684 abletonBtn?.box?.x ?? Infinity, 684 685 waveBtn?.box?.x ?? Infinity, 685 686 octBtn?.box?.x ?? Infinity, 686 - drumBtn?.box?.x ?? Infinity, 687 687 ); 688 688 const rightEdge = Number.isFinite(leftmostButtonX) 689 689 ? leftmostButtonX - 3 ··· 1103 1103 1104 1104 // let qrcells; 1105 1105 1106 - let waveBtn, octBtn, osBtn, abletonBtn, drumBtn; 1107 - let drumMode = false; // When true, all note triggers play the shared drumkit instead. 1106 + let waveBtn, octBtn, osBtn, abletonBtn; 1107 + // 🥁 Drum kit lives as a wave type ("drum") in `wavetypes` — when selected, 1108 + // every note fires from lib/percussion.mjs instead of the pitched synth. 1108 1109 let slideBtn, roomBtn, glitchBtn, quickBtn; // Toggle buttons for slide/room/glitch/quick modes 1109 1110 let metroBtn, bpmMinusBtn, bpmPlusBtn; // Metronome controls 1110 1111 let melodyAliasBtn; ··· 1643 1644 "noise", 1644 1645 "stample", 1645 1646 "sample", 1647 + "drum", 1646 1648 ]; 1647 1649 const requestedWave = wavetypes.indexOf(colon[0]) > -1 ? colon[0] : wave; 1648 1650 wave = requestedWave === "sample" ? "stample" : requestedWave; ··· 1699 1701 buildWaveButton(api); 1700 1702 buildAbletonButton(api); 1701 1703 buildOsButton(api); 1702 - buildDrumButton(api); 1703 1704 buildToggleButtons(api); 1704 1705 buildMetronomeButtons(api); 1705 1706 ··· 4685 4686 ); 4686 4687 }); 4687 4688 4688 - drumBtn?.paint((btn) => { 4689 - const base = drumMode ? [90, 30, 30] : [30, 20, 40]; 4690 - const bright = drumMode ? [220, 110, 110] : [120, 120, 180]; 4691 - ink(btn.down ? [140, 60, 60] : base).box(btn.box); 4692 - if (btn.over && !btn.down) { 4693 - ink(255, 255, 255, 24).box(btn.box); 4694 - ink(255, 160, 160, 140).box(btn.box, "outline"); 4695 - } 4696 - ink(bright).line( 4697 - btn.box.x + btn.box.w, 4698 - btn.box.y, 4699 - btn.box.x + btn.box.w, 4700 - btn.box.y + btn.box.h - 1, 4701 - ); 4702 - ink(btn.down ? [255, 220, 220] : bright).write( 4703 - drumBtn.label || "drm", 4704 - { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, 4705 - undefined, undefined, false, "MatrixChunky8" 4706 - ); 4707 - }); 4708 - 4709 4689 waveBtn?.paint((btn) => { 4710 4690 ink(btn.down ? [40, 40, 100] : "darkblue").box( 4711 4691 btn.box.x, ··· 5958 5938 5959 5939 if (downs[note]) return false; 5960 5940 5961 - // 🥁 Drum mode: the upper octave (notes prefixed with "+" or "++") fires the 5962 - // shared 12-drum kit instead of a pitched note. Lower octave stays pitched so 5963 - // you can play melody + drums simultaneously. Mirrors the kitRight behaviour 5964 - // in fedac/native/pieces/notepat.mjs. 5965 - if (drumMode && note.startsWith("+") && soundContext) { 5941 + // 🥁 Drum voice: when the selected wave is "drum", every note (both octaves) 5942 + // fires a one-shot from the shared 12-drum kit in lib/percussion.mjs. Strip 5943 + // any octave prefix (++, +, -) and lowercase so "C", "+c", and "++c" all 5944 + // land on the same drum slot. The note's pan is derived from its key 5945 + // position like the pitched voices. 5946 + if (wave === "drum" && soundContext) { 5966 5947 const letter = note.replace(/^[+\-]+/, "").toLowerCase(); 5967 5948 const pan = getPanForButtonNote(note); 5968 5949 const volume = Math.max(0.1, velocity / 127); ··· 6133 6114 buildWaveButton(api); 6134 6115 buildAbletonButton(api); 6135 6116 buildOsButton(api); 6136 - buildDrumButton(api); 6137 6117 buildToggleButtons(api); 6138 6118 buildMetronomeButtons(api); 6139 6119 // Resize picture to quarter resolution (half width, half height) ··· 6235 6215 const topPianoEndX = topBarBase + topPianoWidth; 6236 6216 const vizLeft = topPianoEndX; // Start after piano 6237 6217 const vizRight = Math.min( 6238 - drumBtn?.box?.x ?? Infinity, 6239 6218 osBtn?.box?.x ?? Infinity, 6240 6219 abletonBtn?.box?.x ?? Infinity, 6241 6220 waveBtn?.box?.x ?? screen.width, ··· 6392 6371 buildWaveButton(api); 6393 6372 buildAbletonButton(api); 6394 6373 buildOsButton(api); 6395 - buildDrumButton(api); 6396 6374 } 6397 6375 6398 6376 // if (e.is("keyboard:down:shift") && !e.repeat) { ··· 7148 7126 buildWaveButton(api); 7149 7127 buildAbletonButton(api); 7150 7128 buildOsButton(api); 7151 - buildDrumButton(api); 7152 7129 }, 7153 7130 }); 7154 7131 ··· 7161 7138 buildWaveButton(api); 7162 7139 buildAbletonButton(api); 7163 7140 buildOsButton(api); 7164 - buildDrumButton(api); 7165 7141 }, 7166 7142 }); 7167 7143 ··· 7181 7157 }, 7182 7158 }); 7183 7159 7184 - drumBtn?.act(e, { 7185 - down: () => api.beep(400), 7186 - push: () => { 7187 - drumMode = !drumMode; 7188 - api.beep(drumMode ? 600 : 200); 7189 - console.log("🥁 drumMode:", drumMode ? "ON (upper octave → drum kit)" : "OFF (pitched)"); 7190 - }, 7191 - }); 7192 - 7193 7160 // 🎛️ Toggle button interactions 7194 7161 slideBtn?.act(e, { 7195 7162 push: () => { ··· 8291 8258 noise: "noi", 8292 8259 composite: "cmp", 8293 8260 stample: "stp", 8261 + drum: "drm", 8294 8262 }; 8295 8263 const displayWave = isNarrow ? (shortWaveNames[wave] || wave.slice(0, 3)) : wave; 8296 8264 const waveWidth = displayWave.length * glyphWidth; ··· 8342 8310 labels: { 8343 8311 ableton: "m4l", 8344 8312 os: "os", 8345 - drum: isNarrow ? "drm" : "drum", 8346 8313 }, 8347 8314 }; 8348 8315 } ··· 8363 8330 osBtn = new ui.Button(anchorX - w - OS_BAR_BTN_GAP, m.y, w, m.h); 8364 8331 osBtn.id = "os-button"; 8365 8332 osBtn.label = m.labels.os; 8366 - } 8367 - 8368 - function buildDrumButton({ ui, screen }) { 8369 - const m = osBarButtonMetrics({ screen }); 8370 - const w = m.labels.drum.length * m.glyph + m.padX * 2; 8371 - const anchorX = 8372 - osBtn?.box?.x ?? abletonBtn?.box?.x ?? (screen.width - OS_BAR_RIGHT_MARGIN); 8373 - drumBtn = new ui.Button(anchorX - w - OS_BAR_BTN_GAP, m.y, w, m.h); 8374 - drumBtn.id = "drum-button"; 8375 - drumBtn.label = m.labels.drum; 8376 8333 } 8377 8334 8378 8335 // Build metronome controls and toggle buttons with responsive layout
+6
system/public/aesthetic.computer/lib/sound/synth.mjs
··· 75 75 76 76 constructor({ type, id, options, duration, attack, decay, volume, pan }) { 77 77 // console.log("New Synth:", arguments); 78 + // 🌊 Accept "noise" as an alias for "noise-white" so code that targets 79 + // the native AC synth (fedac/native/src/js-bindings.c also aliases both 80 + // strings to WAVE_NOISE) plays correctly on the web. Without this the 81 + // shared drum kit in lib/percussion.mjs falls through every noise 82 + // branch and silently drops snares/hats/claps/etc. 83 + if (type === "noise") type = "noise-white"; 78 84 this.type = type; 79 85 if (id === undefined || id === null || id === NaN) 80 86 console.warn("⏰ No id for sound:", id, type);
+3
system/public/aesthetic.computer/lib/speaker-bundled.mjs
··· 432 432 #customBufferSize = 1024; 433 433 // Size of the streaming buffer 434 434 constructor({ type, id, options, duration, attack, decay, volume, pan }) { 435 + // 🌊 Alias "noise" → "noise-white" to match fedac/native/src/js-bindings.c 436 + // so shared percussion (lib/percussion.mjs) plays correctly on the web. 437 + if (type === "noise") type = "noise-white"; 435 438 this.type = type; 436 439 if (id === void 0 || id === null || id === NaN) 437 440 console.warn("\u23F0 No id for sound:", id, type);