diff --git a/SCORE.md b/SCORE.md index f65599d089..a133659255 100644 --- a/SCORE.md +++ b/SCORE.md @@ -174,6 +174,10 @@ The development environment uses Emacs with named terminal buffers. Use Emacs MC - `ac-dev-logs` — View all dev logs - `ac-dev-log-clean` — Clean old logs - `ac-dev-log-new` — Create new log +- `ac-piece-logs [slug]` — Recent piece-run telemetry (see [Piece-Log Debugging](#piece-log-debugging-client-side-errors)) +- `ac-piece-logs-events [slug]` — Include captured `console.log`/`warn`/`error` output +- `ac-piece-logs-errors` — Runs with `status=error` in the last 60 minutes +- `ac-piece-logs-grep ` — Search console-event text across recent runs #### Deployment & Distribution - `ac-pack` — Package for distribution @@ -237,6 +241,29 @@ ac-restart # Restart AC services only **Notation:** - compush — commit, push +### Piece-Log Debugging (client-side errors) + +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`. + +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: + +- `pieceId`, `slug`, `bootId`, `userAgent`, `host`, geo (from CF headers) +- `events[]` — the captured console output with `{level, at, elapsed, message}`, last 500 per run +- `error` — if the piece crashed, `{message, stack}` +- `summary` — on clean exit, `{duration, ...}` + +**Inspecting from the CLI** (SSHes to lith, runs [`system/backend/piece-logs-cli.mjs`](system/backend/piece-logs-cli.mjs) against the deployed env): + +```fish +ac-piece-logs notepat # recent 20 runs of a slug +ac-piece-logs-events notepat --since 30 # include console events, last 30 min +ac-piece-logs-errors # status=error runs in the last hour +ac-piece-logs-grep "drumMode" # full-text search across captured events +ac-piece-logs-json --slug notepat | jq # raw JSON for scripting +``` + +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). + ### Keeps Market Stats (Tezos / Objkt) Use this flow for live Keeps market checks (`jas.tez`, `keeps.tez`, contract-level stats). diff --git a/dotfiles/fish/functions/ac-piece-logs.fish b/dotfiles/fish/functions/ac-piece-logs.fish new file mode 100644 index 0000000000..c196319e60 --- /dev/null +++ b/dotfiles/fish/functions/ac-piece-logs.fish @@ -0,0 +1,89 @@ +#!/usr/bin/env fish +# ac-piece-logs — Inspect per-piece runtime telemetry (client console capture) +# stored in MongoDB `piece-runs` by /api/piece-log. +# +# Auth: SSHes to lith (same SSH key as lith/deploy.fish) and runs +# system/backend/piece-logs-cli.mjs with the deployed env loaded. +# The piece-logs CLI ships with lith on every deploy. + +set -l LITH_HOST lith.aesthetic.computer +set -l LITH_USER root +set -l REMOTE_DIR /opt/ac + +# Find the vault SSH key relative to wherever aesthetic-computer lives. +# Matches the logic in lith/deploy.fish. +function _ac_piece_logs_ssh_key + for candidate in \ + "$HOME/aesthetic-computer-vault/home/.ssh/id_rsa" \ + "/workspaces/aesthetic-computer-vault/home/.ssh/id_rsa" \ + "$AESTHETIC_VAULT/home/.ssh/id_rsa" + if test -n "$candidate" -a -f "$candidate" + echo $candidate + return 0 + end + end + return 1 +end + +function _ac_piece_logs_run --no-scope-shadowing + set -l key (_ac_piece_logs_ssh_key) + if test -z "$key" + echo "❌ No vault SSH key found. Set AESTHETIC_VAULT or clone aesthetic-computer-vault beside this repo." >&2 + return 1 + end + # Source the deployed env so MONGODB_CONNECTION_STRING / MONGODB_NAME + # are available to the CLI, then run it with whatever args came in. + ssh -i $key $LITH_USER@$LITH_HOST \ + "set -a; source $REMOTE_DIR/system/.env; cd $REMOTE_DIR && node system/backend/piece-logs-cli.mjs $argv" +end + +function ac-piece-logs --description "Recent piece-runs (default 20). Pass a slug to filter: ac-piece-logs notepat" + if test (count $argv) -gt 0; and not string match -q -- '--*' $argv[1] + _ac_piece_logs_run --slug $argv[1] $argv[2..-1] + else + _ac_piece_logs_run $argv + end +end + +function ac-piece-logs-events --description "Recent piece-runs with captured console events. ac-piece-logs-events notepat" + if test (count $argv) -gt 0; and not string match -q -- '--*' $argv[1] + _ac_piece_logs_run --events --slug $argv[1] $argv[2..-1] + else + _ac_piece_logs_run --events $argv + end +end + +function ac-piece-logs-errors --description "Piece-runs with status=error (default last 60m, 10 results)" + _ac_piece_logs_run --errors-only --since 60 --limit 10 $argv +end + +function ac-piece-logs-grep --description "Search console-event text across recent piece-runs. ac-piece-logs-grep 'drumMode'" + if test (count $argv) -eq 0 + echo "Usage: ac-piece-logs-grep [extra flags...]" + return 1 + end + _ac_piece_logs_run --grep $argv[1] $argv[2..-1] +end + +function ac-piece-logs-json --description "Raw JSON output of recent piece-runs" + _ac_piece_logs_run --json $argv +end + +function ac-piece-logs-help --description "Show piece-logs command help" + echo "piece-logs — client-side console telemetry stored in MongoDB piece-runs" + echo "" + echo "Commands:" + echo " ac-piece-logs [slug] — recent runs (optionally filtered by slug)" + echo " ac-piece-logs-events [slug] — recent runs with captured console output" + echo " ac-piece-logs-errors — runs with status=error (last 60m)" + echo " ac-piece-logs-grep — runs whose events match regex" + echo " ac-piece-logs-json — raw JSON for scripting" + echo "" + echo "Pass-through flags: --slug, --host, --status, --since, --limit, --events, --json" + echo "" + echo "Examples:" + echo " ac-piece-logs notepat --limit 5" + echo " ac-piece-logs-events notepat --since 30" + echo " ac-piece-logs-errors" + echo " ac-piece-logs-grep 'Invalid note'" +end diff --git a/system/backend/piece-logs-cli.mjs b/system/backend/piece-logs-cli.mjs new file mode 100644 index 0000000000..83f3ec3340 --- /dev/null +++ b/system/backend/piece-logs-cli.mjs @@ -0,0 +1,141 @@ +// piece-logs-cli.mjs — admin CLI to inspect piece-run telemetry. +// +// Runs on lith (has MONGODB_CONNECTION_STRING in env via +// /opt/ac/system/.env). Reads the `piece-runs` collection populated +// by the /api/piece-log endpoint (see netlify/functions/piece-log.mjs +// and the console-capture wrapper near line 970 of +// system/public/aesthetic.computer/lib/disk.mjs). +// +// Intended invocation: via the ac-piece-logs fish function which +// SSHes to lith and runs `node system/backend/piece-logs-cli.mjs ...` +// with args. Works locally too if your shell has MONGODB_CONNECTION_STRING +// + MONGODB_NAME set (e.g. source /opt/ac/system/.env). +// +// Flags: +// --slug filter by slug (e.g. notepat) +// --host filter by meta.host +// --status started | complete | error +// --since only runs updated in the last N minutes +// --limit cap results (default 20, max 200) +// --events include the captured console events in each run +// --errors-only shorthand for --status error +// --grep fetch --events then filter to runs whose events +// include `pattern` (case-insensitive regex) +// --json raw JSON (default is a human summary) +// +// Examples: +// node system/backend/piece-logs-cli.mjs --slug notepat --limit 5 --events +// node system/backend/piece-logs-cli.mjs --errors-only --since 60 +// node system/backend/piece-logs-cli.mjs --grep "drumMode" --limit 3 + +import { connect } from "./database.mjs"; + +const args = process.argv.slice(2); +const opts = { + slug: null, + host: null, + status: null, + since: null, + limit: 20, + events: false, + grep: null, + json: false, +}; + +for (let i = 0; i < args.length; i++) { + const a = args[i]; + const take = () => args[++i]; + if (a === "--slug") opts.slug = take(); + else if (a === "--host") opts.host = take(); + else if (a === "--status") opts.status = take(); + else if (a === "--since") opts.since = Number(take()); + else if (a === "--limit") opts.limit = Math.min(200, Math.max(1, Number(take()))); + else if (a === "--events") opts.events = true; + else if (a === "--errors-only") opts.status = "error"; + else if (a === "--grep") { opts.grep = take(); opts.events = true; } + else if (a === "--json") opts.json = true; + else if (a === "-h" || a === "--help") { printHelp(); process.exit(0); } + else { + console.error(`Unknown flag: ${a}`); + printHelp(); + process.exit(2); + } +} + +function printHelp() { + console.log(`piece-logs-cli — inspect piece-run telemetry + +Usage: node system/backend/piece-logs-cli.mjs [flags] + +Flags: + --slug filter by slug (e.g. notepat) + --host filter by meta.host + --status started | complete | error + --since only runs updated in the last N minutes + --limit cap results (default 20, max 200) + --events include captured console events + --errors-only shorthand for --status error + --grep filter to runs whose events match regex (implies --events) + --json raw JSON output +`); +} + +const query = {}; +if (opts.slug) query["meta.slug"] = opts.slug; +if (opts.host) query["meta.host"] = opts.host; +if (opts.status) query.status = opts.status; +if (opts.since) query.updatedAt = { $gte: new Date(Date.now() - opts.since * 60_000) }; + +const projection = { _id: 0 }; +if (!opts.events) projection.events = 0; + +async function main() { + const database = await connect(); + const runs = database.db.collection("piece-runs"); + let results = await runs.find(query, { projection }).sort({ updatedAt: -1 }).limit(opts.limit).toArray(); + await database.disconnect(); + + if (opts.grep) { + const re = new RegExp(opts.grep, "i"); + results = results.filter((run) => (run.events || []).some((ev) => re.test(ev.message || ""))); + } + + if (opts.json) { + console.log(JSON.stringify(results, null, 2)); + return; + } + + if (results.length === 0) { + console.log("no matching runs"); + return; + } + + for (const r of results) { + const when = r.updatedAt ? new Date(r.updatedAt).toISOString() : "?"; + const status = r.status || "?"; + const slug = r.meta?.slug || r.slug || "?"; + const host = r.meta?.host || r.server?.country || ""; + console.log(`── ${when} [${status.padEnd(8)}] ${slug.padEnd(20)} ${host}`); + console.log(` pieceId: ${r.pieceId}`); + if (r.meta?.userAgent) console.log(` ua: ${truncate(r.meta.userAgent, 100)}`); + if (r.error) console.log(` error: ${r.error.message || JSON.stringify(r.error).slice(0, 200)}`); + if (opts.events && r.events?.length) { + console.log(` events: (${r.events.length})`); + for (const ev of r.events.slice(-30)) { + const elapsed = `${String(ev.elapsed ?? "?").padStart(6)}ms`; + console.log(` ${elapsed} ${ev.level.padEnd(5)} ${truncate(ev.message, 200)}`); + } + } + console.log(); + } +} + +function truncate(s, n) { + s = String(s ?? ""); + return s.length > n ? s.slice(0, n - 1) + "…" : s; +} + +main().catch((err) => { + console.error("piece-logs-cli failed:", err?.stack || err); + process.exit(1); +}); diff --git a/system/public/aesthetic.computer/disks/notepat.mjs b/system/public/aesthetic.computer/disks/notepat.mjs index e15cb7f299..ab05d7d2cc 100644 --- a/system/public/aesthetic.computer/disks/notepat.mjs +++ b/system/public/aesthetic.computer/disks/notepat.mjs @@ -385,6 +385,7 @@ const wavetypes = [ "noise", // 4 - white noise filtered by pitch "composite", // 5 "stample", // 6 + "drum", // 7 - shared 12-drum kit (lib/percussion.mjs), both octaves ]; let waveIndex = 0; // 0; const STARTING_WAVE = wavetypes[waveIndex]; //"sine"; @@ -683,7 +684,6 @@ function getTopBarPianoMetrics(screen) { abletonBtn?.box?.x ?? Infinity, waveBtn?.box?.x ?? Infinity, octBtn?.box?.x ?? Infinity, - drumBtn?.box?.x ?? Infinity, ); const rightEdge = Number.isFinite(leftmostButtonX) ? leftmostButtonX - 3 @@ -1103,8 +1103,9 @@ let paintPictureOverlay = false; // let qrcells; -let waveBtn, octBtn, osBtn, abletonBtn, drumBtn; -let drumMode = false; // When true, all note triggers play the shared drumkit instead. +let waveBtn, octBtn, osBtn, abletonBtn; +// 🥁 Drum kit lives as a wave type ("drum") in `wavetypes` — when selected, +// every note fires from lib/percussion.mjs instead of the pitched synth. let slideBtn, roomBtn, glitchBtn, quickBtn; // Toggle buttons for slide/room/glitch/quick modes let metroBtn, bpmMinusBtn, bpmPlusBtn; // Metronome controls let melodyAliasBtn; @@ -1643,6 +1644,7 @@ async function boot({ "noise", "stample", "sample", + "drum", ]; const requestedWave = wavetypes.indexOf(colon[0]) > -1 ? colon[0] : wave; wave = requestedWave === "sample" ? "stample" : requestedWave; @@ -1699,7 +1701,6 @@ async function boot({ buildWaveButton(api); buildAbletonButton(api); buildOsButton(api); - buildDrumButton(api); buildToggleButtons(api); buildMetronomeButtons(api); @@ -4685,27 +4686,6 @@ function paint({ ); }); - drumBtn?.paint((btn) => { - const base = drumMode ? [90, 30, 30] : [30, 20, 40]; - const bright = drumMode ? [220, 110, 110] : [120, 120, 180]; - ink(btn.down ? [140, 60, 60] : base).box(btn.box); - if (btn.over && !btn.down) { - ink(255, 255, 255, 24).box(btn.box); - ink(255, 160, 160, 140).box(btn.box, "outline"); - } - ink(bright).line( - btn.box.x + btn.box.w, - btn.box.y, - btn.box.x + btn.box.w, - btn.box.y + btn.box.h - 1, - ); - ink(btn.down ? [255, 220, 220] : bright).write( - drumBtn.label || "drm", - { x: btn.box.x + TOGGLE_BTN_PADDING_X, y: btn.box.y + TOGGLE_BTN_PADDING_Y }, - undefined, undefined, false, "MatrixChunky8" - ); - }); - waveBtn?.paint((btn) => { ink(btn.down ? [40, 40, 100] : "darkblue").box( btn.box.x, @@ -5958,11 +5938,12 @@ function startButtonNote(note, velocity = 127, apiRef = null) { if (downs[note]) return false; - // 🥁 Drum mode: the upper octave (notes prefixed with "+" or "++") fires the - // shared 12-drum kit instead of a pitched note. Lower octave stays pitched so - // you can play melody + drums simultaneously. Mirrors the kitRight behaviour - // in fedac/native/pieces/notepat.mjs. - if (drumMode && note.startsWith("+") && soundContext) { + // 🥁 Drum voice: when the selected wave is "drum", every note (both octaves) + // fires a one-shot from the shared 12-drum kit in lib/percussion.mjs. Strip + // any octave prefix (++, +, -) and lowercase so "C", "+c", and "++c" all + // land on the same drum slot. The note's pan is derived from its key + // position like the pitched voices. + if (wave === "drum" && soundContext) { const letter = note.replace(/^[+\-]+/, "").toLowerCase(); const pan = getPanForButtonNote(note); const volume = Math.max(0.1, velocity / 127); @@ -6133,7 +6114,6 @@ function act({ buildWaveButton(api); buildAbletonButton(api); buildOsButton(api); - buildDrumButton(api); buildToggleButtons(api); buildMetronomeButtons(api); // Resize picture to quarter resolution (half width, half height) @@ -6235,7 +6215,6 @@ function act({ const topPianoEndX = topBarBase + topPianoWidth; const vizLeft = topPianoEndX; // Start after piano const vizRight = Math.min( - drumBtn?.box?.x ?? Infinity, osBtn?.box?.x ?? Infinity, abletonBtn?.box?.x ?? Infinity, waveBtn?.box?.x ?? screen.width, @@ -6392,7 +6371,6 @@ function act({ buildWaveButton(api); buildAbletonButton(api); buildOsButton(api); - buildDrumButton(api); } // if (e.is("keyboard:down:shift") && !e.repeat) { @@ -7148,7 +7126,6 @@ function act({ buildWaveButton(api); buildAbletonButton(api); buildOsButton(api); - buildDrumButton(api); }, }); @@ -7161,7 +7138,6 @@ function act({ buildWaveButton(api); buildAbletonButton(api); buildOsButton(api); - buildDrumButton(api); }, }); @@ -7181,15 +7157,6 @@ function act({ }, }); - drumBtn?.act(e, { - down: () => api.beep(400), - push: () => { - drumMode = !drumMode; - api.beep(drumMode ? 600 : 200); - console.log("🥁 drumMode:", drumMode ? "ON (upper octave → drum kit)" : "OFF (pitched)"); - }, - }); - // 🎛️ Toggle button interactions slideBtn?.act(e, { push: () => { @@ -8291,6 +8258,7 @@ function buildWaveButton({ screen, ui, typeface }) { noise: "noi", composite: "cmp", stample: "stp", + drum: "drm", }; const displayWave = isNarrow ? (shortWaveNames[wave] || wave.slice(0, 3)) : wave; const waveWidth = displayWave.length * glyphWidth; @@ -8342,7 +8310,6 @@ function osBarButtonMetrics({ screen }) { labels: { ableton: "m4l", os: "os", - drum: isNarrow ? "drm" : "drum", }, }; } @@ -8365,16 +8332,6 @@ function buildOsButton({ ui, screen }) { osBtn.label = m.labels.os; } -function buildDrumButton({ ui, screen }) { - const m = osBarButtonMetrics({ screen }); - const w = m.labels.drum.length * m.glyph + m.padX * 2; - const anchorX = - osBtn?.box?.x ?? abletonBtn?.box?.x ?? (screen.width - OS_BAR_RIGHT_MARGIN); - drumBtn = new ui.Button(anchorX - w - OS_BAR_BTN_GAP, m.y, w, m.h); - drumBtn.id = "drum-button"; - drumBtn.label = m.labels.drum; -} - // Build metronome controls and toggle buttons with responsive layout // Calculates available space and shortens labels as needed to prevent overlap function buildMetronomeButtons({ screen, ui, typeface, text }) { diff --git a/system/public/aesthetic.computer/lib/sound/synth.mjs b/system/public/aesthetic.computer/lib/sound/synth.mjs index 8f04f2eff4..53e435acb8 100644 --- a/system/public/aesthetic.computer/lib/sound/synth.mjs +++ b/system/public/aesthetic.computer/lib/sound/synth.mjs @@ -75,6 +75,12 @@ export default class Synth { constructor({ type, id, options, duration, attack, decay, volume, pan }) { // console.log("New Synth:", arguments); + // 🌊 Accept "noise" as an alias for "noise-white" so code that targets + // the native AC synth (fedac/native/src/js-bindings.c also aliases both + // strings to WAVE_NOISE) plays correctly on the web. Without this the + // shared drum kit in lib/percussion.mjs falls through every noise + // branch and silently drops snares/hats/claps/etc. + if (type === "noise") type = "noise-white"; this.type = type; if (id === undefined || id === null || id === NaN) console.warn("⏰ No id for sound:", id, type); diff --git a/system/public/aesthetic.computer/lib/speaker-bundled.mjs b/system/public/aesthetic.computer/lib/speaker-bundled.mjs index 8fad67d2ad..99e8c8ce6b 100644 --- a/system/public/aesthetic.computer/lib/speaker-bundled.mjs +++ b/system/public/aesthetic.computer/lib/speaker-bundled.mjs @@ -432,6 +432,9 @@ var Synth = class { #customBufferSize = 1024; // Size of the streaming buffer constructor({ type, id, options, duration, attack, decay, volume, pan }) { + // 🌊 Alias "noise" → "noise-white" to match fedac/native/src/js-bindings.c + // so shared percussion (lib/percussion.mjs) plays correctly on the web. + if (type === "noise") type = "noise-white"; this.type = type; if (id === void 0 || id === null || id === NaN) console.warn("\u23F0 No id for sound:", id, type);