···6363 "piece": "notepat-remote",
6464 "description": "Relay MIDI from ac-native notepat (ThinkPad) to this track via session-server",
6565 "width": 360,
6666- "height": 220,
6666+ "height": 169,
6767 "type": "midi",
6868 "source": "AC-NotepatRemote.amxd.json",
6969 "version": "0.1.0"
+91-47
fedac/native/initramfs/init
···1818mkdir -p /sys/kernel/debug 2>/dev/null
1919mount -t debugfs debugfs /sys/kernel/debug 2>/dev/null
20202121-# zram swap
2222-modprobe zram 2>/dev/null || true
2323-if [ -e /sys/block/zram0/disksize ] && [ -b /dev/zram0 ]; then
2424- echo 1G > /sys/block/zram0/disksize &&
2525- mkswap /dev/zram0 >/dev/null 2>&1 &&
2626- swapon /dev/zram0 2>/dev/null
2727-fi
2121+# zram swap — skipped by default. notepat fits in ~200 MB, every ThinkPad
2222+# we target has ≥4 GB RAM, and the zram modprobe + mkswap chain adds
2323+# ~100-200 ms to boot for no observable benefit. Set AC_ZRAM=1 on the
2424+# kernel cmdline if you ever need it back (e.g. low-memory tablet boot).
2525+case " $(cat /proc/cmdline 2>/dev/null) " in
2626+ *" AC_ZRAM=1 "*)
2727+ modprobe zram 2>/dev/null || true
2828+ if [ -e /sys/block/zram0/disksize ] && [ -b /dev/zram0 ]; then
2929+ echo 1G > /sys/block/zram0/disksize &&
3030+ mkswap /dev/zram0 >/dev/null 2>&1 &&
3131+ swapon /dev/zram0 2>/dev/null
3232+ fi
3333+ ;;
3434+esac
28352936# Loopback
3037ip link set lo up 2>/dev/null
31383232-# Restore baked Claude credentials (tmpfs mount hid the originals)
3333-if [ -f /claude-creds.json ]; then
3939+# Restore baked Claude credentials (tmpfs mount hid the originals).
4040+# Two bake paths produce creds at /:
4141+# /claude-creds.json legacy full JSON (linux ac-os, reads ~/.claude/.credentials.json)
4242+# /claude-token plain OAuth bearer (macOS flash-mac.sh, MongoDB year-long token)
4343+# Either presence triggers setup of /tmp/.claude/ so Claude Code boots without
4444+# onboarding/login prompts. pty.c separately reads /claude-token to set
4545+# CLAUDE_CODE_OAUTH_TOKEN in the child's env.
4646+if [ -f /claude-creds.json ] || [ -f /claude-token ]; then
3447 mkdir -p /tmp/.claude
3535- cp /claude-creds.json /tmp/.claude/.credentials.json
4848+ [ -f /claude-creds.json ] && cp /claude-creds.json /tmp/.claude/.credentials.json
3649 cp /claude-state.json /tmp/.claude.json 2>/dev/null
3750 printf '{"permissions":{"allow":["Bash(*)","Read(*)","Write(*)","Edit(*)","Glob(*)","Grep(*)","WebFetch(*)","WebSearch(*)"]},"autoUpdates":false,"installMethod":"native"}\n' > /tmp/.claude/settings.json
3851fi
···5063echo "root:x:0:" > /etc/group
5164echo "root:x:0:root" > /etc/passwd
52655353-# Wait for GPU (up to 3 seconds)
6666+# Kick off USB mount in the background — independent of GPU probe, so
6767+# we overlap the two waits instead of running them serially. The main
6868+# thread then waits for GPU (below) and finally waits for this mount
6969+# result before continuing. Shaves up to ~2s on cold boot.
7070+modprobe vfat 2>/dev/null
7171+modprobe nls_cp437 2>/dev/null
7272+modprobe nls_ascii 2>/dev/null
7373+USB_MOUNTED=0
7474+USB_PARTS="/dev/sda1 /dev/sda2 /dev/sda3 /dev/sdb1 /dev/sdb2 /dev/sdb3 /dev/sdc1 /dev/sdc2 /dev/sdc3 /dev/sdd1 /dev/sdd2 /dev/sdd3 /dev/nvme0n1p1 /dev/nvme0n1p2 /dev/nvme0n1p3"
7575+7676+mount_usb_partition() {
7777+ pass="$1"
7878+ for p in $USB_PARTS; do
7979+ if [ -b "$p" ]; then
8080+ mkdir -p /mnt
8181+ mount -t vfat "$p" /mnt 2>/dev/null || continue
8282+ if [ "$pass" = "config" ] && [ -f /mnt/config.json ]; then
8383+ return 0
8484+ fi
8585+ if [ "$pass" = "boot" ] && { [ -f /mnt/EFI/BOOT/BOOTX64.EFI ] || [ -f /mnt/EFI/BOOT/KERNEL.EFI ]; }; then
8686+ return 0
8787+ fi
8888+ umount /mnt 2>/dev/null
8989+ fi
9090+ done
9191+ return 1
9292+}
9393+9494+# Background USB mount: try up to 10 times with 1s delay, write result
9595+# to /run/usb-mounted so the foreground thread can check after GPU wait.
9696+(
9797+ for attempt in 1 2 3 4 5 6 7 8 9 10; do
9898+ mount_usb_partition config && { echo 1 > /run/usb-mounted; exit 0; }
9999+ mount_usb_partition boot && { echo 1 > /run/usb-mounted; exit 0; }
100100+ sleep 1
101101+ done
102102+ echo 0 > /run/usb-mounted
103103+) &
104104+USB_MOUNT_PID=$!
105105+106106+# Wait for GPU (up to 3 seconds) — runs in parallel with USB mount above.
54107i=0
55108while [ ! -e /dev/dri/card0 ] && [ ! -e /dev/dri/card1 ] && [ ! -e /dev/fb0 ] && [ $i -lt 300 ]; do
56109 usleep 10000 2>/dev/null || sleep 1
57110 i=$((i+1))
58111done
112112+113113+# Converge: wait for the background USB mount to settle, pick up its result.
114114+wait $USB_MOUNT_PID 2>/dev/null
115115+[ -f /run/usb-mounted ] && USB_MOUNTED=$(cat /run/usb-mounted 2>/dev/null) && [ -z "$USB_MOUNTED" ] && USB_MOUNTED=0
5911660117# Performance governor (silently skip if cpufreq not available)
61118if [ -d /sys/devices/system/cpu/cpu0/cpufreq ]; then
···88145export COLORTERM="truecolor"
89146export EDITOR="/bin/vi"
901479191-# ── Mount USB config/log partition (for config.json, wifi creds, logs) ──
9292-# Prefer the writable config partition over the boot partitions.
9393-modprobe vfat 2>/dev/null
9494-modprobe nls_cp437 2>/dev/null
9595-modprobe nls_ascii 2>/dev/null
9696-USB_MOUNTED=0
9797-USB_PARTS="/dev/sda1 /dev/sda2 /dev/sda3 /dev/sdb1 /dev/sdb2 /dev/sdb3 /dev/sdc1 /dev/sdc2 /dev/sdc3 /dev/sdd1 /dev/sdd2 /dev/sdd3 /dev/nvme0n1p1 /dev/nvme0n1p2 /dev/nvme0n1p3"
9898-9999-mount_usb_partition() {
100100- pass="$1"
101101- for p in $USB_PARTS; do
102102- if [ -b "$p" ]; then
103103- mkdir -p /mnt
104104- mount -t vfat "$p" /mnt 2>/dev/null || continue
105105- if [ "$pass" = "config" ] && [ -f /mnt/config.json ]; then
106106- USB_MOUNTED=1
107107- return 0
108108- fi
109109- if [ "$pass" = "boot" ] && { [ -f /mnt/EFI/BOOT/BOOTX64.EFI ] || [ -f /mnt/EFI/BOOT/KERNEL.EFI ]; }; then
110110- USB_MOUNTED=1
111111- return 0
112112- fi
113113- umount /mnt 2>/dev/null
114114- fi
115115- done
116116- return 1
117117-}
118118-119119-for attempt in 1 2 3 4 5 6 7 8 9 10; do
120120- mount_usb_partition config && break
121121- mount_usb_partition boot && break
122122- [ "$USB_MOUNTED" = "1" ] && break
123123- sleep 1
124124-done
148148+# USB mount already settled above (kicked off in parallel with GPU wait).
149149+# USB_MOUNTED is populated from /run/usb-mounted at that wait() point.
125150126151# Create samples directory on boot media + mount point for music USB
127152if [ "$USB_MOUNTED" = "1" ]; then
···141166# Run ac-native in a loop — if it crashes, restart; if clean exit, shutdown
142167export LD_LIBRARY_PATH="/lib64:/usr/lib64:${LD_LIBRARY_PATH:-}"
143168144144-# Write diagnostics to console AND USB
169169+# Pre-launch diagnostics — useful for audio probe / GPU / PCI debugging,
170170+# but the full dump writes ~60 KB to FAT32 (slow USB), does dozens of
171171+# shell forks over sysfs, and on older ThinkPads adds 1–2 s of wall time
172172+# to boot. The whole block now runs in a backgrounded subshell so
173173+# ac-native launches immediately; the dump completes concurrently while
174174+# the splash fade is drawing. Downstream tools (os-install-report,
175175+# post-mortem analysis) still get the same pre-launch.log at the same
176176+# path — just a second later than before.
177177+#
178178+# Set AC_NOLAUNCH_DIAG=1 on the kernel cmdline to skip the dump entirely
179179+# (useful for locked-down production kiosks that don't want the sysfs
180180+# scraping cost at all).
145181echo "[init] USB_MOUNTED=$USB_MOUNTED" > /dev/tty0 2>/dev/null
146182if [ "$USB_MOUNTED" = "1" ]; then
147183 LOG=/mnt/pre-launch.log
148184else
149149- # No USB config partition — try writing logs to /tmp
150185 LOG=/tmp/pre-launch.log
151186fi
187187+188188+_pre_launch_diag() {
152189echo "=== PRE-LAUNCH ===" > $LOG
153190ls /dev/dri/ >> $LOG 2>&1
154191echo "binary: $(ls -la /ac-native 2>&1)" >> $LOG
···357394echo "=== CMDLINE ===" >> $LOG
358395cat /proc/cmdline >> $LOG 2>&1
359396sync
397397+} # end _pre_launch_diag
398398+399399+# Launch pre-launch diagnostics in the background unless cmdline disables.
400400+case " $(cat /proc/cmdline 2>/dev/null) " in
401401+ *" AC_NOLAUNCH_DIAG=1 "*) : ;;
402402+ *) _pre_launch_diag & ;;
403403+esac
360404echo "[init] GPU: $(ls /dev/dri/ 2>/dev/null || echo NONE) USB=$USB_MOUNTED" > /dev/tty0 2>/dev/null
361405362406# Start Swank server in background (if SBCL image exists)
+258-99
fedac/native/pieces/notepat.mjs
···111111let clockSyncFrame = 0; // frame counter for periodic resync
112112function syncedNow() { return Date.now() + clockOffset; }
113113114114-// FX rows: dry/wet, echo, pitch, bitcrush.
114114+// FX rows: dry/wet, echo, pitch, bitcrush, volume, drive.
115115let echoMix = 0;
116116let bitcrushMix = 0;
117117let echoDragging = false;
···124124let volDragging = false;
125125let brtDragging = false;
126126127127+// Master volume (user-controlled, separate from system_volume hardware
128128+// mixer). Slider range 0..1 maps to audio gain 0..2 (0..200%), so
129129+// slider at 0.5 = unity gain (1.0×). Default 0.5 means the slider
130130+// starts at the unity tick so the device sounds identical to pre-slider
131131+// builds out of the box.
132132+let masterVolMix = 0.5;
133133+let masterVolDragging = false;
134134+135135+// Drive (tanh soft-sat dry/wet). 0 = clean, 1 = full drive. Applied
136136+// BEFORE master volume so the slider feels like a tone control.
137137+let driveMix = 0;
138138+let driveDragging = false;
139139+140140+// Waveform-strip view cursor — how many seconds in the past the playhead
141141+// sits relative to the live audio edge. 0 = live (wave drifts LEFT as
142142+// real time advances). Grows when spacebar is held (wave drifts RIGHT,
143143+// backwards-replay scrub). Shrinks back to 0 on release so the display
144144+// catches up to live audio over ~0.5 s.
145145+let waveViewOffsetSec = 0;
146146+// Max retreat — also caps how much of the right half can fill in with
147147+// post-cursor audio. Matches recordStripSeconds / 2 so the right half
148148+// fully paints when the cursor has retreated half the visible window.
149149+const WAVE_VIEW_MAX_OFFSET_SEC = 2.0;
150150+127151// Pitch shift — assignable to either trackpad axis
128152let pitchShift = 0; // -1 to +1, 0 = no shift
129153let lastAppliedPitch = 0; // last pitch actually sent to synths (throttle)
···131155// Trackpad FX control (\ toggles on/off)
132156let trackpadFX = false;
133157let trackpadEffectBindings = {
134134- echo: { x: true, y: false },
135135- pitch: { x: false, y: true },
136136- crush: { x: false, y: false },
158158+ echo: { x: true, y: false },
159159+ pitch: { x: false, y: true },
160160+ crush: { x: false, y: false },
161161+ volume: { x: false, y: false },
162162+ drive: { x: false, y: false },
137163};
138164139165function clampRange(value, min, max) {
···166192 return changed;
167193}
168194195195+function setMasterVolMixValue(value, sound, commit = true) {
196196+ // Slider range 0..1 maps to audio gain 0..2 so the UI stays in the
197197+ // same 0..100% pattern as the other FX rows. 50% = unity, 100% = 2x.
198198+ const next = clamp01(value);
199199+ const changed = Math.abs(next - masterVolMix) > 0.0005;
200200+ masterVolMix = next;
201201+ if (commit) sound?.volume?.setMix?.(masterVolMix * 2);
202202+ return changed;
203203+}
204204+205205+function setDriveMixValue(value, sound, commit = true) {
206206+ const next = clamp01(value);
207207+ const changed = Math.abs(next - driveMix) > 0.0005;
208208+ driveMix = next;
209209+ if (commit) sound?.drive?.setMix?.(driveMix);
210210+ return changed;
211211+}
212212+169213function applyPitchShiftToActiveSounds(force = false) {
170214 const ep = effectivePitchShift();
171215 if (!force && Math.abs(ep - lastAppliedPitch) <= 0.001) return false;
···204248 if (commit) sound?.fx?.setMix?.(fxMix);
205249 return true;
206250 }
207207- if (rowId === "echo") return setEchoMixValue(norm, sound, commit);
208208- if (rowId === "crush") return setBitcrushMixValue(norm, sound, commit);
209209- if (rowId === "pitch") return setPitchShiftValue(norm * 2 - 1, commit);
251251+ if (rowId === "echo") return setEchoMixValue(norm, sound, commit);
252252+ if (rowId === "crush") return setBitcrushMixValue(norm, sound, commit);
253253+ if (rowId === "pitch") return setPitchShiftValue(norm * 2 - 1, commit);
254254+ if (rowId === "volume") return setMasterVolMixValue(norm, sound, commit);
255255+ if (rowId === "drive") return setDriveMixValue(norm, sound, commit);
210256 return false;
211257}
212258···18211867}
1822186818231869function loadUdpMidiConfig(system) {
18701870+ // Default ON: every notepat install should broadcast to the amxd plugin
18711871+ // unless the user explicitly opts out by writing
18721872+ // {"udpMidiBroadcast": false}
18731873+ // into /mnt/config.json. Previously the flag defaulted to false and
18741874+ // required `true` to enable, which silently blocked the relay on every
18751875+ // OTA-updated device whose pre-existing config.json predates the flag.
18761876+ udpMidiBroadcast = true;
18241877 try {
18251878 const raw = system?.readFile?.("/mnt/config.json");
18261826- if (!raw) {
18791879+ if (!raw) return;
18801880+ const cfg = JSON.parse(raw);
18811881+ if (cfg.udpMidiBroadcast === false || cfg.udpMidiBroadcast === "false") {
18271882 udpMidiBroadcast = false;
18281828- return;
18291883 }
18301830- const cfg = JSON.parse(raw);
18311831- udpMidiBroadcast = cfg.udpMidiBroadcast === true || cfg.udpMidiBroadcast === "true";
18321884 } catch (_) {
18331833- udpMidiBroadcast = false;
18851885+ // Keep default (true) on parse failure.
18341886 }
18351887}
18361888···18551907 udpMidiNextHeartbeatFrame = frame + 300;
18561908}
1857190919101910+// Build the UDP-MIDI status line — parallel in form to usbMidiStatusText so
19111911+// the two indicators read as siblings in the top bar. States:
19121912+// "UDP MIDI OFF" broadcast disabled in /mnt/config.json
19131913+// "UDP MIDI ..." broadcast enabled, socket not yet up
19141914+// "UDP MIDI ON" socket up, no notes sent yet
19151915+// "UDP MIDI ON 60v100 ▸ 42" socket up, last note + total-sent count
19161916+// The caller colors based on whether a note was sent in the last ~1.5s
19171917+// (bright green) vs idle-but-connected (dim green) vs disconnected (amber).
18581918function udpMidiRelayStatusText(system) {
18591859- if (!udpMidiBroadcast) return "";
18601860- const handle = system?.udp?.handle || system?.config?.handle || "";
19191919+ if (!udpMidiBroadcast) return "UDP MIDI OFF";
18611920 const connected = !!system?.udp?.connected;
18621862- const prefix = connected
18631863- ? (handle ? "relay @" + handle : "relay on")
18641864- : (handle ? "relay ...@" + handle : "relay ...");
18651865- // Append counters + last note when actively sending so the overlay shows
18661866- // the ThinkPad actually broadcasts notes (and not just that the socket is up).
18671867- if (!connected) return prefix;
18681868- if (udpMidiSentCount === 0) return prefix + " 0";
18691869- const recent = frame - udpMidiLastSentFrame < 90; // ~1.5s fresh window
18701870- const tail = recent && udpMidiLastPitch >= 0
18711871- ? ` ${udpMidiSentCount} ${udpMidiLastPitch}v${udpMidiLastVelocity}`
18721872- : ` ${udpMidiSentCount}`;
18731873- return prefix + tail;
19211921+ if (!connected) return "UDP MIDI ...";
19221922+ if (udpMidiSentCount === 0) return "UDP MIDI ON";
19231923+ const recent = frame - udpMidiLastSentFrame < 90;
19241924+ if (recent && udpMidiLastPitch >= 0) {
19251925+ return `UDP MIDI ON ${udpMidiLastPitch}v${udpMidiLastVelocity} \u25B8 ${udpMidiSentCount}`;
19261926+ }
19271927+ return `UDP MIDI ON \u25B8 ${udpMidiSentCount}`;
19281928+}
19291929+19301930+// Returns 0 when no note has ever been sent; otherwise 0..1 recency with
19311931+// 1.0 at the moment of the send and fading to 0 over ~90 frames (1.5s).
19321932+// Used to pulse the badge color from bright-green → dim-green as notes fire.
19331933+function udpMidiSendRecency() {
19341934+ if (udpMidiSentCount === 0) return 0;
19351935+ const age = frame - udpMidiLastSentFrame;
19361936+ if (age < 0 || age > 90) return 0;
19371937+ return 1 - (age / 90);
18741938}
1875193918761940function rememberSound(key, entry, system, velocity = 1) {
···19612025function playWaveSound(sound, waveType) {
19622026 if (!sound?.synth) return;
19632027 if (waveType === "sample") {
19641964- // Short percussive click for sample mode
19651965- sound.synth({ type: "noise", tone: 800 * pf, duration: 0.03, volume: 0.12, attack: 0.001, decay: 0.025, pan: 0 });
20282028+ // Short percussive click for sample mode. Previously this referenced
20292029+ // `pf` (a local from playZoo/playLaser/playPercussion that never made
20302030+ // it into this scope) — throwing a ReferenceError the moment anyone
20312031+ // switched wave to "sample". Plain tone is fine for a UI blip.
20322032+ sound.synth({ type: "noise", tone: 800, duration: 0.03, volume: 0.12, attack: 0.001, decay: 0.025, pan: 0 });
19662033 return;
19672034 }
19682035 const tones = { sine: 660, triangle: 550, sawtooth: 440, square: 330, harp: 440, whistle: 880 };
···20592126 sound?.room?.setMix?.(echoMix);
20602127 sound?.glitch?.setMix?.(bitcrushMix);
20612128 sound?.fx?.setMix?.(fxMix);
21292129+ sound?.volume?.setMix?.(masterVolMix * 2); // slider 0..1 → audio 0..2
21302130+ sound?.drive?.setMix?.(driveMix);
20622131 loadUdpMidiConfig(system);
20632132 udpMidiNextHeartbeatFrame = 0;
20642133 const mic = sound?.microphone || null;
···28202889 return;
28212890 }
28222891 if (pointInRect(x, y, { x: row.sliderX, y: row.y, w: row.sliderW, h: row.h })) {
28232823- if (rowId === "fx") fxDragging = true;
28242824- else if (rowId === "echo") echoDragging = true;
28252825- else if (rowId === "pitch") pitchDragging = true;
28262826- else if (rowId === "crush") bitcrushDragging = true;
28922892+ if (rowId === "fx") fxDragging = true;
28932893+ else if (rowId === "echo") echoDragging = true;
28942894+ else if (rowId === "pitch") pitchDragging = true;
28952895+ else if (rowId === "crush") bitcrushDragging = true;
28962896+ else if (rowId === "volume") masterVolDragging = true;
28972897+ else if (rowId === "drive") driveDragging = true;
28272898 setEffectRowFromPointer(rowId, x, row, sound, true);
28282899 return;
28292900 }
···30003071 djDragLastX = x;
30013072 }
30023073 const fxRows = globalThis.__fxRows || {};
30033003- if (fxDragging) setEffectRowFromPointer("fx", x, fxRows.fx, sound, true);
30043004- if (echoDragging) setEffectRowFromPointer("echo", x, fxRows.echo, sound, true);
30053005- if (pitchDragging) setEffectRowFromPointer("pitch", x, fxRows.pitch, sound, true);
30063006- if (bitcrushDragging) setEffectRowFromPointer("crush", x, fxRows.crush, sound, true);
30743074+ if (fxDragging) setEffectRowFromPointer("fx", x, fxRows.fx, sound, true);
30753075+ if (echoDragging) setEffectRowFromPointer("echo", x, fxRows.echo, sound, true);
30763076+ if (pitchDragging) setEffectRowFromPointer("pitch", x, fxRows.pitch, sound, true);
30773077+ if (bitcrushDragging) setEffectRowFromPointer("crush", x, fxRows.crush, sound, true);
30783078+ if (masterVolDragging) setEffectRowFromPointer("volume", x, fxRows.volume, sound, true);
30793079+ if (driveDragging) setEffectRowFromPointer("drive", x, fxRows.drive, sound, true);
30073080 if (volDragging) {
30083081 const vb = globalThis.__volBar;
30093082 if (vb) {
···31063179 if (echoDragging) echoDragging = false;
31073180 if (pitchDragging) pitchDragging = false;
31083181 if (bitcrushDragging) bitcrushDragging = false;
31823182+ if (masterVolDragging) masterVolDragging = false;
31833183+ if (driveDragging) driveDragging = false;
31093184 if (volDragging) volDragging = false;
31103185 if (brtDragging) brtDragging = false;
31113186 // Release touch-triggered note
···31603235 if (trackpadFX && trackpad) {
31613236 let echoDirty = false;
31623237 let crushDirty = false;
32383238+ let volDirty = false;
32393239+ let driveDirty = false;
31633240 if (trackpad.dx !== 0) {
31643241 const dxNorm = trackpad.dx / Math.max(1, w);
31653242 if (trackpadEffectBindings.echo.x) {
···31713248 if (trackpadEffectBindings.pitch.x) {
31723249 setPitchShiftValue(pitchShift + dxNorm, false);
31733250 }
32513251+ if (trackpadEffectBindings.volume.x) {
32523252+ volDirty = setMasterVolMixValue(masterVolMix + dxNorm * 3, sound, false) || volDirty;
32533253+ }
32543254+ if (trackpadEffectBindings.drive.x) {
32553255+ driveDirty = setDriveMixValue(driveMix + dxNorm * 3, sound, false) || driveDirty;
32563256+ }
31743257 }
31753258 if (trackpad.dy !== 0) {
31763259 const dyNorm = -trackpad.dy / Math.max(1, h);
···31833266 if (trackpadEffectBindings.pitch.y) {
31843267 setPitchShiftValue(pitchShift + dyNorm, false);
31853268 }
32693269+ if (trackpadEffectBindings.volume.y) {
32703270+ volDirty = setMasterVolMixValue(masterVolMix + dyNorm * 3, sound, false) || volDirty;
32713271+ }
32723272+ if (trackpadEffectBindings.drive.y) {
32733273+ driveDirty = setDriveMixValue(driveMix + dyNorm * 3, sound, false) || driveDirty;
32743274+ }
31863275 }
31873276 if (echoDirty && frame % 3 === 0) sound?.room?.setMix?.(echoMix);
31883277 if (crushDirty && frame % 3 === 0) sound?.glitch?.setMix?.(bitcrushMix);
32783278+ if (volDirty && frame % 3 === 0) sound?.volume?.setMix?.(masterVolMix * 2);
32793279+ if (driveDirty && frame % 3 === 0) sound?.drive?.setMix?.(driveMix);
31893280 // Apply pitch shift to active voices — throttled to every 4th frame
31903281 // and only when pitch actually changed
31913282 if (frame % 4 === 0) applyPitchShiftToActiveSounds(false);
···33253416 }
3326341733273418 // === STATUS BAR ===
33283328- // Bar is tall enough to fit a 25px QR code (21-module QR version 1 with
33293329- // a 2-module quiet-zone margin, scale=1) in the top-left corner.
33303330- const topBarH = 26;
33313331- const barY = 10; // vertical offset for status text — matrix font is ~7px tall
34193419+ // Bar is tall enough to fit a 50×50 QR code (21-module version-1 QR +
34203420+ // 2-module quiet-zone margin at scale=2) in the top-left corner, with
34213421+ // the text row sitting vertically centered below the QR's midline so
34223422+ // readability at arm's length works on a phone-camera scan too.
34233423+ const topBarH = 54;
34243424+ const barY = 22; // center of text row — leaves 2 px top/bottom padding for status text at size=1
3332342533333426 ink(BAR_BG[0], BAR_BG[1], BAR_BG[2]);
33343427 box(0, 0, w, topBarH, true);
···33533446 if (reserveSysBrt >= 0) statusRightReserve += 4 + 16 + 2 + 3 * CH;
33543447 const statusRightLimit = Math.max(80, w - statusRightReserve - 8);
3355344833563356- // Left: tiny QR code → notepat.com (25x25 at scale=1), then label.
33573357- // Clicking anywhere in the label zone still jumps to prompt piece.
34493449+ // Left: QR code → notepat.com. Scale=1, version-1 with 2-module quiet
34503450+ // zone = 25×25 px. Taller top bar (54 px) leaves breathing room below
34513451+ // the QR for the label + status text without cramping. C side caches
34523452+ // the Reed-Solomon encoding so the inner module-grid blit is the only
34533453+ // per-frame cost.
33583454 if (globalThis.qr) {
33593359- // qr() handles its own white background + margin.
33603360- globalThis.qr("https://notepat.com", 1, 1, 1);
34553455+ globalThis.qr("https://notepat.com", 2, 2, 1);
33613456 }
33623362- const qrW = 26; // 25px QR + 1px padding before label
34573457+ const qrW = 28; // 25px QR + 2px left inset + 1px right padding before label
33633458 const labelX = qrW + 4;
33643459 const labelW = 48; // "notepat.com" label width in matrix font at size=1
33653460 const npHovered = hoverX >= 0 && hoverX <= labelX + labelW && hoverY < topBarH;
···34603555 statusWrite("key:" + lastKeyPressed, 180, 220, 255, fadeA);
34613556 }
3462355735583558+ // UDP MIDI — sibling indicator to USB MIDI. Color encodes state:
35593559+ // disabled → FG_DIM (flat "OFF")
35603560+ // enabled + down → amber (255,190,80) "..."
35613561+ // enabled + up → dim green (100,220,140) "ON"
35623562+ // actively sending→ bright green, pulsing with recency
35633563+ // The pulse decays over ~1.5s after each note so rapid play visibly
35643564+ // lights the indicator vs just sitting on "connected".
34633565 const relayText = udpMidiRelayStatusText(system);
34643566 if (relayText) {
34653465- statusWrite(
34663466- relayText,
34673467- system?.udp?.connected ? 80 : 255,
34683468- system?.udp?.connected ? 180 : 180,
34693469- system?.udp?.connected ? 255 : 90,
34703470- 210
34713471- );
35673567+ if (!udpMidiBroadcast) {
35683568+ statusWrite(relayText, FG_DIM, FG_DIM, FG_DIM, 200);
35693569+ } else if (!system?.udp?.connected) {
35703570+ statusWrite(relayText, 255, 190, 80, 220);
35713571+ } else {
35723572+ const recency = udpMidiSendRecency();
35733573+ // dim green (100,220,140) → bright green (160,255,190) as recency rises
35743574+ const r = Math.round(100 + recency * 60);
35753575+ const g = Math.round(220 + recency * 35);
35763576+ const b = Math.round(140 + recency * 50);
35773577+ statusWrite(relayText, r, g, b, 220);
35783578+ }
34723579 }
3473358034743581 // Metronome indicator (pendulum) in status bar — shown when enabled
···41374244 const leftX = margin;
41384245 const rightX = w - gridW - margin;
4139424641404140- // Scrolling record-needle strip: the last ~4 seconds of mixed speaker
41414141- // output, always rolling regardless of whether notes are active. Think
41424142- // classic DJ turntable display — you can see the waveform the spacebar
41434143- // reverse-play would snap back into. Refreshed every 4 frames to keep
41444144- // paint cheap; downsampled to one peak value per pixel column.
42474247+ // Scrolling record-needle strip with continuous-drift playback cursor.
42484248+ //
42494249+ // waveViewOffsetSec drives the cursor's position relative to live audio:
42504250+ // = 0 → cursor at live edge, past on LEFT, right empty;
42514251+ // wave drifts LEFT as new audio arrives
42524252+ // > 0, growing → cursor retreats into the past; right half fills in
42534253+ // with samples captured AFTER the cursor; wave drifts
42544254+ // RIGHT (backwards-replay visual)
42554255+ // > 0, shrinking→ cursor catches back up to "now"; wave drifts LEFT
42564256+ // faster than normal until offset hits 0
42574257+ //
42584258+ // waveViewOffsetSec is advanced/retreated in sim() below based on
42594259+ // spaceHeld. The C drawStrip reads the offset and renders accordingly
42604260+ // in a single call (no JS peak loop).
41454261 const recordStripH = 22;
41464262 const recordStripSeconds = 4;
41474263 const recordStripTop = Math.max(topBarH + 1, gridTop - recordStripH - 2);
41484148- const recordStripBottom = recordStripTop + recordStripH;
41494149- if (frame % 4 === 0 && sound?.speaker?.getRecentBuffer) {
41504150- const snap = sound.speaker.getRecentBuffer(recordStripSeconds);
41514151- if (snap && snap.data && snap.data.length > 0) {
41524152- globalThis.__recordStripData = snap.data;
41534153- }
41544154- }
41554155- const rsData = globalThis.__recordStripData;
41564156- if (rsData && rsData.length > 4) {
42644264+ if (sound?.speaker?.drawStrip) {
41574265 const rsX = margin;
41584266 const rsW = w - margin * 2;
41594159- const midY = Math.floor((recordStripTop + recordStripBottom) / 2);
41604160- // Background strip
41614161- ink(dark ? 20 : 235, dark ? 15 : 225, dark ? 30 : 210, 160);
41624162- box(rsX, recordStripTop, rsW, recordStripH, true);
41634163- // Center zero-line
41644164- ink(dark ? 80 : 140, dark ? 80 : 140, dark ? 90 : 150, 120);
41654165- line(rsX, midY, rsX + rsW, midY);
41664166- // Per-pixel-column peak of that time-slice.
41674167- const samplesPerCol = rsData.length / rsW;
41684168- const amp = Math.floor(recordStripH * 0.45);
41694169- for (let x = 0; x < rsW; x++) {
41704170- const i0 = Math.floor(x * samplesPerCol);
41714171- const i1 = Math.min(rsData.length, Math.floor((x + 1) * samplesPerCol));
41724172- let peak = 0;
41734173- for (let i = i0; i < i1; i++) {
41744174- const a = Math.abs(rsData[i]);
41754175- if (a > peak) peak = a;
41764176- }
41774177- // Clip extreme outliers so a transient hot sample doesn't dominate the
41784178- // column-height math and flatten everything else visually.
41794179- if (peak > 1.0) peak = 1.0;
41804180- const h = Math.max(1, Math.round(peak * amp));
41814181- // Color fades from warm (loud) through amber (mid) to cold (quiet).
41824182- const r = Math.min(255, Math.round(120 + peak * 140));
41834183- const g = Math.round(120 + peak * 80);
41844184- const b = Math.round(90 + (1 - peak) * 120);
41854185- ink(r, g, b, 220);
41864186- line(rsX + x, midY - h, rsX + x, midY + h);
41874187- }
41884188- // Right-edge "record needle" — where new samples are being written.
41894189- ink(240, 80, 80, 220);
41904190- line(rsX + rsW - 1, recordStripTop, rsX + rsW - 1, recordStripBottom);
42674267+ sound.speaker.drawStrip(rsX, recordStripTop, rsW, recordStripH,
42684268+ recordStripSeconds, 0.5, waveViewOffsetSec);
41914269 }
4192427041934271 // Waveform visualizer bars only in lanes above pad grids (not full-screen).
···48304908 fxRows.crush = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox };
48314909 }
4832491049114911+ // Master volume slider — user gain 0..200% (50% on slider = unity).
49124912+ // Sits below crush so it feels like a "last stage" like a mixing board
49134913+ // master fader. A tick at 50% marks unity for visual reference.
49144914+ {
49154915+ const sliderY = settingsY + sliderH * 4;
49164916+ const sliderW = w - axisAreaW;
49174917+ const hov = hoverY >= sliderY && hoverY < sliderY + sliderH;
49184918+ ink(dark ? (hov ? 40 : 25) : (hov ? 220 : 235),
49194919+ dark ? (hov ? 40 : 25) : (hov ? 220 : 235),
49204920+ dark ? (hov ? 45 : 28) : (hov ? 225 : 238));
49214921+ box(0, sliderY, w, sliderH, true);
49224922+ const fillW = Math.floor(masterVolMix * sliderW);
49234923+ if (fillW > 0) {
49244924+ ink(100, 220, 150, trackpadFX ? 240 : 180);
49254925+ box(0, sliderY, fillW, sliderH, true);
49264926+ }
49274927+ // Unity tick at the 50% position.
49284928+ const unityX = Math.floor(sliderW * 0.5);
49294929+ ink(dark ? 90 : 160, dark ? 110 : 180, dark ? 110 : 170, 200);
49304930+ box(unityX, sliderY, 1, sliderH, true);
49314931+ if (masterVolMix > 0.005) {
49324932+ const knobX = Math.max(1, Math.min(sliderW - 3, Math.floor(masterVolMix * sliderW)));
49334933+ ink(160, 255, 190, 220);
49344934+ box(knobX - 1, sliderY, 3, sliderH, true);
49354935+ }
49364936+ ink(dark ? 90 : 160, dark ? 90 : 160, dark ? 100 : 170);
49374937+ write("vol " + Math.round(masterVolMix * 200) + "%",
49384938+ { x: 2, y: sliderY + 2, size: 1, font: "font_1" });
49394939+ const xBox = { x: sliderW, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize };
49404940+ const yBox = { x: sliderW + axisBoxSize + axisGap, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize };
49414941+ drawAxisToggle(xBox, "x", !!trackpadEffectBindings.volume.x, [100, 220, 150]);
49424942+ drawAxisToggle(yBox, "y", !!trackpadEffectBindings.volume.y, [100, 220, 150]);
49434943+ fxRows.volume = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox };
49444944+ }
49454945+49464946+ // Drive slider — tanh soft-sat dry/wet (0 = clean, 100% = fully driven).
49474947+ // Subtle warmth in the 10-30% range, obvious distortion above 60%.
49484948+ {
49494949+ const sliderY = settingsY + sliderH * 5;
49504950+ const sliderW = w - axisAreaW;
49514951+ const hov = hoverY >= sliderY && hoverY < sliderY + sliderH;
49524952+ ink(dark ? (hov ? 40 : 25) : (hov ? 220 : 235),
49534953+ dark ? (hov ? 40 : 25) : (hov ? 220 : 235),
49544954+ dark ? (hov ? 45 : 28) : (hov ? 225 : 238));
49554955+ box(0, sliderY, w, sliderH, true);
49564956+ const fillW = Math.floor(driveMix * sliderW);
49574957+ if (fillW > 0) {
49584958+ ink(220, 90, 70, trackpadFX ? 240 : 180);
49594959+ box(0, sliderY, fillW, sliderH, true);
49604960+ }
49614961+ if (driveMix > 0.005) {
49624962+ const knobX = Math.max(1, Math.min(sliderW - 3, Math.floor(driveMix * sliderW)));
49634963+ ink(255, 160, 120, 220);
49644964+ box(knobX - 1, sliderY, 3, sliderH, true);
49654965+ }
49664966+ ink(dark ? 90 : 160, dark ? 90 : 160, dark ? 100 : 170);
49674967+ write("drive " + Math.round(driveMix * 100) + "%",
49684968+ { x: 2, y: sliderY + 2, size: 1, font: "font_1" });
49694969+ const xBox = { x: sliderW, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize };
49704970+ const yBox = { x: sliderW + axisBoxSize + axisGap, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize };
49714971+ drawAxisToggle(xBox, "x", !!trackpadEffectBindings.drive.x, [220, 90, 70]);
49724972+ drawAxisToggle(yBox, "y", !!trackpadEffectBindings.drive.y, [220, 90, 70]);
49734973+ fxRows.drive = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox };
49744974+ }
49754975+48334976 globalThis.__fxRows = fxRows;
48344834- const waveRowY = settingsY + sliderH * 4;
49774977+ const waveRowY = settingsY + sliderH * 6;
48354978 const waveRowH = 14;
4836497948374980 // === WAVE TYPE BUTTONS (below sliders, modular GUI) ===
···55145657 if (recording && (Date.now() - recStartTime) / 1000 >= MAX_REC_SECS) {
55155658 stopSampleRecording(sound, "max-duration");
55165659 }
56605660+56615661+ // Advance / retreat the waveform-strip view cursor based on spacebar.
56625662+ // 1x audio-rate retreat on press → the wave drifts RIGHT at a natural
56635663+ // speed. 2x catch-up on release → wave snaps forward noticeably faster
56645664+ // than normal drift so the eye can tell the cursor is "coming back".
56655665+ const dtSec = 1 / 60;
56665666+ if (spaceHeld) {
56675667+ waveViewOffsetSec = Math.min(WAVE_VIEW_MAX_OFFSET_SEC,
56685668+ waveViewOffsetSec + dtSec);
56695669+ } else if (waveViewOffsetSec > 0) {
56705670+ waveViewOffsetSec = Math.max(0, waveViewOffsetSec - dtSec * 2);
56715671+ }
55175672 // Update dark/light mode via global theme (every ~5 seconds)
55185673 if (frame % 300 === 0) {
55195674 const wasDark = dark;
···56135768 pitchShift = 0;
56145769 lastAppliedPitch = 0;
56155770 fxMix = 1;
57715771+ masterVolMix = 0.5; // unity (slider 0..1 → audio 0..2)
57725772+ driveMix = 0;
56165773 trackpadFX = false;
56175774 soundAPI?.room?.setMix?.(0);
56185775 soundAPI?.glitch?.setMix?.(0);
56195776 soundAPI?.fx?.setMix?.(1);
57775777+ soundAPI?.volume?.setMix?.(1); // unity
57785778+ soundAPI?.drive?.setMix?.(0); // clean
56205779 stopAllSounds(soundAPI, systemAPI, 0.02);
56215780}
56225781
+130-13
fedac/native/scripts/flash-mac.sh
···3434USB_DEV="${1:?usage: $0 /dev/diskN [SRC_DIR]}"
3535SRC_DIR="${2:-/tmp/ac-os-pull}"
36363737-# This script needs root for diskutil/sgdisk/dd/newfs_msdos/mount_msdos.
3838-# Re-exec under sudo if invoked as a regular user (sudoers.d/ac-flash-mac
3939-# whitelists this exact path NOPASSWD).
4040-if [ "$(id -u)" != "0" ]; then
4141- exec sudo --preserve-env=PATH "$0" "$@"
4242-fi
4343-4437SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
4538REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
3939+REAL_REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
4640NATIVE_DIR="${REPO_ROOT}/native"
4741[ -d "${NATIVE_DIR}/boot" ] || NATIVE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
48424343+# --- step 1: ensure ~/.ac-token is fresh (run ac-login if stale) ---
4444+# Done BEFORE the sudo exec so the OAuth browser dance happens as the
4545+# actual user. If ac-login.mjs isn't findable we skip the refresh and
4646+# leave the downstream bake to fail-soft with a warning.
4747+if [ "$(id -u)" != "0" ]; then
4848+ NEEDS_LOGIN=1
4949+ if [ -f "${HOME}/.ac-token" ] && command -v node >/dev/null 2>&1; then
5050+ NEEDS_LOGIN="$(node -e '
5151+ try {
5252+ const t = JSON.parse(require("fs").readFileSync(process.env.HOME+"/.ac-token", "utf8"));
5353+ const rawExp = t.expires_at || 0;
5454+ const expMs = rawExp > 10_000_000_000 ? rawExp : rawExp * 1000;
5555+ // Consider stale if expired or within 60s of expiring
5656+ process.stdout.write((!expMs || Date.now() >= expMs - 60000) ? "1" : "0");
5757+ } catch { process.stdout.write("1"); }
5858+ ')"
5959+ fi
6060+ if [ "${NEEDS_LOGIN}" = "1" ]; then
6161+ AC_LOGIN=""
6262+ for p in "${REAL_REPO_ROOT}/tezos/ac-login.mjs" \
6363+ "${HOME}/aesthetic-computer/tezos/ac-login.mjs"; do
6464+ [ -f "${p}" ] && AC_LOGIN="${p}" && break
6565+ done
6666+ if [ -n "${AC_LOGIN}" ] && command -v node >/dev/null 2>&1; then
6767+ echo "[flash-mac] ~/.ac-token is stale — running ac-login to refresh…"
6868+ echo "[flash-mac] script: ${AC_LOGIN}"
6969+ node "${AC_LOGIN}" || {
7070+ echo "[flash-mac] ac-login failed (non-fatal; proceeding without Claude bake)" >&2
7171+ }
7272+ else
7373+ echo "[flash-mac] WARN: ac-login.mjs not found — Claude creds won't be baked" >&2
7474+ echo "[flash-mac] searched: ${REAL_REPO_ROOT}/tezos/ac-login.mjs" >&2
7575+ fi
7676+ fi
7777+fi
7878+7979+# --- step 2: re-exec under sudo ---
8080+# Needs root for diskutil/sgdisk/dd/newfs_msdos/mount_msdos. sudoers.d/
8181+# ac-flash-mac whitelists this exact path NOPASSWD.
8282+if [ "$(id -u)" != "0" ]; then
8383+ exec sudo --preserve-env=PATH "$0" "$@"
8484+fi
8585+4986KERNEL="${SRC_DIR}/vmlinuz"
5087INITRAMFS="${SRC_DIR}/initramfs.cpio.gz"
5188SPLASH_EFI="${NATIVE_DIR}/bootloader/splash.efi"
···71108TOKEN_FILE="${TOKEN_HOME}/.ac-token"
7210973110USER_HANDLE=""; USER_SUB=""; USER_EMAIL=""
111111+AC_ACCESS_TOKEN=""; AC_TOKEN_EXPIRED=0
74112if [ -f "${TOKEN_FILE}" ] && command -v node >/dev/null 2>&1; then
75113 eval "$(node -e '
76114 const t = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
77115 let h = t.user?.handle || t.user?.name || "";
78116 if (h.startsWith("@")) h = h.slice(1);
117117+ const now = Date.now();
118118+ const rawExp = t.expires_at || 0;
119119+ const expMs = rawExp > 10_000_000_000 ? rawExp : rawExp * 1000;
120120+ const fresh = expMs && now < expMs;
79121 const out = (k, v) => process.stdout.write(`${k}=${JSON.stringify(v || "")}\n`);
8080- out("USER_HANDLE", h);
8181- out("USER_SUB", t.user?.sub);
8282- out("USER_EMAIL", t.user?.email);
122122+ out("USER_HANDLE", h);
123123+ out("USER_SUB", t.user?.sub);
124124+ out("USER_EMAIL", t.user?.email);
125125+ out("AC_ACCESS_TOKEN", fresh ? (t.access_token || "") : "");
126126+ process.stdout.write(`AC_TOKEN_EXPIRED=${fresh ? 0 : 1}\n`);
83127 ' "${TOKEN_FILE}" 2>/dev/null)"
84128 [ -n "${USER_HANDLE}" ] && log "Authenticated as @${USER_HANDLE}"
85129fi
86130[ -z "${USER_HANDLE}${USER_SUB}${USER_EMAIL}" ] && \
87131 log "No ~/.ac-token (run \`ac-login\` first to bake credentials in)"
88132133133+# --- fetch Claude OAuth token + GitHub PAT from MongoDB ---
134134+# /api/claude-token returns { handle, token, githubPat } from @handles.
135135+# `token` is the year-long Claude Code OAuth bearer (sk-ant-...) — pty.c
136136+# reads /claude-token at spawn time and sets CLAUDE_CODE_OAUTH_TOKEN so
137137+# `claude` launches without interactive login. `githubPat` lets on-device
138138+# git push to the GitHub mirror without SSH.
139139+CLAUDE_TOKEN=""
140140+GITHUB_PAT=""
141141+if [ -n "${AC_ACCESS_TOKEN}" ]; then
142142+ log "Fetching Claude token + GitHub PAT from MongoDB…"
143143+ CT_RESP="$(curl -fsS -H "Authorization: Bearer ${AC_ACCESS_TOKEN}" \
144144+ "https://aesthetic.computer/api/claude-token" 2>/dev/null || echo "")"
145145+ if [ -n "${CT_RESP}" ]; then
146146+ eval "$(node -e '
147147+ try {
148148+ const r = JSON.parse(process.argv[1]);
149149+ const out = (k, v) => process.stdout.write(`${k}=${JSON.stringify(v || "")}\n`);
150150+ out("CLAUDE_TOKEN", r.token);
151151+ out("GITHUB_PAT", r.githubPat);
152152+ } catch {}
153153+ ' "${CT_RESP}" 2>/dev/null)"
154154+ fi
155155+ if [ -n "${CLAUDE_TOKEN}" ]; then
156156+ log " claude token: ${#CLAUDE_TOKEN} bytes"
157157+ else
158158+ log " claude token: none stored in MongoDB (POST to /api/claude-token to save)"
159159+ fi
160160+ [ -n "${GITHUB_PAT}" ] && log " github pat: ${#GITHUB_PAT} bytes"
161161+elif [ "${AC_TOKEN_EXPIRED}" = "1" ]; then
162162+ log " creds fetch: skipped (~/.ac-token expired — run \`ac-login\` to refresh)"
163163+fi
164164+165165+# --- bake creds into initramfs via concatenated cpio archive ---
166166+# The Linux kernel's unpack_to_rootfs() accepts multiple concatenated
167167+# gzipped cpio archives in the initrd stream (same trick intel-ucode
168168+# uses). We build a tiny supplementary archive containing the baked
169169+# files and append it to the end of initramfs.cpio.gz.
170170+# Files baked (matches ac-os Linux layout):
171171+# /claude-token plain bearer — pty.c reads + sets CLAUDE_CODE_OAUTH_TOKEN
172172+# /claude-state.json init copies to /tmp/.claude.json (skips CC onboarding)
173173+# /github-pat plain bearer — pty.c sets GITHUB_TOKEN
174174+if [ -n "${CLAUDE_TOKEN}${GITHUB_PAT}" ]; then
175175+ BAKE_DIR="$(mktemp -d /tmp/ac-bake.XXXXXX)"
176176+ BAKED_INITRAMFS="/tmp/ac-initramfs-baked.$$.cpio.gz"
177177+ ORIG_INITRD_SIZE="$(stat -f%z "${INITRAMFS}")"
178178+ BAKE_LIST=""
179179+ if [ -n "${CLAUDE_TOKEN}" ]; then
180180+ printf %s "${CLAUDE_TOKEN}" > "${BAKE_DIR}/claude-token"
181181+ chmod 600 "${BAKE_DIR}/claude-token"
182182+ cat > "${BAKE_DIR}/claude-state.json" <<STATE
183183+{"oauthAccount":{"emailAddress":"${USER_EMAIL}","organizationName":"","accountUuid":""},"hasCompletedOnboarding":true,"installMethod":"manual","numStartups":1,"autoUpdates":false,"autoUpdatesProtectedForNative":true}
184184+STATE
185185+ BAKE_LIST="${BAKE_LIST}claude-token
186186+claude-state.json
187187+"
188188+ fi
189189+ if [ -n "${GITHUB_PAT}" ]; then
190190+ printf %s "${GITHUB_PAT}" > "${BAKE_DIR}/github-pat"
191191+ chmod 600 "${BAKE_DIR}/github-pat"
192192+ BAKE_LIST="${BAKE_LIST}github-pat
193193+"
194194+ fi
195195+ cp "${INITRAMFS}" "${BAKED_INITRAMFS}"
196196+ ( cd "${BAKE_DIR}" && printf '%s' "${BAKE_LIST}" \
197197+ | cpio -o -H newc 2>/dev/null ) \
198198+ | gzip -9 >> "${BAKED_INITRAMFS}" \
199199+ || die "Failed to append creds cpio to initramfs"
200200+ rm -rf "${BAKE_DIR}"
201201+ INITRAMFS="${BAKED_INITRAMFS}"
202202+ NEW_INITRD_SIZE="$(stat -f%z "${INITRAMFS}")"
203203+ log " baked initramfs: ${NEW_INITRD_SIZE} bytes (+$(( NEW_INITRD_SIZE - ORIG_INITRD_SIZE )) bytes for creds cpio)"
204204+fi
205205+89206# --- preserve existing wifi_creds.json from target USB before we wipe it ---
90207# Linux `ac-os flash` does this via ac_media_merge_wifi_creds: read the
91208# previously-flashed USB for user-added networks, then merge with the
···100217 log "Preserving wifi_creds.json from ${mnt}" && break
101218 fi
102219done
103103-trap "rm -f '${PRESERVE_WIFI}' 2>/dev/null" EXIT
220220+trap "rm -f '${PRESERVE_WIFI}' '${BAKED_INITRAMFS:-}' 2>/dev/null" EXIT
104221105222# --- hardcoded preset networks (kept in sync with media-layout.sh + src/wifi.c) ---
106223WIFI_PRESETS_JSON='[
···184301# --- mount ---
185302M1=$(mktemp -d /tmp/ac-main.XXXXXX)
186303M2=$(mktemp -d /tmp/ac-efi.XXXXXX)
187187-trap "umount '${M1}' 2>/dev/null; umount '${M2}' 2>/dev/null; rmdir '${M1}' '${M2}' 2>/dev/null; true" EXIT
304304+trap "umount '${M1}' 2>/dev/null; umount '${M2}' 2>/dev/null; rmdir '${M1}' '${M2}' 2>/dev/null; rm -f '${PRESERVE_WIFI}' '${BAKED_INITRAMFS:-}' 2>/dev/null; true" EXIT
188305189306log "Mounting partitions…"
190307mount_msdos "${P1}" "${M1}"
···195312mkdir -p "${M1}/EFI/BOOT"
196313cp "${KERNEL}" "${M1}/EFI/BOOT/BOOTX64.EFI"
197314cp "${INITRAMFS}" "${M1}/initramfs.cpio.gz"
198198-printf '{"handle":"%s","piece":"notepat","sub":"%s","email":"%s"}\n' \
315315+printf '{"handle":"%s","piece":"notepat","sub":"%s","email":"%s","udpMidiBroadcast":true}\n' \
199316 "${USER_HANDLE}" "${USER_SUB}" "${USER_EMAIL}" | tee "${M1}/config.json" >/dev/null
200317201318# Build merged wifi_creds.json (presets + preserved + optional override)
+58
fedac/native/src/audio.c
···14341434 audio->glitch_mix += (audio->target_glitch_mix - audio->glitch_mix) * 0.00005f;
14351435 }
1436143614371437+ // Smooth master volume + drive toward target (same 1s time const)
14381438+ if (audio->master_volume != audio->target_master_volume) {
14391439+ audio->master_volume += (audio->target_master_volume - audio->master_volume) * 0.00005f;
14401440+ }
14411441+ if (audio->drive_mix != audio->target_drive_mix) {
14421442+ audio->drive_mix += (audio->target_drive_mix - audio->drive_mix) * 0.00005f;
14431443+ }
14441444+14371445 // Save dry signal before FX chain
14381446 double dry_l = mix_l, dry_r = mix_r;
14391447···15721580 mix_l *= reduction;
15731581 mix_r *= reduction;
15741582 }
15831583+ }
15841584+15851585+ // User-controlled drive (tanh soft-saturation) BEFORE system
15861586+ // volume so the harmonic character is independent of hardware
15871587+ // gain. drive_mix is a dry/wet blend: 0 = pure bypass, 1 = fully
15881588+ // driven (pre-gain × 6 into tanh, attenuated back to roughly
15891589+ // unity peak). At mid settings you get pleasing tube-ish warmth.
15901590+ if (audio->drive_mix > 0.001f) {
15911591+ float dm = audio->drive_mix;
15921592+ float pre_gain = 1.0f + dm * 5.0f;
15931593+ double driven_l = tanh(mix_l * pre_gain) * 0.8;
15941594+ double driven_r = tanh(mix_r * pre_gain) * 0.8;
15951595+ mix_l = mix_l * (1.0 - dm) + driven_l * dm;
15961596+ mix_r = mix_r * (1.0 - dm) + driven_r * dm;
15971597+ }
15981598+15991599+ // User-controlled master volume (0..2 = 0..200%). Applied after
16001600+ // drive so the slider feels like a "louder/quieter" control that
16011601+ // doesn't change the tone character the user dialled in.
16021602+ {
16031603+ float mv = audio->master_volume;
16041604+ mix_l *= mv;
16051605+ mix_r *= mv;
15751606 }
1576160715771608 // Apply system volume (software gain). system_volume can go
···17971828 audio->target_glitch_mix = 0.0f;
17981829 audio->fx_mix = 1.0f; // FX chain fully wet by default
17991830 audio->target_fx_mix = 1.0f;
18311831+ // User master volume starts at 1.0 (unity gain) — the pre-existing
18321832+ // system_volume path still provides the hardware mixer control, so
18331833+ // this is a per-user soft gain on top.
18341834+ audio->master_volume = 1.0f;
18351835+ audio->target_master_volume = 1.0f;
18361836+ audio->drive_mix = 0.0f; // Clean bypass until user dials drive
18371837+ audio->target_drive_mix = 0.0f;
18001838 audio->room_buf_l = calloc(ROOM_SIZE, sizeof(float));
18011839 audio->room_buf_r = calloc(ROOM_SIZE, sizeof(float));
18021840···29773015 if (mix < 0.0f) mix = 0.0f;
29783016 if (mix > 1.0f) mix = 1.0f;
29793017 audio->target_fx_mix = mix;
30183018+}
30193019+30203020+// User-exposed master gain. Range 0..2 (200%) — above that you're almost
30213021+// certainly just hitting soft_clip and colouring the signal, so clamp
30223022+// before that to avoid giving false "louder" feedback in the UI slider.
30233023+void audio_set_master_volume(ACAudio *audio, float value) {
30243024+ if (!audio) return;
30253025+ if (value < 0.0f) value = 0.0f;
30263026+ if (value > 2.0f) value = 2.0f;
30273027+ audio->target_master_volume = value;
30283028+}
30293029+30303030+// Drive amount 0..1 dry/wet blend. 0 = clean bypass, 1 = fully driven
30313031+// (pre-gain × 6 into tanh, attenuated back). Smoothed per-sample so
30323032+// sliding the fader doesn't audibly zipper.
30333033+void audio_set_drive_mix(ACAudio *audio, float value) {
30343034+ if (!audio) return;
30353035+ if (value < 0.0f) value = 0.0f;
30363036+ if (value > 1.0f) value = 1.0f;
30373037+ audio->target_drive_mix = value;
29803038}
2981303929823040// --- Hot-mic capture thread ---
+14
fedac/native/src/audio.h
···253253 float fx_mix; // 0.0 = fully dry, 1.0 = fully wet (smoothed)
254254 float target_fx_mix; // target (set by JS, smoothed per sample)
255255256256+ // User-controlled master output gain (applied right before soft_clip).
257257+ // Defaults to 1.0; 0.0 silent; >1.0 amplifies (use carefully — soft_clip
258258+ // still protects against speaker-blowing peaks).
259259+ float master_volume;
260260+ float target_master_volume;
261261+262262+ // Drive / tanh soft-saturation (dry/wet blend). 0.0 = clean pass-through,
263263+ // 1.0 = fully driven (pre-gain 6× → tanh → attenuation). Adds harmonic
264264+ // warmth at low settings and obvious distortion at high settings.
265265+ float drive_mix;
266266+ float target_drive_mix;
267267+256268 // System mixer volume (0-100 percent)
257269 int system_volume;
258270 int card_index; // ALSA card number (0 or 1)
···392404void audio_set_room_mix(ACAudio *audio, float mix);
393405void audio_set_glitch_mix(ACAudio *audio, float mix);
394406void audio_set_fx_mix(ACAudio *audio, float mix);
407407+void audio_set_master_volume(ACAudio *audio, float value);
408408+void audio_set_drive_mix(ACAudio *audio, float value);
395409396410// Microphone — hot-mic mode (device stays open, recording toggles buffering)
397411int audio_mic_open(ACAudio *audio); // open device + start hot-mic thread
+25-10
fedac/native/src/graph.c
···488488 g->fb = target ? target : g->screen;
489489}
490490491491+// Module-level cache so repeat calls with the same text don't re-run
492492+// qrcodegen_encodeText (Reed-Solomon + masking — multi-ms on slow CPUs).
493493+// Notepat calls qr("https://notepat.com", ...) every paint frame; caching
494494+// drops the cost to ~1 memcmp + the draw loop.
495495+static char qr_cache_text[256] = {0};
496496+static uint8_t qr_cache_buf[qrcodegen_BUFFER_LEN_FOR_VERSION(10)];
497497+static int qr_cache_size = 0;
498498+491499void graph_qr(ACGraph *g, const char *text, int x, int y, int scale) {
492500 if (!g || !text || !text[0]) return;
493501 if (scale < 1) scale = 1;
494502495495- uint8_t qr_buf[qrcodegen_BUFFER_LEN_FOR_VERSION(10)];
496496- uint8_t tmp_buf[qrcodegen_BUFFER_LEN_FOR_VERSION(10)];
497497-498498- if (!qrcodegen_encodeText(text, tmp_buf, qr_buf,
499499- qrcodegen_Ecc_LOW, qrcodegen_VERSION_MIN, 10,
500500- qrcodegen_Mask_AUTO, true)) {
501501- return; // encode failed (text too long for version 10)
503503+ // Cache hit: skip the encode entirely.
504504+ if (qr_cache_size == 0 ||
505505+ strncmp(qr_cache_text, text, sizeof(qr_cache_text)) != 0) {
506506+ uint8_t tmp_buf[qrcodegen_BUFFER_LEN_FOR_VERSION(10)];
507507+ if (!qrcodegen_encodeText(text, tmp_buf, qr_cache_buf,
508508+ qrcodegen_Ecc_LOW, qrcodegen_VERSION_MIN, 10,
509509+ qrcodegen_Mask_AUTO, true)) {
510510+ qr_cache_size = 0;
511511+ qr_cache_text[0] = '\0';
512512+ return;
513513+ }
514514+ qr_cache_size = qrcodegen_getSize(qr_cache_buf);
515515+ strncpy(qr_cache_text, text, sizeof(qr_cache_text) - 1);
516516+ qr_cache_text[sizeof(qr_cache_text) - 1] = '\0';
502517 }
503518504504- int size = qrcodegen_getSize(qr_buf);
505505- int margin = 2; // quiet zone
519519+ const int size = qr_cache_size;
520520+ const int margin = 2; // quiet zone
506521507522 // Draw white background with margin
508523 int total = (size + margin * 2) * scale;
···514529 graph_ink(g, (ACColor){0, 0, 0, 255});
515530 for (int qy = 0; qy < size; qy++) {
516531 for (int qx = 0; qx < size; qx++) {
517517- if (qrcodegen_getModule(qr_buf, qx, qy)) {
532532+ if (qrcodegen_getModule(qr_cache_buf, qx, qy)) {
518533 graph_box(g, x + (qx + margin) * scale, y + (qy + margin) * scale,
519534 scale, scale, 1);
520535 }
+227
fedac/native/src/js-bindings.c
···11841184 return JS_UNDEFINED;
11851185}
1186118611871187+// sound.volume.setMix(value) — user-controlled master output gain (0..2)
11881188+static JSValue js_set_master_volume(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
11891189+ (void)this_val;
11901190+ if (argc < 1 || !current_rt->audio) return JS_UNDEFINED;
11911191+ double v;
11921192+ JS_ToFloat64(ctx, &v, argv[0]);
11931193+ audio_set_master_volume(current_rt->audio, (float)v);
11941194+ return JS_UNDEFINED;
11951195+}
11961196+11971197+// sound.drive.setMix(value) — tanh soft-clip dry/wet blend (0..1)
11981198+static JSValue js_set_drive_mix(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
11991199+ (void)this_val;
12001200+ if (argc < 1 || !current_rt->audio) return JS_UNDEFINED;
12011201+ double v;
12021202+ JS_ToFloat64(ctx, &v, argv[0]);
12031203+ audio_set_drive_mix(current_rt->audio, (float)v);
12041204+ return JS_UNDEFINED;
12051205+}
12061206+11871207// sound.microphone.open() — open device + start hot-mic thread
11881208static JSValue js_mic_open(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
11891209 (void)this_val; (void)argc; (void)argv;
···15661586 audio_replay_load_data(audio, data, len, rate);
15671587 JS_FreeValue(ctx, ab);
15681588 return JS_TRUE;
15891589+}
15901590+15911591+// sound.speaker.drawStrip(x, y, w, h, seconds, needleFrac, viewOffsetSec)
15921592+// Renders a scrolling waveform strip in one C call. The needle stays at
15931593+// `needleFrac` (0.0..1.0) of the strip width and represents the current
15941594+// PLAYBACK CURSOR — what's being heard right now. The wave never jumps:
15951595+// it continuously drifts based on how the cursor moves through the buffer
15961596+// across frames.
15971597+//
15981598+// viewOffsetSec controls the cursor's position relative to "now":
15991599+//
16001600+// 0.0 → cursor sits at the latest captured sample. Past audio
16011601+// extends to the LEFT of the needle. Right of needle is empty
16021602+// (no future). As real time advances, freshly-captured samples
16031603+// appear at the needle and the wave drifts LEFT — the natural
16041604+// scroll for live capture.
16051605+//
16061606+// > 0 → cursor is OFFSET-seconds in the past. Left of needle still
16071607+// shows the LEFT-PAST (older than cursor). Right of needle
16081608+// shows the RIGHT-PAST (samples captured AFTER the cursor
16091609+// position, up to the live edge). As JS grows the offset (e.g.
16101610+// while spacebar is held), the cursor retreats further into
16111611+// the past and the wave appears to drift RIGHT — exactly
16121612+// matching a backwards-replay scrub.
16131613+//
16141614+// shrinking offset on release → cursor catches back up to "now" and
16151615+// the wave drifts LEFT (forward) until the right side empties
16161616+// and we're back in live capture mode.
16171617+//
16181618+// Per-pixel peak math is C-native (no JS loop overhead); the audio ring
16191619+// slice is copied under audio->lock then drawn unlocked.
16201620+static JSValue js_speaker_draw_strip(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
16211621+ (void)this_val;
16221622+ if (!current_rt || argc < 4) return JS_UNDEFINED;
16231623+ ACAudio *audio = current_rt->audio;
16241624+ ACGraph *g = current_rt->graph;
16251625+ if (!audio || !audio->output_history_buf || audio->output_history_size <= 0 || !g) {
16261626+ return JS_UNDEFINED;
16271627+ }
16281628+16291629+ int x = 0, y = 0, w = 0, h = 0;
16301630+ JS_ToInt32(ctx, &x, argv[0]);
16311631+ JS_ToInt32(ctx, &y, argv[1]);
16321632+ JS_ToInt32(ctx, &w, argv[2]);
16331633+ JS_ToInt32(ctx, &h, argv[3]);
16341634+ double seconds = 4.0;
16351635+ if (argc >= 5 && JS_IsNumber(argv[4])) JS_ToFloat64(ctx, &seconds, argv[4]);
16361636+ double needle_frac = 0.5;
16371637+ if (argc >= 6 && JS_IsNumber(argv[5])) JS_ToFloat64(ctx, &needle_frac, argv[5]);
16381638+ double view_offset_sec = 0.0;
16391639+ if (argc >= 7 && JS_IsNumber(argv[6])) JS_ToFloat64(ctx, &view_offset_sec, argv[6]);
16401640+ if (view_offset_sec < 0.0) view_offset_sec = 0.0;
16411641+16421642+ if (w <= 2 || h <= 2 || seconds <= 0.0) return JS_UNDEFINED;
16431643+ if (needle_frac < 0.0) needle_frac = 0.0;
16441644+ if (needle_frac > 1.0) needle_frac = 1.0;
16451645+16461646+ int needle_off = (int)((double)w * needle_frac + 0.5);
16471647+ if (needle_off < 0) needle_off = 0;
16481648+ if (needle_off >= w) needle_off = w - 1;
16491649+ int needle_x = x + needle_off;
16501650+16511651+ // Compute the time window the strip needs to span:
16521652+ // [cursor - left_seconds, cursor + right_seconds]
16531653+ // where cursor = now - view_offset_sec, and the LEFT/RIGHT widths in
16541654+ // seconds are proportional to the LEFT/RIGHT pixel widths so each
16551655+ // pixel covers the same temporal slice end-to-end.
16561656+ int left_w = needle_off;
16571657+ int right_w = w - needle_off;
16581658+ double total_w = (double)w;
16591659+ double left_seconds = seconds * ((double)left_w / total_w);
16601660+ double right_seconds_max = seconds * ((double)right_w / total_w);
16611661+ // Right side only fills to view_offset_sec — no future audio exists.
16621662+ double right_seconds = view_offset_sec < right_seconds_max ? view_offset_sec : right_seconds_max;
16631663+16641664+ pthread_mutex_lock(&audio->lock);
16651665+ unsigned int rate = audio->output_history_rate ? audio->output_history_rate
16661666+ : AUDIO_OUTPUT_HISTORY_RATE;
16671667+ int hist_size = audio->output_history_size;
16681668+ uint64_t write_pos = audio->output_history_write_pos;
16691669+ int available = write_pos < (uint64_t)hist_size ? (int)write_pos : hist_size;
16701670+16711671+ // cursor_pos: ring index of "where the playhead sits" in samples.
16721672+ // = write_pos - offset_samples
16731673+ uint64_t offset_samples = (uint64_t)(view_offset_sec * (double)rate + 0.5);
16741674+ if (offset_samples > write_pos) offset_samples = write_pos;
16751675+ uint64_t cursor_pos = write_pos - offset_samples;
16761676+16771677+ // Snapshot the LEFT-past slice [cursor - left_seconds, cursor]
16781678+ int left_samples_want = (int)(left_seconds * (double)rate + 0.5);
16791679+ if (left_samples_want < 1) left_samples_want = 1;
16801680+ if ((uint64_t)left_samples_want > cursor_pos) left_samples_want = (int)cursor_pos;
16811681+ if (left_samples_want > available) left_samples_want = available;
16821682+16831683+ // Snapshot the RIGHT-past slice [cursor, cursor + right_seconds]
16841684+ int right_samples_want = (int)(right_seconds * (double)rate + 0.5);
16851685+ if (right_samples_want < 0) right_samples_want = 0;
16861686+ if ((uint64_t)right_samples_want > write_pos - cursor_pos) {
16871687+ right_samples_want = (int)(write_pos - cursor_pos);
16881688+ }
16891689+16901690+ float *left_copy = NULL;
16911691+ float *right_copy = NULL;
16921692+ if (left_samples_want > 0) {
16931693+ left_copy = (float *)malloc((size_t)left_samples_want * sizeof(float));
16941694+ if (left_copy) {
16951695+ uint64_t start = cursor_pos - (uint64_t)left_samples_want;
16961696+ for (int i = 0; i < left_samples_want; i++) {
16971697+ left_copy[i] = audio->output_history_buf[(start + (uint64_t)i) % (uint64_t)hist_size];
16981698+ }
16991699+ }
17001700+ }
17011701+ if (right_samples_want > 0) {
17021702+ right_copy = (float *)malloc((size_t)right_samples_want * sizeof(float));
17031703+ if (right_copy) {
17041704+ uint64_t start = cursor_pos;
17051705+ for (int i = 0; i < right_samples_want; i++) {
17061706+ right_copy[i] = audio->output_history_buf[(start + (uint64_t)i) % (uint64_t)hist_size];
17071707+ }
17081708+ }
17091709+ }
17101710+ pthread_mutex_unlock(&audio->lock);
17111711+17121712+ int midY = y + h / 2;
17131713+ int amp = (int)((double)h * 0.45);
17141714+ if (amp < 1) amp = 1;
17151715+ ACColor saved = g->ink;
17161716+17171717+ // Background + zero-line
17181718+ graph_ink(g, (ACColor){20, 15, 30, 160});
17191719+ graph_box(g, x, y, w, h, 1);
17201720+ graph_ink(g, (ACColor){80, 80, 90, 120});
17211721+ graph_line(g, x, midY, x + w - 1, midY);
17221722+17231723+ // Inner draw helper: render `len` samples across `pixel_w` pixels
17241724+ // starting at draw_x, with column 0 = oldest sample.
17251725+ #define DRAW_SLICE(buf, len, pixel_w, draw_x_off) do { \
17261726+ if ((buf) && (len) > 0 && (pixel_w) > 0) { \
17271727+ double spc = (double)(len) / (double)(pixel_w); \
17281728+ for (int col = 0; col < (pixel_w); col++) { \
17291729+ int i0 = (int)((double)col * spc); \
17301730+ int i1 = (int)((double)(col + 1) * spc); \
17311731+ if (i1 > (len)) i1 = (len); \
17321732+ if (i0 < 0) i0 = 0; \
17331733+ if (i1 <= i0) continue; \
17341734+ float peak = 0.0f; \
17351735+ for (int i = i0; i < i1; i++) { \
17361736+ float a = (buf)[i]; \
17371737+ if (a < 0) a = -a; \
17381738+ if (a > peak) peak = a; \
17391739+ } \
17401740+ if (peak > 1.0f) peak = 1.0f; \
17411741+ int bar_h = (int)(peak * (float)amp + 0.5f); \
17421742+ if (bar_h < 1) bar_h = 1; \
17431743+ int r = 120 + (int)(peak * 140.0f + 0.5f); if (r > 255) r = 255; \
17441744+ int gc = 120 + (int)(peak * 80.0f + 0.5f); if (gc > 255) gc = 255; \
17451745+ int b_ = 90 + (int)((1.0f - peak) * 120.0f + 0.5f); if (b_ > 255) b_ = 255; \
17461746+ graph_ink(g, (ACColor){(uint8_t)r, (uint8_t)gc, (uint8_t)b_, 220}); \
17471747+ int dx = x + (draw_x_off) + col; \
17481748+ graph_line(g, dx, midY - bar_h, dx, midY + bar_h); \
17491749+ } \
17501750+ } \
17511751+ } while (0)
17521752+17531753+ // LEFT half: samples cover full left_w pixels (oldest at column 0).
17541754+ DRAW_SLICE(left_copy, left_samples_want, left_w, 0);
17551755+17561756+ // RIGHT half: samples cover only the portion proportional to view_offset.
17571757+ // If offset is small, the right half is mostly empty (background shows
17581758+ // through). If offset reaches max, right half is fully drawn.
17591759+ int right_pixels_filled = right_samples_want > 0
17601760+ ? (int)((double)right_samples_want / (double)rate / right_seconds_max * (double)right_w + 0.5)
17611761+ : 0;
17621762+ if (right_pixels_filled > right_w) right_pixels_filled = right_w;
17631763+ DRAW_SLICE(right_copy, right_samples_want, right_pixels_filled, needle_off);
17641764+17651765+ #undef DRAW_SLICE
17661766+17671767+ if (left_copy) free(left_copy);
17681768+ if (right_copy) free(right_copy);
17691769+17701770+ // Playhead needle — draw last so it sits on top of the bars. Color
17711771+ // tints toward orange when the cursor is offset (replay mode) so it's
17721772+ // visually distinct from the live red needle.
17731773+ if (view_offset_sec > 0.001) {
17741774+ graph_ink(g, (ACColor){255, 160, 60, 230});
17751775+ } else {
17761776+ graph_ink(g, (ACColor){240, 80, 80, 220});
17771777+ }
17781778+ graph_line(g, needle_x, y, needle_x, y + h - 1);
17791779+17801780+ g->ink = saved;
17811781+ return JS_UNDEFINED;
15691782}
1570178315711784// sound.speaker.getRecentBuffer(seconds) -> { data: Float32Array, rate: number }
···26262839 JS_SetPropertyStr(ctx, speaker, "poll", JS_NewCFunction(ctx, js_noop, "poll", 0));
26272840 JS_SetPropertyStr(ctx, speaker, "getRecentBuffer",
26282841 JS_NewCFunction(ctx, js_speaker_get_recent_buffer, "getRecentBuffer", 1));
28422842+ JS_SetPropertyStr(ctx, speaker, "drawStrip",
28432843+ JS_NewCFunction(ctx, js_speaker_draw_strip, "drawStrip", 7));
26292844 JS_SetPropertyStr(ctx, speaker, "sampleRate",
26302845 JS_NewInt32(ctx, rt->audio ? (int)rt->audio->actual_rate : AUDIO_SAMPLE_RATE));
26312846···26972912 JS_SetPropertyStr(ctx, fx, "setMix", JS_NewCFunction(ctx, js_set_fx_mix, "setMix", 1));
26982913 JS_SetPropertyStr(ctx, fx, "mix", JS_NewFloat64(ctx, rt->audio ? rt->audio->fx_mix : 1.0));
26992914 JS_SetPropertyStr(ctx, sound, "fx", fx);
29152915+29162916+ // volume (user master output gain)
29172917+ JSValue volume = JS_NewObject(ctx);
29182918+ JS_SetPropertyStr(ctx, volume, "setMix", JS_NewCFunction(ctx, js_set_master_volume, "setMix", 1));
29192919+ JS_SetPropertyStr(ctx, volume, "mix", JS_NewFloat64(ctx, rt->audio ? rt->audio->master_volume : 1.0));
29202920+ JS_SetPropertyStr(ctx, sound, "volume", volume);
29212921+29222922+ // drive (tanh soft-clip saturation)
29232923+ JSValue drive = JS_NewObject(ctx);
29242924+ JS_SetPropertyStr(ctx, drive, "setMix", JS_NewCFunction(ctx, js_set_drive_mix, "setMix", 1));
29252925+ JS_SetPropertyStr(ctx, drive, "mix", JS_NewFloat64(ctx, rt->audio ? rt->audio->drive_mix : 0.0));
29262926+ JS_SetPropertyStr(ctx, sound, "drive", drive);
2700292727012928 // microphone
27022929 JSValue mic = JS_NewObject(ctx);
+3-3
gigs/are-na-annual-vol-8/README.md
···66- **Submission form:** https://aredotna.notion.site/3178a0f816d9815abdf3cb1624bb9e88
77- **Deadline:** Monday, April 20, 2026 — 11:59pm EST
88- **Honorarium:** $200 (published pieces, book releases December 2026)
99-- **Submitted channel:** [the score that teaches itself](https://www.are.na/aesthetic-computer/the-score-that-teaches-itself) — 68 blocks
99+- **Submitted channel:** [Self-Teaching Scores](https://www.are.na/aesthetic-computer/self-teaching-scores) — 68 blocks
10101111## Pitch (tightened, ~170 words)
12121313-> **Channel:** *The Score That Teaches Itself* — whistlegraphs alongside Cardew's *Treatise*, Cage's *Fontana Mix*, shape-note hymnals, Fluxus event scores, skateboard lines.
1313+> **Channel:** *Self-Teaching Scores* — whistlegraphs alongside Cardew's *Treatise*, Cage's *Fontana Mix*, shape-note hymnals, Fluxus event scores, skateboard lines.
1414>
1515> I want to write about whistlegraph, a drawing form I invented in 2019 where every mark is a sung syllable. Between 2019 and 2023 it reached 2.6 million TikTok followers with no paid promotion and no trend-jacking. The distribution model was the form itself: a score legible enough that watching, learning, and performing collapse into a single gesture.
1616>
···51515252Two scripts, both auth via `ARENA_TOKEN` env var. A personal access token is created at https://dev.are.na/oauth/applications → any app → "Access Token." The OAuth code-exchange path used to originally mint this token is described in [reference_arena.md](../../../../../../.claude/projects/-Users-jas-aesthetic-computer/memory/reference_arena.md) (local auto-memory, not in the repo).
53535454-- [seed-channel.mjs](seed-channel.mjs) — posts 68 blocks to `the-score-that-teaches-itself` in reverse reading order (so whistlegraph lands on top).
5454+- [seed-channel.mjs](seed-channel.mjs) — posts 68 blocks to `self-teaching-scores` in reverse reading order (so whistlegraph lands on top).
5555- [set-descriptions.mjs](set-descriptions.mjs) — walks the channel and PUTs a per-block description from the lookup map.
56565757```sh
+2-2
gigs/are-na-annual-vol-8/channel-blocks.md
···11-# Channel — the score that teaches itself
11+# Channel — Self-Teaching Scores
2233-68 blocks on https://www.are.na/aesthetic-computer/the-score-that-teaches-itself, organized bottom-up (reading order on the channel page is top-down, so this list reads in the order the reader encounters the channel from top).
33+68 blocks on https://www.are.na/aesthetic-computer/self-teaching-scores, organized bottom-up (reading order on the channel page is top-down, so this list reads in the order the reader encounters the channel from top).
4455## §10 — Whistlegraph (top of channel)
66
+2-2
gigs/are-na-annual-vol-8/seed-channel.mjs
···11#!/usr/bin/env node
22-// Seed blocks into the Are.na channel "the-score-that-teaches-itself".
22+// Seed blocks into the Are.na channel "self-teaching-scores".
33// Blocks are added in reading-bottom → reading-top order so whistlegraph lands
44// first on the channel page.
55//
66// Usage: ARENA_TOKEN=... node seed-channel.mjs
7788const TOKEN = process.env.ARENA_TOKEN;
99-const SLUG = "the-score-that-teaches-itself";
99+const SLUG = "self-teaching-scores";
1010if (!TOKEN) { console.error("ARENA_TOKEN missing"); process.exit(1); }
11111212const blocks = [
···11+# Source swaps — remove wikipedia, add primary sources
22+33+current channel: 68 blocks · **52 are wikipedia** · 3 go to the wrong page entirely
44+55+## critical fixes (wikipedia redirected to unrelated page)
66+77+| # | current (broken) | swap to |
88+|---|---|---|
99+| 1 | `Renegade_(dance)` → goes to "Lottery (K Camp song)" | **NYT 2020 — Taylor Lorenz profile of Jalaiah Harmon**: https://www.nytimes.com/2020/02/13/style/the-original-renegade.html — the actual primary source for the story |
1010+| 33 | `Water_Yam` → goes to "Dioscorea alata" (yam tuber!) | **Monoskop — George Brecht, Water Yam**: https://monoskop.org/Water_Yam — scans + bibliography |
1111+| 31 | `Gahu` → goes to "Gahuiyeh, Kerman" (Iranian village) | **Ladzekpo / CNMAT archive** or **Mustapha Tettey Addy** – https://cnmat.berkeley.edu/library/cdr/ewa or drop for another drum tradition |
1212+| 52 | `moma.org/s/ge/curated_ge/styles/list_ge/artists/1191/` → Page not found | **MoMA — Notations (1969) catalog**: https://www.moma.org/calendar/exhibitions/3282 |
1313+1414+## graphic-score canon — swap to ubuweb, monoskop, foundations
1515+1616+| # | wiki | primary |
1717+|---|---|---|
1818+| 38 | Treatise (Cardew) | **Monoskop — Cornelius Cardew**: https://monoskop.org/Cornelius_Cardew |
1919+| 39 | Fontana Mix | **UbuWeb — Cage Fontana Mix**: https://www.ubu.com/sound/cage.html |
2020+| 40 | Aria (Cage) | **UbuWeb — Cage Aria**: https://www.ubu.com/film/cage_aria.html |
2121+| 41 | Concert for Piano and Orchestra (Cage) | **Edition Peters — Cage Concert**: https://www.edition-peters.com/product/concert-for-piano-and-orchestra/ep6705 |
2222+| 42 | Variations I (Cage) | **UbuWeb — Cage Variations**: https://www.ubu.com/historical/cage/ |
2323+| 43 | December 1952 (Earle Brown) | **Earle Brown Music Foundation**: https://earle-brown.org |
2424+| 44 | Morton Feldman | **CNVill Feldman archive**: https://cnvill.net/mfhome.htm |
2525+| 45 | Christian Wolff | **CNVill Wolff archive**: https://cnvill.net/mfwolff.htm |
2626+| 46 | Artikulation (Ligeti) | **UbuWeb — Wehinger listening score video**: https://www.ubu.com/film/wehinger_artikulation.html |
2727+| 47 | Metastaseis (Xenakis) | **Iannis Xenakis site / CIX**: https://www.iannis-xenakis.org |
2828+| 48 | Sonic Meditations (Oliveros) | **Pauline Oliveros Foundation / Deep Listening**: https://deeplistening.rpi.edu |
2929+| 49 | I Am Sitting in a Room | **UbuWeb — Lucier**: https://www.ubu.com/sound/lucier.html |
3030+| 50 | In C (Riley) | **Terry Riley**: https://terryriley.net |
3131+| 51 | Composition 1960 (La Monte Young) | **Mela Foundation**: https://www.melafoundation.org |
3232+| 53 | Notations 21 | **Mark Batty / Theresa Sauer**: https://www.notations21.com (or author site) |
3333+3434+## fluxus / event scores
3535+3636+| # | wiki | primary |
3737+|---|---|---|
3838+| 32 | Grapefruit (Yoko Ono) | **imagine-peace.com / Ono Grapefruit scans on Monoskop**: https://monoskop.org/images/c/c7/Ono_Yoko_Grapefruit_1971.pdf |
3939+| 34 | Dick Higgins | **Something Else Press archive / Estate of Dick Higgins**: http://www.dickhiggins.org |
4040+| 35 | An Anthology of Chance Operations | **Monoskop**: https://monoskop.org/An_Anthology_of_Chance_Operations |
4141+| 36 | Alison Knowles | **aknowles.com** (primary): https://www.aknowles.com |
4242+4343+## vernacular / folk notation
4444+4545+| # | wiki | primary |
4646+|---|---|---|
4747+| 24 | Shape note | **Fasola.org — Sacred Harp Musical Heritage**: https://fasola.org |
4848+| 25 | Sacred Harp | **Sacred Harp Publishing Co.**: https://originalsacredharp.com |
4949+| 26 | Tablature | **Lute Society of America**: https://lutesocietyofamerica.org (or guitar tablature primary source?) |
5050+| 27 | Neume | **Gregobase — Gregorian chant notation**: https://gregobase.selapa.net |
5151+| 28 | Jianpu | consider dropping, or: **Zhu Zaiyu / historical primer**; no strong primary online |
5252+| 29 | Gongche notation | consider dropping, or a Kunqu / Peking opera archive link |
5353+| 30 | Sargam | **ITC Sangeet Research Academy**: https://www.itcsra.org |
5454+5555+## body / movement notation
5656+5757+| # | wiki | primary |
5858+|---|---|---|
5959+| 14 | Labanotation | **Dance Notation Bureau**: https://dancenotation.org |
6060+| 15 | Eshkol–Wachman | **Noa Eshkol Foundation**: https://www.noaeshkol.org |
6161+| 16 | Benesh | **Royal Academy of Dance — Benesh**: https://www.royalacademyofdance.org/benesh |
6262+| 17 | Kata | **JKA — Japan Karate Association / kata catalog**: https://jka.or.jp |
6363+| 18 | American football plays | **Walsh's West Coast Offense playbook scans / NFL Coaches' resources** — or drop for a Bill Walsh interview |
6464+6565+## sport / line
6666+6767+| # | wiki | primary |
6868+|---|---|---|
6969+| 19 | Skateboarding | **Thrasher Mag**: https://www.thrashermagazine.com |
7070+| 20 | Dogtown and Z-Boys | **Stacy Peralta / Z-Boys documentary**: https://www.imdb.com/title/tt0275309/ or https://www.dogtownskateboards.com |
7171+| 21 | Surf break | **Surfline**: https://www.surfline.com |
7272+| 22 | Yardage book | **StrackaLine — modern yardage-book publisher**: https://strackaline.com |
7373+| 23 | Parkour | **ADAPT / parkour UK governing body**: https://parkour.uk |
7474+7575+## instructional / craft
7676+7777+| # | wiki | primary |
7878+|---|---|---|
7979+| 6 | Knitting abbreviations | **Craft Yarn Council standards**: https://www.craftyarncouncil.com/standards/knit-abbreviations |
8080+| 7 | Crease pattern | **Erik Demaine — origami and folding**: https://erikdemaine.org/origami |
8181+| 9 | Sewing pattern | **McCall's / Vogue pattern archive**: https://mccall.com |
8282+| 10 | IKEA | **IKEA assembly instructions library**: https://www.ikea.com/us/en/customer-service/services/assembly |
8383+| 11 | Lego | **LEGO building instructions archive**: https://www.lego.com/en-us/service/buildinginstructions |
8484+| 12 | Julia Child | **WGBH — The French Chef / American Archive of Public Broadcasting**: https://americanarchive.org/catalog?q=julia+child |
8585+| 13 | Japanese tea ceremony | **Urasenke**: https://www.urasenke.or.jp |
8686+8787+## viral / social kin
8888+8989+| # | current | primary |
9090+|---|---|---|
9191+| 2 | Harlem Shake (meme) | **Filthy Frank original video**: https://www.youtube.com/watch?v=384IUU43bfQ |
9292+| 3 | Know Your Meme — Squidward (404!) | **Squidward tutorial original video** (YouTube search needed) |
9393+| 4 | Pictogram | **Otl Aicher — Munich '72 pictogram archive**: https://www.designreviewed.com/otl-aicher-munich-1972 |
9494+| 5 | ISOTYPE | **Otto Neurath / Gerd Arntz Web Archive**: https://www.gerdarntz.org/isotype |
9595+9696+## additional blocks to *add* (from whistlegraph.tex citations)
9797+9898+these are specific, real, less cliché:
9999+100100+- **Goodiepal — El Camino del Hardcore** (ALKU 83, 2012) — https://alku.org/alku-83 or discogs
101101+- **Jacob Ciocci — "The Butterfly Effect / Rules Set You Free"** (Whistlegraph Zine, 2023) — zine catalog page if exists
102102+- **Asher Penn — Sex Magazine**: https://sexmagazine.us
103103+- **Dirt Magazine — "What is a Whistlegraph?"** (2023): https://dirt.fyi/article/2023/09/whistlegraph
104104+- **Rhizome — First Look: The Longest Whistlegraph Ever**: https://rhizome.org/editorial/2022/sep/13/first-look-the-longest-whistlegraph-ever-so-far
105105+- **Feral File — Ten Whistlegraphs exhibition page**: https://feralfile.com/exhibitions/ten-whistlegraphs-thv
106106+- **Schloss-Post — Manifesto for Radical Digital Painting** (2017): https://schloss-post.com/manifesto-radical-digital-painting
107107+- **Creative Independent — "Drawing is the Best Videogame"** (2019): https://thecreativeindependent.com/weekends/drawing-is-the-best-videogame-by-jeffrey-alan-scudder
108108+- **Paper Rad** (Jacob Ciocci): http://www.paperrad.org
109109+110110+## execution plan
111111+112112+1. `gigs/are-na-annual-vol-8/swap-sources.mjs` — read approved swaps, for each: delete old block from channel, post new URL with same description
113113+2. keep positions stable — post in reverse reading order so nothing rearranges
114114+3. text blocks (§9 framing, §6 Ono quote) are untouched — they're already primary
115115+116116+## what the `nearby` validator can't tell us without a token
117117+118118+are.na's `/v2/search/blocks` requires auth. once `ARENA_TOKEN` is exported, re-run `node validate-sources.mjs` and it will surface which URLs already have dozens of connections on are.na — those are the de-facto primaries.
+168
gigs/are-na-annual-vol-8/validate-sources.mjs
···11+#!/usr/bin/env node
22+// Validate the blocks in "self-teaching-scores":
33+// - flag every Wikipedia link (we want primary sources)
44+// - for each block, search are.na for existing uses of the same URL
55+// and of the topic name — so we can see what other people
66+// are connecting to for the same idea (usually the real source)
77+//
88+// Usage:
99+// ARENA_TOKEN=... node validate-sources.mjs # all blocks
1010+// ARENA_TOKEN=... node validate-sources.mjs --wiki # wiki only
1111+// ARENA_TOKEN=... node validate-sources.mjs --json > report.json
1212+//
1313+// ARENA_TOKEN is optional but greatly raises rate limits.
1414+1515+const SLUG = "self-teaching-scores";
1616+const TOKEN = process.env.ARENA_TOKEN;
1717+const API = "https://api.are.na/v2";
1818+1919+const args = new Set(process.argv.slice(2));
2020+const WIKI_ONLY = args.has("--wiki");
2121+const JSON_OUT = args.has("--json");
2222+2323+const headers = TOKEN
2424+ ? { Authorization: `Bearer ${TOKEN}` }
2525+ : {};
2626+2727+async function j(url) {
2828+ const r = await fetch(url, { headers });
2929+ if (!r.ok) throw new Error(`${r.status} ${r.statusText} — ${url}`);
3030+ return r.json();
3131+}
3232+3333+function isWiki(url) {
3434+ return url && /wikipedia\.org/i.test(url);
3535+}
3636+3737+function hostOf(url) {
3838+ try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return ""; }
3939+}
4040+4141+// Pull a short topic phrase out of a title like "Treatise (Cardew) - Wikipedia"
4242+function topicOf(title) {
4343+ if (!title) return "";
4444+ return title
4545+ .replace(/\s*[-–—|]\s*Wikipedia.*$/i, "")
4646+ .replace(/^\s+|\s+$/g, "");
4747+}
4848+4949+// Search are.na for any blocks whose source matches the URL (or title)
5050+// Returns an array of { url, host, count, sample: [{channelSlug, user}] }
5151+async function searchSimilar(block) {
5252+ const out = { exact: [], nearby: [] };
5353+ const url = block.source?.url;
5454+ const topic = topicOf(block.title) || block.generated_title || "";
5555+ // 1) exact URL — search /search/blocks for the URL itself
5656+ if (url) {
5757+ try {
5858+ const q = encodeURIComponent(url);
5959+ const r = await j(`${API}/search/blocks?q=${q}&per=10`);
6060+ for (const b of r.blocks || []) {
6161+ if (b.source?.url === url) {
6262+ out.exact.push({
6363+ id: b.id,
6464+ title: b.title || b.generated_title,
6565+ url: b.source.url,
6666+ connections: b.connections_count || 0,
6767+ });
6868+ }
6969+ }
7070+ } catch (e) { out.err = e.message; }
7171+ }
7272+ // 2) topic phrase — find the most-connected non-wiki blocks for the same idea
7373+ if (topic) {
7474+ try {
7575+ const q = encodeURIComponent(topic);
7676+ const r = await j(`${API}/search/blocks?q=${q}&per=20`);
7777+ const seen = new Set();
7878+ for (const b of r.blocks || []) {
7979+ const bu = b.source?.url;
8080+ if (!bu) continue;
8181+ if (seen.has(bu)) continue;
8282+ seen.add(bu);
8383+ if (bu === url) continue;
8484+ if (isWiki(bu)) continue;
8585+ out.nearby.push({
8686+ url: bu,
8787+ host: hostOf(bu),
8888+ title: b.title || b.generated_title,
8989+ connections: b.connections_count || 0,
9090+ });
9191+ }
9292+ out.nearby.sort((a, b) => b.connections - a.connections);
9393+ out.nearby = out.nearby.slice(0, 5);
9494+ } catch (e) { out.err = (out.err || "") + "; " + e.message; }
9595+ }
9696+ return out;
9797+}
9898+9999+function fmtReport(rows) {
100100+ const lines = [];
101101+ for (const row of rows) {
102102+ const flag = row.isWiki ? "🟡 WIKI" : "⚪ primary";
103103+ const url = row.url || "(text)";
104104+ const host = hostOf(url) || "—";
105105+ lines.push(`\n#${row.position} ${flag} [${host}]`);
106106+ lines.push(` title : ${row.title || row.content?.slice(0, 80) || "(empty)"}`);
107107+ lines.push(` url : ${url}`);
108108+ lines.push(` desc : ${(row.description || "").slice(0, 100)}`);
109109+ if (row.sim) {
110110+ if (row.sim.exact?.length) {
111111+ lines.push(` ↳ already on are.na: ${row.sim.exact.length} exact block(s), top connections: ${row.sim.exact.slice(0, 3).map(e => e.connections).join("/")}`);
112112+ } else if (url) {
113113+ lines.push(` ↳ not on are.na (exact URL) — fresh`);
114114+ }
115115+ if (row.sim.nearby?.length) {
116116+ lines.push(` ↳ alternate sources people connect for this topic:`);
117117+ for (const n of row.sim.nearby) {
118118+ lines.push(` • [${n.host}] ${n.connections}× conn — ${n.url}`);
119119+ }
120120+ }
121121+ }
122122+ }
123123+ return lines.join("\n");
124124+}
125125+126126+async function main() {
127127+ console.error(`fetching /channels/${SLUG}/contents …`);
128128+ const ch = await j(`${API}/channels/${SLUG}/contents?per=100&direction=desc`);
129129+ const blocks = ch.contents || [];
130130+ console.error(`got ${blocks.length} blocks${TOKEN ? "" : " (unauthed, rate limits apply)"}\n`);
131131+132132+ const rows = [];
133133+ for (const b of blocks) {
134134+ const url = b.source?.url;
135135+ const row = {
136136+ position: b.position,
137137+ class: b.class,
138138+ title: b.title,
139139+ content: b.content,
140140+ description: b.description,
141141+ url,
142142+ isWiki: isWiki(url),
143143+ };
144144+ if (WIKI_ONLY && !row.isWiki) { rows.push(row); continue; }
145145+ if (b.class === "Link") {
146146+ try {
147147+ row.sim = await searchSimilar(b);
148148+ process.stderr.write(".");
149149+ } catch (e) {
150150+ row.simErr = e.message;
151151+ process.stderr.write("x");
152152+ }
153153+ await new Promise(r => setTimeout(r, 400)); // gentle rate limit
154154+ }
155155+ rows.push(row);
156156+ }
157157+ console.error("\n");
158158+159159+ if (JSON_OUT) {
160160+ console.log(JSON.stringify({ channel: SLUG, at: new Date().toISOString(), rows }, null, 2));
161161+ } else {
162162+ console.log(fmtReport(rows));
163163+ const wikiCount = rows.filter(r => r.isWiki).length;
164164+ console.log(`\n\nsummary: ${rows.length} blocks · ${wikiCount} wikipedia link(s) flagged for swap`);
165165+ }
166166+}
167167+168168+main().catch(e => { console.error("error:", e.message); process.exit(1); });
+101
lith/mirror/README.md
···11+# lith mirror — knot ↔ GitHub bidirectional sync
22+33+Keeps `main` in lockstep between the two remotes for
44+`aesthetic.computer/core`:
55+66+- **knot** (`knot.aesthetic.computer:aesthetic.computer/core`, Tangled) —
77+ what `lith` deploy pulls from.
88+- **GitHub** (`whistlegraph/aesthetic-computer`) — what `session-server`
99+ deploy pulls from, plus Claude/tools usage.
1010+1111+Runs as a systemd timer on `lith.aesthetic.computer`, every 60 seconds.
1212+Idempotent: exits 0 when the tips match, pushes the ahead side to the
1313+behind side otherwise, and exits 2 with a warning when the tips truly
1414+diverged (requires manual merge).
1515+1616+## Files in this directory
1717+1818+- [`mirror.sh`](./mirror.sh) — the bidirectional sync script.
1919+- [`ac-mirror.service`](./ac-mirror.service) — systemd oneshot unit.
2020+- [`ac-mirror.timer`](./ac-mirror.timer) — every-60s trigger.
2121+2222+## First-time setup
2323+2424+On the lith host:
2525+2626+```sh
2727+# 1. Bare clone (fetched over anon HTTPS; push goes via SSH keys below).
2828+mkdir -p /opt/ac-mirror
2929+git clone --bare https://knot.aesthetic.computer/aesthetic.computer/core \
3030+ /opt/ac-mirror/core
3131+cd /opt/ac-mirror/core
3232+git remote rename origin knot
3333+git remote set-url --push knot git@knot.aesthetic.computer:aesthetic.computer/core
3434+git remote add github https://github.com/whistlegraph/aesthetic-computer.git
3535+git remote set-url --push github git@github.com:whistlegraph/aesthetic-computer.git
3636+3737+# 2. SSH keys (ed25519).
3838+# /root/.ssh/knot_push ← copy of the vault's home/.ssh/tangled
3939+# (the key registered on @jeffrey's
4040+# Tangled account, allowed to push).
4141+# /root/.ssh/github_mirror ← fresh ed25519 keypair generated on lith;
4242+# public half registered as a repo deploy key
4343+# with write access on
4444+# whistlegraph/aesthetic-computer,
4545+# encrypted private copy in
4646+# aesthetic-computer-vault/lith/mirror/.
4747+# Both files must be mode 600.
4848+4949+# 3. Pin host keys to avoid interactive prompts.
5050+ssh-keyscan -t ed25519,rsa knot.aesthetic.computer >> /root/.ssh/known_hosts
5151+ssh-keyscan -t ed25519,rsa github.com >> /root/.ssh/known_hosts
5252+sort -u /root/.ssh/known_hosts -o /root/.ssh/known_hosts
5353+5454+# 4. Install the script + units and enable the timer.
5555+install -m 755 mirror.sh /opt/ac-mirror/mirror.sh
5656+install -m 644 ac-mirror.service /etc/systemd/system/ac-mirror.service
5757+install -m 644 ac-mirror.timer /etc/systemd/system/ac-mirror.timer
5858+systemctl daemon-reload
5959+systemctl enable --now ac-mirror.timer
6060+```
6161+6262+## Observe / debug
6363+6464+```sh
6565+systemctl list-timers ac-mirror.timer
6666+journalctl -u ac-mirror -n 50
6767+# In sync = no output per run. A sync push logs one line:
6868+# 2026-04-20T22:31:27+00:00 → knot behind; pushing <sha> to knot.
6969+```
7070+7171+## Manual force
7272+7373+```sh
7474+# Run immediately (the timer fires hourly-ish otherwise on boot).
7575+systemctl start ac-mirror.service
7676+```
7777+7878+## Divergent heads
7979+8080+If both sides received independent commits (true fork), the script exits
8181+`2` and logs:
8282+8383+```
8484+⚠️ divergent heads: knot=<sha> github=<sha> — skipping (manual resolution required)
8585+```
8686+8787+Resolve by pulling both locally, merging with `git merge`, and pushing
8888+the merge commit. The mirror will then see both sides equal the merge
8989+tip and go back to green.
9090+9191+## Why this instead of GitHub Actions?
9292+9393+A `.github/workflows/mirror-to-knot.yml` was tried first but:
9494+9595+1. GitHub Actions is billing-locked on the repo at the moment —
9696+ the workflow never fires.
9797+2. Tangled knot *also* reads `.github/workflows/*.yml` as pipelines,
9898+ and sent failure emails about them.
9999+100100+The systemd timer on lith is free, runs even when GitHub is unavailable,
101101+and keeps all credentials on a host we already control.
+13
lith/mirror/ac-mirror.service
···11+[Unit]
22+Description=Mirror main between knot and GitHub for aesthetic-computer/core
33+After=network-online.target
44+Wants=network-online.target
55+66+[Service]
77+Type=oneshot
88+ExecStart=/opt/ac-mirror/mirror.sh
99+# Don't flood logs with normal exits (code 0 = in sync, 1 = error, 2 = divergent).
1010+SuccessExitStatus=0
1111+1212+[Install]
1313+WantedBy=multi-user.target
···11+#!/usr/bin/env bash
22+# ac-mirror.sh — bidirectional knot ↔ github mirror for the core repo.
33+# Runs via systemd timer every 60s. Idempotent, exits fast when in sync.
44+set -euo pipefail
55+66+REPO=/opt/ac-mirror/core
77+KNOT_KEY=/root/.ssh/knot_push
88+GH_KEY=/root/.ssh/github_mirror
99+BRANCH=main
1010+1111+cd "$REPO"
1212+1313+export GIT_SSH_COMMAND="ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
1414+ -i $KNOT_KEY -i $GH_KEY"
1515+1616+# "+" prefix → allow non-fast-forward fetches. A mirror must always track
1717+# wherever the remote actually is, even after force-pushes or rewinds.
1818+git fetch --quiet knot "+$BRANCH":refs/remotes/knot/$BRANCH
1919+git fetch --quiet github "+$BRANCH":refs/remotes/github/$BRANCH
2020+2121+knot_head=$(git rev-parse "knot/$BRANCH")
2222+gh_head=$(git rev-parse "github/$BRANCH")
2323+2424+if [ "$knot_head" = "$gh_head" ]; then
2525+ exit 0
2626+fi
2727+2828+if git merge-base --is-ancestor "$gh_head" "$knot_head"; then
2929+ echo "$(date -Iseconds) → github behind; pushing $knot_head to github."
3030+ git push --quiet github "$knot_head:refs/heads/$BRANCH"
3131+elif git merge-base --is-ancestor "$knot_head" "$gh_head"; then
3232+ echo "$(date -Iseconds) → knot behind; pushing $gh_head to knot."
3333+ git push --quiet knot "$gh_head:refs/heads/$BRANCH"
3434+else
3535+ echo "$(date -Iseconds) ⚠️ divergent heads: knot=$knot_head github=$gh_head — skipping (manual resolution required)" >&2
3636+ exit 2
3737+fi
+7-3
oven/native-builder.mjs
···360360 `if git rev-parse --verify origin/${NATIVE_BRANCH} >/dev/null 2>&1; then`,
361361 ` git reset --hard origin/${NATIVE_BRANCH} --quiet`,
362362 "fi",
363363- // Honor caller-specified ref. Fetch the exact commit first (in case
364364- // it's not on the default branch yet / on a PR branch), then detach.
365365- requestedRef ? `git fetch origin ${requestedRef} --quiet || true` : "",
363363+ // Honor caller-specified ref. Skip the direct-SHA fetch — tangled/
364364+ // knot rejects short-SHA fetches over the wire (treats them as
365365+ // missing refs), which caused preflight-sync failures for every
366366+ // 9-char abbreviated ref. Since `fetch origin <branch>` above
367367+ // already brings in all commits reachable from main, a local
368368+ // `git checkout <ref>` resolves abbreviations against the local
369369+ // object db — works for both full and short SHAs.
366370 requestedRef ? `git checkout -f ${requestedRef} --quiet` : "",
367371 "git clean -fdq -- fedac/native fedac/nixos",
368372 ].filter(Boolean).join("\n")], repoDir);
+228
plans/usb-midi-gadget.md
···11+# USB MIDI Gadget for ac-native → Ableton direct
22+33+> Status: **plan / unscoped**. Lets the ac-native ThinkPad present itself
44+> to the MacBook (running Ableton) as a USB MIDI controller, bypassing the
55+> session-server WebSocket relay entirely for the lowest possible
66+> ThinkPad→Ableton latency.
77+88+## Motivation
99+1010+Current path: `notepat.mjs (ThinkPad) → UDP → session-server.aesthetic.computer:10010 → WS fanout → notepat-remote.amxd (Mac) → Operator`. That's a round trip over public internet. Observed latency is fine for async use but **40-100ms** is unavoidable even on good wifi.
1111+1212+Direct USB MIDI gets us:
1313+1414+- **~1-3ms** ThinkPad key → MacBook MIDI-in (USB controller polling + kernel hop)
1515+- No internet dependency, no session-server required
1616+- No WS subscriptions, no handle/machineId negotiation
1717+- Ableton sees it as a native MIDI controller — appears in MIDI input list, can be mapped, recorded, etc.
1818+1919+The session-server relay can stay as a **networked** backup / for multi-ThinkPad scenarios (one ThinkPad playing into multiple remote DAWs), but the USB path becomes the default for a local setup.
2020+2121+## Architecture
2222+2323+```
2424+┌───────────────────────────┐ USB-C ┌──────────────────────┐
2525+│ ThinkPad (ac-native OS) │ ═════════▶ │ MacBook (Ableton) │
2626+│ Linux g_midi gadget │ │ CoreMIDI auto-mount │
2727+│ /dev/snd/midiCxDy │ │ "Linux USB MIDI" │
2828+│ ↑ notepat.mjs writes │ │ → MIDI input list │
2929+│ raw MIDI bytes │ │ → Ableton track │
3030+└───────────────────────────┘ └──────────────────────┘
3131+```
3232+3333+`notepat.mjs` on ac-native gains a new output sink alongside the existing UDP→session-server path:
3434+3535+```
3636+keypress → playSoundKey → [ UDP relay | USB MIDI | USB audio synth | ... ]
3737+```
3838+3939+Configurable at runtime via `/mnt/config.json` (new key `usbMidiGadget: true`). Both paths can be on simultaneously — it's fire-and-forget MIDI data, the ingest endpoints are independent.
4040+4141+## Hardware prerequisites
4242+4343+### ThinkPad 11e Yoga Gen 6
4444+4545+The USB-C port must support **device/OTG mode** in its controller. Most 8th-gen Intel and newer support this through the Thunderbolt PHY, but it needs to be enabled in:
4646+4747+1. **BIOS/UEFI**: look for "USB-C device mode", "Thunderbolt configuration", or similar. On Lenovo ThinkPads this is usually under "Config → USB" or "Thunderbolt(TM) 3".
4848+2. **Kernel**: `CONFIG_USB_GADGET=y`, `CONFIG_USB_GADGETFS=y`, `CONFIG_USB_CONFIGFS=y`, `CONFIG_USB_CONFIGFS_F_MIDI=y`. Verify with `zcat /proc/config.gz | grep GADGET` on the running kernel.
4949+3. **UDC driver**: the "USB Device Controller" needs a loaded driver. On Intel platforms this is often `dwc3` or `xhci` with gadget support. Check `ls /sys/class/udc/` — there should be at least one entry when device mode is active.
5050+5151+**If the ThinkPad's USB-C is host-only**, the fallback is a USB device such as a **Raspberry Pi Zero W / 2 W** or **BeagleBone** tethered to the ThinkPad via network/serial, acting as the gadget. Out of scope for this plan.
5252+5353+### MacBook
5454+5555+No setup required. macOS auto-detects USB MIDI devices on plug-in and exposes them through CoreMIDI. They show up in:
5656+5757+- Audio MIDI Setup → Window → Show MIDI Studio
5858+- Ableton → Preferences → Link Tempo MIDI → MIDI Ports → check "Track" for the gadget
5959+6060+### Cable
6161+6262+USB-C to USB-C (or USB-C to USB-A with correct gadget direction). Thunderbolt cables work but are overkill — a standard USB 2.0 data cable is plenty for MIDI (31.25 kbps).
6363+6464+## Linux gadget setup (ac-native)
6565+6666+### Option A — legacy `g_midi` driver (simplest)
6767+6868+```bash
6969+modprobe g_midi \
7070+ iProduct="AC Notepat" \
7171+ iManufacturer="Aesthetic Computer" \
7272+ id="AC_NOTEPAT"
7373+```
7474+7575+Creates `/dev/snd/midiC<N>D0` and shows up on the Mac as "AC Notepat".
7676+7777+### Option B — configfs composite gadget (more control)
7878+7979+Scripted at boot time so it survives reboots and can be composed with
8080+other gadget functions later (e.g. serial console).
8181+8282+```bash
8383+#!/bin/sh
8484+# /usr/local/bin/ac-usb-gadget-start
8585+8686+set -e
8787+GADGET=/sys/kernel/config/usb_gadget/ac_notepat
8888+mkdir -p "$GADGET"
8989+9090+echo 0x1d6b > "$GADGET/idVendor" # Linux Foundation
9191+echo 0x0104 > "$GADGET/idProduct" # Multifunction Composite Gadget
9292+echo 0x0100 > "$GADGET/bcdDevice"
9393+echo 0x0200 > "$GADGET/bcdUSB"
9494+9595+mkdir -p "$GADGET/strings/0x409"
9696+echo "ACNP-$(cat /etc/machine-id | cut -c1-8)" > "$GADGET/strings/0x409/serialnumber"
9797+echo "Aesthetic Computer" > "$GADGET/strings/0x409/manufacturer"
9898+echo "AC Notepat" > "$GADGET/strings/0x409/product"
9999+100100+mkdir -p "$GADGET/configs/c.1/strings/0x409"
101101+echo "MIDI config" > "$GADGET/configs/c.1/strings/0x409/configuration"
102102+echo 250 > "$GADGET/configs/c.1/MaxPower"
103103+104104+mkdir -p "$GADGET/functions/midi.usb0"
105105+echo 1 > "$GADGET/functions/midi.usb0/in_ports"
106106+echo 1 > "$GADGET/functions/midi.usb0/out_ports"
107107+echo 64 > "$GADGET/functions/midi.usb0/buflen" # small buffer = low latency
108108+echo 32 > "$GADGET/functions/midi.usb0/qlen"
109109+110110+ln -s "$GADGET/functions/midi.usb0" "$GADGET/configs/c.1/"
111111+112112+# Bind to the first available UDC. ls /sys/class/udc shows the devices.
113113+UDC=$(ls /sys/class/udc | head -n1)
114114+echo "$UDC" > "$GADGET/UDC"
115115+```
116116+117117+Run at boot via an ac-native init hook. A matching teardown script writes empty to `UDC` and `rmdir`s the tree.
118118+119119+### Verifying on the Mac
120120+121121+With the cable plugged in:
122122+123123+```bash
124124+system_profiler SPUSBDataType | grep -A 4 "AC Notepat"
125125+# Should list it as a USB device.
126126+127127+# In Audio MIDI Setup app: "AC Notepat" appears with a MIDI icon.
128128+```
129129+130130+In Ableton → Preferences → Link Tempo MIDI → MIDI Ports, the new input appears. Enable "Track" to make it a note source, or "Remote" to map its controls.
131131+132132+## ac-native integration
133133+134134+### New code in `fedac/native/src/`
135135+136136+`usb-midi.c` (new file):
137137+138138+```c
139139+int usb_midi_open(const char *device); // opens /dev/snd/midiC0D0
140140+void usb_midi_close(int fd);
141141+int usb_midi_send_note_on(int fd, int channel, int pitch, int velocity);
142142+int usb_midi_send_note_off(int fd, int channel, int pitch);
143143+```
144144+145145+Uses raw ALSA MIDI bytes:
146146+147147+- note-on: `0x90 | channel, pitch, velocity` (3 bytes)
148148+- note-off: `0x80 | channel, pitch, 0`
149149+150150+`write(fd, buf, 3)` on the MIDI char device. Non-blocking mode preferred so a stalled receiver doesn't wedge the audio thread.
151151+152152+### JS bindings (`fedac/native/src/js-bindings.c`)
153153+154154+Mirror the existing `system.udp.sendMidi` helpers:
155155+156156+```js
157157+system.usbGadget.open() // → bool
158158+system.usbGadget.sendMidi(event, note, vel, ch)
159159+system.usbGadget.close()
160160+system.usbGadget.status // { connected, device, bytesSent }
161161+```
162162+163163+### notepat.mjs wiring
164164+165165+At the existing `sendUdpMidiEvent(…)` call site (around `fedac/native/pieces/notepat.mjs:1847-1850`), also emit USB MIDI when enabled:
166166+167167+```js
168168+const usbMidiGadgetEnabled =
169169+ cfg.usbMidiGadget === true || cfg.usbMidiGadget === "true";
170170+171171+function sendMidiEvent(system, event, midiNote, velocity, channel = 0) {
172172+ // Existing: UDP relay
173173+ if (udpMidiBroadcast && system?.udp?.connected) {
174174+ system.udp.sendMidi(event, midiNote, velocity, channel, "notepat");
175175+ }
176176+ // NEW: USB gadget direct to Mac
177177+ if (usbMidiGadgetEnabled && system?.usbGadget?.status?.connected) {
178178+ system.usbGadget.sendMidi(event, midiNote, velocity, channel);
179179+ }
180180+ // Existing telemetry counters
181181+ udpMidiSentCount += 1;
182182+ …
183183+}
184184+```
185185+186186+### Prompt command
187187+188188+Add `usb midi on/off/status` to `prompt.mjs`, parallel to the existing `midi relay on/off` command. Persists the flag to `/mnt/config.json`.
189189+190190+## Expected latency budget
191191+192192+| Stage | Cost |
193193+|---|---|
194194+| ThinkPad key press → notepat handler | <1 ms |
195195+| JS → C `sendMidi()` binding | <0.1 ms |
196196+| `write()` → kernel USB gadget | <0.2 ms |
197197+| USB bus traversal (2× full-speed frame ≈ 1 ms polling interval) | ~1-2 ms |
198198+| MacBook USB host → CoreMIDI callback | <0.5 ms |
199199+| Ableton MIDI-in → track → instrument | <1 ms |
200200+| **Audio buffer output** (Live at 64 samples / 48 kHz) | ~1.3 ms |
201201+| **Total keypress → audible** | **~5-7 ms** |
202202+203203+Versus current session-server path (~40-100 ms), that's **~10-15× faster**.
204204+205205+## Out of scope / follow-ups
206206+207207+- **Bi-directional MIDI**: this plan is one-way ThinkPad→Mac. Receiving MIDI from Ableton back to ac-native (e.g., for playback-synced visuals) needs a `read()` loop on the same gadget endpoint. Easy to add.
208208+- **MIDI clock sync**: ac-native can emit `0xF8` every 24 PPQ to sync Ableton's transport.
209209+- **Multi-channel**: currently all notes fire on channel 0. Exposing channel selection in notepat's UI is trivial once the gadget is up.
210210+- **Power**: the ThinkPad draws from its own battery; USB in device mode doesn't supply power to the Mac. Bus power is one-directional.
211211+- **SysEx / MPE**: raw `write()` handles arbitrary MIDI bytes — both just work.
212212+213213+## Test plan
214214+215215+1. On the ThinkPad: `dmesg | grep gadget` after `g_midi` modprobe — expect "using random self ethernet address" + MIDI gadget init lines.
216216+2. `aconnect -l` lists the gadget port.
217217+3. `amidi -l` shows the device with its hw:N,M address.
218218+4. `amidi -p hw:N,M -S '90 3C 7F'` plays a C4 note-on — verify by ear through Ableton after enabling the MIDI input.
219219+5. Wire into `notepat.mjs`, rebuild ac-native, flash to ThinkPad.
220220+6. Plug into Mac. Enable in Live's MIDI preferences.
221221+7. Press keys in ac-native notepat — notes should fire instantly on the Mac.
222222+8. A/B against the session-server relay path (`midi relay on`) to confirm the latency improvement.
223223+224224+## Why not this?
225225+226226+- If the ThinkPad's USB-C can't do device mode (BIOS or silicon limitation), fall back to a USB-serial adapter or a Pi Zero bridge.
227227+- If you're primarily using ac-native remotely (not physically next to the Mac), keep the session-server path — USB obviously requires a cable.
228228+- The M4L `notepat-remote.amxd` device is still useful either way: on USB MIDI the AC-native-sourced notes land on *any* MIDI track directly, no device needed; on network relay the device is required to subscribe + route.
+287
system/backend/catchup-kidlisp-to-datomic.mjs
···11+// Catch-up sync: Mongo kidlisp → Datomic sidecar.
22+//
33+// Unlike backfill-kidlisp-to-datomic.mjs, this script is idempotent: for
44+// each piece it checks what's already in Datomic and only sends the
55+// delta. Intended to be run any time the sidecar has missed writes
66+// (e.g., everything between the 2026-03-24 backfill and the dual-write
77+// rollout).
88+//
99+// Env:
1010+// SIDECAR_URL default http://127.0.0.1:8891
1111+// CLIENT_SECRET required
1212+// SINCE default 2026-03-24T00:00:00Z — low-water mark for
1313+// filtering Mongo docs. Only docs touched after this
1414+// (by `when`, `kept.keptAt`, `ipfsMedia.createdAt`,
1515+// `pendingRebake.createdAt`, or a contract mint date)
1616+// are processed.
1717+// DRY_RUN if "true", logs intended writes without sending
1818+// ONLY_CODE optional — process a single piece by code
1919+//
2020+// Usage (from silo):
2121+// SIDECAR_URL=http://127.0.0.1:8891 \
2222+// CLIENT_SECRET=... \
2323+// node system/backend/catchup-kidlisp-to-datomic.mjs
2424+2525+import { connect } from "./database.mjs";
2626+2727+const SIDECAR_URL = process.env.SIDECAR_URL || "http://127.0.0.1:8891";
2828+const CLIENT_SECRET = process.env.CLIENT_SECRET;
2929+const DRY_RUN = process.env.DRY_RUN === "true";
3030+const SINCE = new Date(process.env.SINCE || "2026-03-24T00:00:00Z");
3131+const ONLY_CODE = process.env.ONLY_CODE || null;
3232+3333+if (!DRY_RUN && !CLIENT_SECRET) {
3434+ console.error("CLIENT_SECRET is required (or set DRY_RUN=true)");
3535+ process.exit(1);
3636+}
3737+3838+function sidecarHeaders() {
3939+ return {
4040+ "content-type": "application/json",
4141+ "x-sidecar-secret": CLIENT_SECRET,
4242+ };
4343+}
4444+4545+async function sidecarReq(method, path, body) {
4646+ if (DRY_RUN && method !== "GET") {
4747+ return { ok: true, status: 200, body: { dryRun: true } };
4848+ }
4949+ const res = await fetch(`${SIDECAR_URL}${path}`, {
5050+ method,
5151+ headers: sidecarHeaders(),
5252+ body: body != null ? JSON.stringify(body) : undefined,
5353+ });
5454+ const text = await res.text();
5555+ let json = null;
5656+ try { json = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
5757+ return { ok: res.ok, status: res.status, body: json ?? text };
5858+}
5959+6060+function normInstant(value) {
6161+ if (!value) return null;
6262+ if (value instanceof Date) return value.toISOString();
6363+ if (typeof value === "string") return value;
6464+ if (typeof value === "number") return new Date(value).toISOString();
6565+ return null;
6666+}
6767+6868+function normalizeKeep(k, defaults = {}) {
6969+ const tokenId = Number(k?.tokenId);
7070+ if (!Number.isInteger(tokenId) || tokenId < 0) return null;
7171+ const contractAddress = k?.contractAddress || defaults.contractAddress || null;
7272+ if (!contractAddress) return null;
7373+ return {
7474+ tokenId,
7575+ contractAddress,
7676+ network: k?.network || defaults.network || "mainnet",
7777+ txHash: k?.txHash || defaults.txHash || null,
7878+ contractProfile: k?.contractProfile || k?.profile || defaults.contractProfile || null,
7979+ contractVersion: k?.contractVersion || k?.version || defaults.contractVersion || null,
8080+ keptAt: normInstant(k?.keptAt || k?.mintedAt || defaults.keptAt),
8181+ keptBy: k?.keptBy || defaults.keptBy || null,
8282+ walletAddress: k?.walletAddress || k?.owner || defaults.walletAddress || null,
8383+ artifactUri: k?.artifactUri || defaults.artifactUri || null,
8484+ thumbnailUri: k?.thumbnailUri || defaults.thumbnailUri || null,
8585+ metadataUri: k?.metadataUri || defaults.metadataUri || null,
8686+ source: defaults.source || "catchup",
8787+ };
8888+}
8989+9090+function existingKeepKey(k) {
9191+ return `${k?.tokenId}::${(k?.contractAddress || "").toLowerCase()}::${k?.txHash || ""}`;
9292+}
9393+9494+function docTouchedSince(doc, since) {
9595+ const t = since.getTime();
9696+ const dates = [
9797+ doc.when,
9898+ doc.kept?.keptAt,
9999+ doc.ipfsMedia?.createdAt,
100100+ doc.pendingRebake?.createdAt,
101101+ ];
102102+ if (doc.tezos?.contracts && typeof doc.tezos.contracts === "object") {
103103+ for (const v of Object.values(doc.tezos.contracts)) {
104104+ if (v?.mintedAt) dates.push(v.mintedAt);
105105+ if (v?.lastUpdatedAt) dates.push(v.lastUpdatedAt);
106106+ if (v?.lastConfirmAt) dates.push(v.lastConfirmAt);
107107+ }
108108+ }
109109+ for (const d of dates) {
110110+ if (!d) continue;
111111+ const dt = new Date(d).getTime();
112112+ if (Number.isFinite(dt) && dt >= t) return true;
113113+ }
114114+ return false;
115115+}
116116+117117+async function ensureEntity(doc, stats) {
118118+ // Idempotent by hash — if present, sidecar bumps hits and returns code.
119119+ const res = await sidecarReq("POST", "/kidlisp", {
120120+ code: doc.code,
121121+ source: doc.source,
122122+ hash: doc.hash,
123123+ user_sub: doc.user || null,
124124+ when: doc.when ? new Date(doc.when).toISOString() : null,
125125+ hits: typeof doc.hits === "number" ? doc.hits : 1,
126126+ });
127127+ if (!res.ok) {
128128+ stats.errors++;
129129+ console.error(` ! ensure failed ${doc.code}: ${res.status} ${JSON.stringify(res.body)}`);
130130+ return false;
131131+ }
132132+ return true;
133133+}
134134+135135+async function syncIpfsMedia(doc, stats) {
136136+ if (!doc.ipfsMedia) return;
137137+ const res = await sidecarReq("POST", `/kidlisp/${doc.code}/ipfs-media`, {
138138+ artifactUri: doc.ipfsMedia.artifactUri || null,
139139+ thumbnailUri: doc.ipfsMedia.thumbnailUri || null,
140140+ sourceHash: doc.ipfsMedia.sourceHash || null,
141141+ authorHandle: doc.ipfsMedia.authorHandle || null,
142142+ depCount: doc.ipfsMedia.depCount ?? null,
143143+ packDate: doc.ipfsMedia.packDate || null,
144144+ createdAt: normInstant(doc.ipfsMedia.createdAt),
145145+ });
146146+ if (res.ok) stats.ipfs++;
147147+ else {
148148+ stats.errors++;
149149+ console.error(` ! ipfs-media ${doc.code}: ${res.status}`);
150150+ }
151151+}
152152+153153+async function syncPendingRebake(doc, stats) {
154154+ if (!doc.pendingRebake) return;
155155+ const res = await sidecarReq("POST", `/kidlisp/${doc.code}/pending-rebake`, doc.pendingRebake);
156156+ if (res.ok) stats.pendingRebake++;
157157+ else {
158158+ stats.errors++;
159159+ console.error(` ! pending-rebake ${doc.code}: ${res.status}`);
160160+ }
161161+}
162162+163163+async function syncTezosState(doc, stats) {
164164+ if (!doc.tezos || typeof doc.tezos !== "object") return;
165165+ const t = doc.tezos;
166166+ const res = await sidecarReq("POST", `/kidlisp/${doc.code}/tezos-state`, {
167167+ minted: !!t.minted,
168168+ exists: !!t.exists,
169169+ tokenId: t.tokenId ?? null,
170170+ txHash: t.txHash ?? null,
171171+ creatorAddress: t.creatorAddress ?? null,
172172+ codeHash: t.codeHash ?? null,
173173+ network: t.network ?? null,
174174+ reason: t.reason ?? null,
175175+ error: t.error ?? null,
176176+ });
177177+ if (res.ok) stats.tezos++;
178178+ else {
179179+ stats.errors++;
180180+ console.error(` ! tezos-state ${doc.code}: ${res.status}`);
181181+ }
182182+}
183183+184184+async function syncKeeps(doc, stats) {
185185+ // Collect candidate keeps from the Mongo doc
186186+ const candidates = [];
187187+ if (doc.kept) {
188188+ const k = normalizeKeep(doc.kept, { source: "kept" });
189189+ if (k) candidates.push(k);
190190+ }
191191+ if (doc.tezos?.contracts && typeof doc.tezos.contracts === "object") {
192192+ for (const [contractAddress, v] of Object.entries(doc.tezos.contracts)) {
193193+ if (!v || typeof v !== "object" || !v.minted) continue;
194194+ const k = normalizeKeep(v, {
195195+ source: "contract_keyed",
196196+ contractAddress,
197197+ keptAt: v.mintedAt,
198198+ });
199199+ if (k) candidates.push(k);
200200+ }
201201+ }
202202+ if (candidates.length === 0) return;
203203+204204+ // Ask datomic what it already has so we skip duplicates.
205205+ let existing = new Set();
206206+ if (!DRY_RUN) {
207207+ const lookup = await sidecarReq("GET", `/kidlisp/${doc.code}`);
208208+ if (lookup.ok && lookup.body?.keeps) {
209209+ for (const k of lookup.body.keeps) existing.add(existingKeepKey(k));
210210+ }
211211+ }
212212+213213+ for (const k of candidates) {
214214+ if (existing.has(existingKeepKey(k))) {
215215+ stats.keepsSkipped++;
216216+ continue;
217217+ }
218218+ const res = await sidecarReq("POST", `/kidlisp/${doc.code}/mint`, k);
219219+ if (res.ok) stats.keeps++;
220220+ else {
221221+ stats.errors++;
222222+ console.error(` ! mint ${doc.code} token ${k.tokenId}: ${res.status} ${JSON.stringify(res.body)}`);
223223+ }
224224+ }
225225+}
226226+227227+async function processDoc(doc, stats) {
228228+ stats.processed++;
229229+ const ok = await ensureEntity(doc, stats);
230230+ if (!ok) return;
231231+ await syncIpfsMedia(doc, stats);
232232+ await syncPendingRebake(doc, stats);
233233+ await syncTezosState(doc, stats);
234234+ await syncKeeps(doc, stats);
235235+}
236236+237237+async function main() {
238238+ const started = Date.now();
239239+ console.log(`▶ catchup start — dryRun=${DRY_RUN} sidecar=${SIDECAR_URL} since=${SINCE.toISOString()}`);
240240+241241+ const database = await connect();
242242+ const coll = database.db.collection("kidlisp");
243243+244244+ // Broad filter then precise check — picks up docs that were touched
245245+ // after SINCE via any of the mutable sub-fields.
246246+ const filter = ONLY_CODE
247247+ ? { code: ONLY_CODE }
248248+ : {
249249+ $or: [
250250+ { when: { $gte: SINCE } },
251251+ { "kept.keptAt": { $gte: SINCE } },
252252+ { "ipfsMedia.createdAt": { $gte: SINCE } },
253253+ { "pendingRebake.createdAt": { $gte: SINCE } },
254254+ { "tezos.mintedAt": { $gte: SINCE } },
255255+ ],
256256+ };
257257+258258+ const total = await coll.countDocuments(filter);
259259+ console.log(` candidate docs: ${total}`);
260260+261261+ const cursor = coll.find(filter).sort({ when: 1 }).batchSize(200);
262262+263263+ const stats = {
264264+ processed: 0, ensured: 0, keeps: 0, keepsSkipped: 0,
265265+ ipfs: 0, pendingRebake: 0, tezos: 0, errors: 0,
266266+ };
267267+268268+ let last = Date.now();
269269+ for await (const doc of cursor) {
270270+ if (!docTouchedSince(doc, SINCE) && !ONLY_CODE) continue;
271271+ await processDoc(doc, stats);
272272+ if (Date.now() - last > 3000) {
273273+ console.log(` ${stats.processed} — ${JSON.stringify(stats)}`);
274274+ last = Date.now();
275275+ }
276276+ }
277277+278278+ await database.disconnect();
279279+ const secs = ((Date.now() - started) / 1000).toFixed(1);
280280+ console.log(`✓ catchup done in ${secs}s — ${JSON.stringify(stats)}`);
281281+ if (stats.errors > 0) process.exit(1);
282282+}
283283+284284+main().catch((err) => {
285285+ console.error("✗ catchup fatal:", err);
286286+ process.exit(1);
287287+});
+116
system/backend/kidlisp-dual-write.mjs
···11+// Dual-write helpers for the kidlisp Datomic cutover.
22+//
33+// Each function mirrors a Mongo write into the sidecar when
44+// KIDLISP_DATOMIC=on. They are fire-and-forget from the caller's
55+// perspective: errors are logged and swallowed so that the live
66+// Mongo write remains the source of truth for request success.
77+//
88+// Gate these calls on `kidlispDatomicEnabled()` in the caller so we
99+// don't pay for the sidecar round-trip when the flag is off.
1010+1111+import { sidecar, kidlispDatomicEnabled } from "./kidlisp-sidecar.mjs";
1212+1313+function normalizeInstant(value) {
1414+ if (!value) return null;
1515+ if (value instanceof Date) return value.toISOString();
1616+ if (typeof value === "string") return value;
1717+ if (typeof value === "number") return new Date(value).toISOString();
1818+ return null;
1919+}
2020+2121+function swallow(label, code) {
2222+ return (err) => {
2323+ const msg = err?.message || String(err);
2424+ console.warn(`⚠️ sidecar ${label} for $${code} failed: ${msg}`);
2525+ };
2626+}
2727+2828+// Record a mint in Datomic. No-ops without a tokenId or contract address,
2929+// since the sidecar's record-mint endpoint requires both. `source` labels
3030+// the origin (kept, update, server_mint, contract_keyed, …) so the primary
3131+// keep selector can prefer the freshest record.
3232+export async function mirrorRecordMint(code, keep, { source = "kept" } = {}) {
3333+ if (!kidlispDatomicEnabled()) return;
3434+ if (!code || !keep) return;
3535+ const tokenId = Number(keep.tokenId);
3636+ if (!Number.isInteger(tokenId) || tokenId < 0) return;
3737+ const contractAddress = keep.contractAddress || null;
3838+ if (!contractAddress) return;
3939+4040+ const body = {
4141+ tokenId,
4242+ contractAddress,
4343+ network: keep.network || "mainnet",
4444+ txHash: keep.txHash || null,
4545+ contractProfile: keep.contractProfile || null,
4646+ contractVersion: keep.contractVersion || null,
4747+ keptAt: normalizeInstant(keep.keptAt || keep.mintedAt || new Date()),
4848+ keptBy: keep.keptBy || null,
4949+ walletAddress: keep.walletAddress || keep.owner || null,
5050+ artifactUri: keep.artifactUri || null,
5151+ thumbnailUri: keep.thumbnailUri || null,
5252+ metadataUri: keep.metadataUri || null,
5353+ source,
5454+ };
5555+5656+ try {
5757+ await sidecar.recordMint(code, body);
5858+ } catch (err) {
5959+ swallow("recordMint", code)(err);
6060+ }
6161+}
6262+6363+export async function mirrorIpfsMedia(code, media) {
6464+ if (!kidlispDatomicEnabled()) return;
6565+ if (!code || !media) return;
6666+ try {
6767+ await sidecar.setIpfsMedia(code, {
6868+ artifactUri: media.artifactUri || null,
6969+ thumbnailUri: media.thumbnailUri || null,
7070+ sourceHash: media.sourceHash || null,
7171+ authorHandle: media.authorHandle || null,
7272+ depCount: media.depCount ?? null,
7373+ packDate: media.packDate || null,
7474+ createdAt: normalizeInstant(media.createdAt),
7575+ });
7676+ } catch (err) {
7777+ swallow("setIpfsMedia", code)(err);
7878+ }
7979+}
8080+8181+export async function mirrorPendingRebake(code, rebake) {
8282+ if (!kidlispDatomicEnabled()) return;
8383+ if (!code || !rebake) return;
8484+ try {
8585+ await sidecar.setPendingRebake(code, {
8686+ artifactUri: rebake.artifactUri || null,
8787+ thumbnailUri: rebake.thumbnailUri || null,
8888+ metadataUri: rebake.metadataUri || null,
8989+ contractAddress: rebake.contractAddress || null,
9090+ contractProfile: rebake.contractProfile || null,
9191+ contractVersion: rebake.contractVersion || null,
9292+ });
9393+ } catch (err) {
9494+ swallow("setPendingRebake", code)(err);
9595+ }
9696+}
9797+9898+export async function mirrorTezosState(code, state) {
9999+ if (!kidlispDatomicEnabled()) return;
100100+ if (!code || !state) return;
101101+ try {
102102+ await sidecar.setTezosState(code, {
103103+ minted: !!state.minted,
104104+ exists: !!state.exists,
105105+ tokenId: state.tokenId ?? null,
106106+ txHash: state.txHash ?? null,
107107+ creatorAddress: state.creatorAddress ?? null,
108108+ codeHash: state.codeHash ?? null,
109109+ network: state.network ?? null,
110110+ reason: state.reason ?? null,
111111+ error: state.error ?? null,
112112+ });
113113+ } catch (err) {
114114+ swallow("setTezosState", code)(err);
115115+ }
116116+}
+6
system/netlify.toml
···11+# DEPRECATED: Aesthetic Computer no longer deploys on Netlify. The production
22+# host is the lith Express monolith at lith.aesthetic.computer (see CLAUDE.md).
33+# This file is retained for historical reference only — do not add new routes
44+# here. Backend handlers still live in system/netlify/functions/ (the path is
55+# historical), and lith adapts them via app.all("/api/:fn", …) at runtime.
66+17[build]
28base = "system"
39publish = "public"
···45094509 const hasDawParam = new URLSearchParams(window.location.search).has("daw");
45104510 if (hasDawParam) {
45114511 _dawConnectSend(send, updateMetronome);
45124512+45134513+ // 🎹 Low-latency keyboard bridge for M4L devices.
45144514+ // jweb~ captures keyboard focus, which means Max's own [key]/[keyup]
45154515+ // objects never see keystrokes. Capture them here on the main thread and
45164516+ // forward directly via window.max.outlet — sub-ms, no worker round-trip,
45174517+ // no iframe focus fight.
45184518+ //
45194519+ // BIOS owns the notepat key→pitch layout and octave state so we can emit
45204520+ // a finished MIDI pitch and keep the Max patcher trivial. The piece UI
45214521+ // also listens to the same keystrokes to render matching visual feedback.
45224522+ const _dawKeyOffsets = {
45234523+ z: -2, x: -1,
45244524+ c: 0, v: 1, d: 2, s: 3, e: 4, f: 5, w: 6,
45254525+ g: 7, r: 8, a: 9, q: 10, b: 11,
45264526+ h: 12, t: 13, i: 14, y: 15, j: 16, k: 17, u: 18,
45274527+ l: 19, o: 20, m: 21, p: 22, n: 23,
45284528+ ";": 24, "'": 25, "]": 26,
45294529+ };
45304530+ let _dawBaseOctave = 4;
45314531+ const _dawHeldPitch = {}; // keyLower → emitted pitch (for correct note-off across octave shifts)
45324532+45334533+ // Round-trip latency probe. BIOS emits a "ping" with a monotonic
45344534+ // timestamp alongside each notedown; the Max patcher echoes it back
45354535+ // via `script window.acMaxPong(...)` and we log the delta. This captures
45364536+ // the full iframe → Max → iframe hop — the real pipeline cost.
45374537+ const _dawRttSamples = []; // rolling buffer of recent RTTs (ms)
45384538+ window.acMaxPong = function (t0) {
45394539+ if (typeof t0 !== "number" || !Number.isFinite(t0)) return;
45404540+ const rtt = performance.now() - t0;
45414541+ _dawRttSamples.push(rtt);
45424542+ if (_dawRttSamples.length > 20) _dawRttSamples.shift();
45434543+ const avg =
45444544+ _dawRttSamples.reduce((s, v) => s + v, 0) / _dawRttSamples.length;
45454545+ console.log(
45464546+ `🎹 rtt ${rtt.toFixed(2)}ms (avg of ${_dawRttSamples.length}: ${avg.toFixed(2)}ms)`,
45474547+ );
45484548+ };
45494549+45504550+ function _dawEmitMax(sym, value) {
45514551+ if (
45524552+ typeof window !== "undefined" &&
45534553+ window.max &&
45544554+ typeof window.max.outlet === "function"
45554555+ ) {
45564556+ try { window.max.outlet(sym, value); } catch (_err) {}
45574557+ }
45584558+ }
45594559+ function _dawComputePitch(k) {
45604560+ const off = _dawKeyOffsets[k];
45614561+ if (off === undefined) return null;
45624562+ return (_dawBaseOctave + 1) * 12 + off; // baseOctave 4 → C = 60
45634563+ }
45644564+45654565+ window.addEventListener("keydown", (e) => {
45664566+ if (e.repeat) return;
45674567+ const k = typeof e.key === "string" ? e.key : "";
45684568+ if (k.length !== 1) return;
45694569+ // Octave hot-switch 1-9
45704570+ if (k >= "1" && k <= "9") {
45714571+ _dawBaseOctave = parseInt(k, 10);
45724572+ _dawEmitMax("octave", _dawBaseOctave);
45734573+ return;
45744574+ }
45754575+ const low = k.toLowerCase();
45764576+ const pitch = _dawComputePitch(low);
45774577+ if (pitch === null) return;
45784578+ _dawHeldPitch[low] = pitch;
45794579+ _dawEmitMax("notedown", pitch);
45804580+ // RTT ping: use Math.round so the int fits %ld in Max's [sprintf]
45814581+ // round-trip path. Delta is logged in window.acMaxPong above.
45824582+ _dawEmitMax("ping", Math.round(performance.now()));
45834583+ }, true);
45844584+45854585+ window.addEventListener("keyup", (e) => {
45864586+ const k = typeof e.key === "string" ? e.key : "";
45874587+ if (k.length !== 1) return;
45884588+ const low = k.toLowerCase();
45894589+ const pitch = _dawHeldPitch[low];
45904590+ if (pitch === undefined) return;
45914591+ delete _dawHeldPitch[low];
45924592+ _dawEmitMax("noteup", pitch);
45934593+ }, true);
45944594+45954595+ // Focus/blur indicator. On blur, release all held pitches so we never
45964596+ // leave a note hanging when the iframe loses focus (common during an
45974597+ // Ableton window switch). The piece UI uses the same events to show a
45984598+ // "tap me!" attract state.
45994599+ window.addEventListener("focus", () => {
46004600+ _dawEmitMax("focus", 1);
46014601+ }, true);
46024602+ window.addEventListener("blur", () => {
46034603+ for (const k of Object.keys(_dawHeldPitch)) {
46044604+ _dawEmitMax("noteup", _dawHeldPitch[k]);
46054605+ }
46064606+ for (const k of Object.keys(_dawHeldPitch)) delete _dawHeldPitch[k];
46074607+ _dawEmitMax("focus", 0);
46084608+ }, true);
45124609 }
4513461045144611 function requestBeat(time) {
···51555252 audioContext.resume().then(() => {
51565253 console.log("🎹 AudioContext resumed for DAW mode! State:", audioContext.state);
51575254 }).catch(e => console.warn("🎹 AudioContext resume failed:", e));
52555255+ }
52565256+ return;
52575257+ }
52585258+52595259+ // 🎹 MIDI note emit from a worker piece → Max (jweb~ main-thread bridge).
52605260+ // Pieces running inside an M4L device's jweb~ send
52615261+ // send({ type: "daw:midi", content: { pitch, velocity, channel? } })
52625262+ // and BIOS forwards to `window.max.outlet` which the patcher routes via
52635263+ // [route note channel] → [noteout].
52645264+ if (type === "daw:midi" && content) {
52655265+ if (typeof window !== "undefined" && window.max && typeof window.max.outlet === "function") {
52665266+ const pitch = Number(content.pitch);
52675267+ const velocity = Number(content.velocity);
52685268+ const channel = Number.isFinite(Number(content.channel)) ? Number(content.channel) : 0;
52695269+ if (!Number.isFinite(pitch) || !Number.isFinite(velocity)) return;
52705270+ try { window.max.outlet("channel", channel); } catch (_e) {}
52715271+ try { window.max.outlet("note", pitch, velocity); } catch (_e) {}
51585272 }
51595273 return;
51605274 }
+28-13
system/public/aesthetic.computer/disks/arena.mjs
···187187 }
188188 const seen = new Set();
189189 for (const p of blobs) {
190190- if (p.h === myHandle) {
190190+ const isMe = p.h === myHandle;
191191+ if (isMe) {
191192 myServerState = p;
192193 myServerStateMs = snap.serverMs;
193194 myServerAckCmdMs = typeof snap.ackCmdMs === "number" ? snap.ackCmdMs : myServerAckCmdMs;
194194- continue;
195195+ // While spectating, our "me" entry is being driven by another tab —
196196+ // render it as just another remote stick figure so we can watch.
197197+ // Otherwise skip (cam-doll is already drawing us locally).
198198+ if (!netSpectator) continue;
195199 }
196200 seen.add(p.h);
197201 let o = others[p.h];
···210214 });
211215 while (o.buffer.length > 32) o.buffer.shift();
212216 }
217217+ // If we just left spectator mode, drop the self entry we had been tracking.
218218+ if (!netSpectator && others[myHandle]) delete others[myHandle];
213219 // Prune others not in this snap for >2s (graceful drop).
214220 for (const h of Object.keys(others)) {
215221 if (seen.has(h)) continue;
···18051811 if (!phys.onGround) activeShadow = shadowAir;
18061812 else if (phys.crouch > 0.5) activeShadow = shadowCrouch;
18071813 }
18081808- // Only draw ground-anchored shadow/plumb while on solid ground.
18141814+ // Only draw ground-anchored shadow/plumb while on solid ground AND we're
18151815+ // playing (spectators are flying around without a body — no shadow).
18091816 const onSolidGround = phys?.onGround;
18101810- if (activeShadow && onSolidGround) ink(255, 255, 255).form(activeShadow);
18111811- if (plumbLine && onSolidGround && plumbLine.scale[1] > 0.05) {
18121812- ink(255, 255, 255).form(plumbLine);
18131813- }
18141814- // Feet + arms render regardless of ground state (they fall with you).
18151815- // Dropped entirely in LOW perf mode — wireframes are nice-to-have.
18161816- if (!perfLowMode) {
18171817- if (bodyFeet) ink(255).form(bodyFeet);
18181818- if (bodyArms) ink(255).form(bodyArms);
18171817+ if (!netSpectator) {
18181818+ if (activeShadow && onSolidGround) ink(255, 255, 255).form(activeShadow);
18191819+ if (plumbLine && onSolidGround && plumbLine.scale[1] > 0.05) {
18201820+ ink(255, 255, 255).form(plumbLine);
18211821+ }
18221822+ // Feet + arms render regardless of ground state (they fall with you).
18231823+ // Dropped entirely in LOW perf mode — wireframes are nice-to-have.
18241824+ if (!perfLowMode) {
18251825+ if (bodyFeet) ink(255).form(bodyFeet);
18261826+ if (bodyArms) ink(255).form(bodyArms);
18271827+ }
18191828 }
1820182918211830 // 🏟️ Remote players (interpolated from server snapshots, rendered ~100ms behind).
···23422351 };
23432352}
2344235323542354+// 🏟️ Lifecycle: tell the server we're leaving so our player record is
23552355+// deleted immediately instead of waiting for the 30s stale sweep.
23562356+function leave() {
23572357+ try { netServer?.send("arena:bye", { handle: myHandle }); } catch {}
23582358+}
23592359+23452360export const system = "fps";
23462346-export { boot, sim, paint, act };
23612361+export { boot, sim, paint, act, leave };
···58795879 commitBtn.reposition({ center: "x", y: buttonY, screen }, commitText);
58805880 commitBtn.btn.disabled = false;
58815881 }
58825882+ // TextButtonSmall sizes w as text.length * 4; MatrixChunky8 is proportional
58835883+ // so spaces/parens overshoot. Tighten to the real rendered width and recenter.
58845884+ const commitTextWidth = $.text.box(
58855885+ commitText, undefined, undefined, undefined, undefined, "MatrixChunky8",
58865886+ ).box.width;
58875887+ commitBtn.btn.box.w = commitTextWidth + 4; // padL + padR
58885888+ commitBtn.btn.box.x = Math.floor((screen.width - commitBtn.btn.box.w) / 2);
58825889 const cBox = commitBtn.btn.box;
58835890 if (cBox) {
58845891 const isHover = commitBtn.btn.over && !commitBtn.btn.down;
···59265933 ];
59275934 commitBtn.paint($, colors);
5928593559295929- // 🎹 Notepat shortcut button — sits to the right of the commit button.
59365936+ // 🎹 Notepat shortcut button — sits to the right of the commit button,
59375937+ // but stacks above it on narrow screens (phones) when the pair would
59385938+ // collide with the TextInput's Enter button.
59305939 // Leading spaces reserve room for the piano icon we draw afterward.
59315931- // MatrixChunky8 = 4px/char + 2px pad ⇒ text starts at box.x + 2 + 4*spaces.
59325932- // Icon frame (13×9) ends at box.x + 15, so 4 spaces (x+18) leaves a 3px gap.
59335933- const notepatLabel = " notepat";
59405940+ // MatrixChunky8 space advance = 2px (proportional), padL = 2px ⇒ text
59415941+ // starts at box.x + 2 + 2*spaces. Icon frame (13×9) ends at box.x + 14,
59425942+ // so 8 spaces (x+18) leaves a 3px gap after the frame.
59435943+ const notepatLabel = " notepat";
59445944+<<<<<<< Updated upstream
59455945+ // TextButtonSmall sizes the box as label.length * 4, but MatrixChunky8
59465946+ // is proportional (spaces advance only 2px), so the button overshoots.
59475947+ // Measure the actual rendered width and tighten the box below.
59485948+ const notepatTextWidth = $.text.box(
59495949+ notepatLabel, undefined, undefined, undefined, undefined, "MatrixChunky8",
59505950+ ).box.width;
59515951+ const notepatWidth = notepatTextWidth + 4; // padL + padR
59525952+ const notepatHeight = 7 + 2 * 2; // ch=7, padY*2
59535953+ const pairGap = 4;
59545954+ const stackedGap = 10; // breathing room between stacked notepat and commit
59555955+ const enterBoxForStack = $.system.prompt.input?.enter?.btn?.box;
59565956+ const pasteBoxForStack = $.system.prompt.input?.paste?.btn?.box;
59575957+ // Commit is centered; pair extends rightward by pairGap + notepatWidth.
59585958+ const pairRightEdge = (screen.width + cBox.w) / 2 + pairGap + notepatWidth;
59595959+ const pairLeftEdge = (screen.width - cBox.w) / 2;
59605960+ const rightLimit = enterBoxForStack ? enterBoxForStack.x - 4 : screen.width - 4;
59615961+ const leftLimit = pasteBoxForStack ? pasteBoxForStack.x + pasteBoxForStack.w + 4 : 4;
59625962+ const stackVertically = pairRightEdge > rightLimit || pairLeftEdge < leftLimit;
59635963+ const notepatPos = stackVertically
59645964+ ? { center: "x", y: buttonY - notepatHeight - stackedGap, screen }
59655965+ : { x: cBox.x + cBox.w + pairGap, y: buttonY };
59665966+=======
59345967 const notepatX = cBox.x + cBox.w + 4;
59685968+>>>>>>> Stashed changes
59355969 if (!notepatBtn) {
59365936- notepatBtn = new $.ui.TextButtonSmall(notepatLabel, { x: notepatX, y: buttonY });
59705970+ notepatBtn = new $.ui.TextButtonSmall(notepatLabel, notepatPos);
59375971 } else {
59385938- notepatBtn.reposition({ x: notepatX, y: buttonY }, notepatLabel);
59725972+ notepatBtn.reposition(notepatPos, notepatLabel);
59395973 notepatBtn.btn.disabled = false;
59745974+ }
59755975+ // Shrink the box to the real text width and re-center when stacked.
59765976+ notepatBtn.btn.box.w = notepatWidth;
59775977+ if (stackVertically) {
59785978+ notepatBtn.btn.box.x = Math.floor((screen.width - notepatWidth) / 2);
59405979 }
59415980 const nBox = notepatBtn.btn.box;
59425981 const nHover = notepatBtn.btn.over && !notepatBtn.btn.down;
+481
system/public/are.na-annual/index.html
···11+<!doctype html>
22+<html lang="en">
33+<head>
44+<meta charset="utf-8">
55+<meta name="viewport" content="width=device-width,initial-scale=1">
66+<title>whistlegraph and the self-teaching score · are.na annual vol. 8 pitch</title>
77+<meta name="description" content="Jeffrey Alan Scudder's pitch for Are.na Annual Vol. 8 (theme: Score) — whistlegraph as the first viral graphic score.">
88+<link rel="icon" href="https://aesthetic.computer/icon/128x128/prompt.png" type="image/png">
99+<link rel="stylesheet" href="https://aesthetic.computer/type/webfonts/berkeley-mono-variable.css">
1010+<style>
1111+ @font-face {
1212+ font-family: 'YWFT Processing';
1313+ src: url('https://aesthetic.computer/type/webfonts/ywft-processing-regular.woff2') format('woff2');
1414+ font-weight: normal;
1515+ font-display: swap;
1616+ }
1717+ @font-face {
1818+ font-family: 'YWFT Processing';
1919+ src: url('https://aesthetic.computer/type/webfonts/ywft-processing-bold.woff2') format('woff2');
2020+ font-weight: bold;
2121+ font-display: swap;
2222+ }
2323+2424+ :root {
2525+ --bg: #1a1a2e;
2626+ --text: #e8e8e8;
2727+ --dim: #888;
2828+ --pink: #cd5c9b;
2929+ --cyan: #4ecdc4;
3030+ --purple: #7850b4;
3131+ --gold: #d4a017;
3232+ --green: #4ecb71;
3333+ --red: #e06666;
3434+ --box-bg: rgba(255,255,255,0.03);
3535+ --box-border: rgba(255,255,255,0.10);
3636+ }
3737+ @media (prefers-color-scheme: light) {
3838+ :root:not(.dark-mode) {
3939+ --bg: #f5f5f5;
4040+ --text: #1a1a2e;
4141+ --dim: #666;
4242+ --pink: #b4489a;
4343+ --cyan: #0891b2;
4444+ --purple: #7850b4;
4545+ --gold: #a07800;
4646+ --green: #0a8a3e;
4747+ --red: #b33a3a;
4848+ --box-bg: rgba(0,0,0,0.03);
4949+ --box-border: rgba(0,0,0,0.12);
5050+ }
5151+ }
5252+5353+ * { margin: 0; padding: 0; box-sizing: border-box; }
5454+ ::-webkit-scrollbar { display: none; }
5555+5656+ html, body { background: var(--bg); color: var(--text); }
5757+ body {
5858+ font-family: 'Berkeley Mono Variable', 'Menlo', monospace;
5959+ font-size: 13px;
6060+ line-height: 1.55;
6161+ -webkit-text-size-adjust: none;
6262+ padding: 1.4em 1.6em 4em;
6363+ min-height: 100vh;
6464+ }
6565+ @media (min-width: 900px) { body { padding: 1.8em 2.2em 5em; } }
6666+6767+ a { color: var(--cyan); text-decoration: none; }
6868+ a:hover { color: var(--pink); }
6969+7070+ .wrap { max-width: 900px; margin: 0 auto; }
7171+7272+ /* ── MASTHEAD ─────────────────────────────── */
7373+ .mast { margin-bottom: 2em; }
7474+ .eyebrow {
7575+ color: var(--dim);
7676+ font-size: 0.75em;
7777+ letter-spacing: 0.18em;
7878+ text-transform: uppercase;
7979+ }
8080+ .eyebrow .tag { color: var(--pink); }
8181+8282+ h1 {
8383+ font-family: 'YWFT Processing', 'Berkeley Mono Variable', monospace;
8484+ font-size: clamp(32px, 5.6vw, 56px);
8585+ font-weight: normal;
8686+ letter-spacing: -0.01em;
8787+ line-height: 1.02;
8888+ margin: 0.2em 0 0.2em;
8989+ }
9090+ h1 .dot { color: var(--pink); }
9191+ h1 em {
9292+ font-style: normal;
9393+ color: var(--cyan);
9494+ }
9595+9696+ .sub {
9797+ color: var(--dim);
9898+ font-size: 0.95em;
9999+ margin-bottom: 1.1em;
100100+ }
101101+ .sub strong { color: var(--text); font-weight: normal; }
102102+103103+ .meta {
104104+ display: grid;
105105+ grid-template-columns: max-content 1fr;
106106+ gap: 0.15em 1.2em;
107107+ padding: 0.8em 1em;
108108+ background: var(--box-bg);
109109+ border: 1px solid var(--box-border);
110110+ border-radius: 4px;
111111+ font-size: 0.88em;
112112+ }
113113+ .meta dt { color: var(--dim); text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.85em; padding-top: 0.18em; }
114114+ .meta dd { margin: 0; }
115115+ .meta .due { color: var(--gold); }
116116+117117+ /* ── SECTIONS ─────────────────────────────── */
118118+ section { margin: 2.4em 0; }
119119+ h2 {
120120+ font-family: 'YWFT Processing', 'Berkeley Mono Variable', monospace;
121121+ font-size: 1.4em;
122122+ font-weight: normal;
123123+ color: var(--text);
124124+ letter-spacing: 0;
125125+ padding-bottom: 0.3em;
126126+ border-bottom: 1px solid var(--box-border);
127127+ margin-bottom: 1em;
128128+ }
129129+ h2 .ord { color: var(--pink); margin-right: 0.4em; }
130130+ h2 .count { float: right; color: var(--dim); font-size: 0.7em; letter-spacing: 0.1em; text-transform: uppercase; padding-top: 0.6em; font-family: 'Berkeley Mono Variable', monospace; }
131131+132132+ /* ── PITCH PROSE ─────────────────────────── */
133133+ .pitch p { margin: 0 0 0.9em; font-size: 0.98em; }
134134+ .pitch p:last-child { margin-bottom: 0; }
135135+ .pitch .drop {
136136+ font-family: 'YWFT Processing', monospace;
137137+ font-size: 1.25em;
138138+ color: var(--cyan);
139139+ display: block;
140140+ margin: 1.4em 0 0.6em;
141141+ }
142142+ .pitch q {
143143+ quotes: "“" "”";
144144+ color: var(--gold);
145145+ font-style: italic;
146146+ }
147147+ .pitch em { color: var(--pink); font-style: normal; }
148148+ .pitch .name { color: var(--cyan); }
149149+150150+ /* ── ANCHORS ──────────────────────────────── */
151151+ .anchors {
152152+ display: grid;
153153+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
154154+ gap: 0.6em;
155155+ margin: 1em 0;
156156+ }
157157+ .anchor {
158158+ padding: 0.8em 1em;
159159+ background: var(--box-bg);
160160+ border: 1px solid var(--box-border);
161161+ border-left: 2px solid var(--pink);
162162+ border-radius: 3px;
163163+ }
164164+ .anchor b {
165165+ display: block;
166166+ color: var(--pink);
167167+ font-family: 'YWFT Processing', monospace;
168168+ font-size: 1.05em;
169169+ font-weight: normal;
170170+ margin-bottom: 0.2em;
171171+ }
172172+ .anchor p { margin: 0; color: var(--text); font-size: 0.88em; line-height: 1.5; }
173173+174174+ /* ── LINEAGE LIST ─────────────────────────── */
175175+ .lineage { display: grid; gap: 0.5em; }
176176+ .lineage .row {
177177+ display: grid;
178178+ grid-template-columns: 120px 1fr;
179179+ gap: 1em;
180180+ padding: 0.5em 0;
181181+ border-bottom: 1px dotted var(--box-border);
182182+ font-size: 0.92em;
183183+ }
184184+ .lineage .row:last-child { border-bottom: none; }
185185+ .lineage .who { color: var(--cyan); }
186186+ .lineage .claim { color: var(--text); }
187187+ .lineage .claim em { color: var(--pink); font-style: normal; }
188188+189189+ /* ── CHANNEL (live) ───────────────────────── */
190190+ .channel {
191191+ margin-top: 1.2em;
192192+ }
193193+ .channel-status {
194194+ font-size: 0.8em;
195195+ color: var(--dim);
196196+ margin-bottom: 1em;
197197+ padding: 0.4em 0.6em;
198198+ border-left: 2px solid var(--gold);
199199+ background: var(--box-bg);
200200+ }
201201+ .channel-status .ok { color: var(--green); }
202202+ .channel-status .err { color: var(--red); }
203203+204204+ .section-break {
205205+ margin: 1.6em 0 0.6em;
206206+ padding: 0.4em 0;
207207+ border-top: 1px solid var(--box-border);
208208+ font-family: 'Berkeley Mono Variable', monospace;
209209+ text-transform: uppercase;
210210+ letter-spacing: 0.14em;
211211+ font-size: 0.75em;
212212+ color: var(--dim);
213213+ }
214214+ .section-break b { color: var(--pink); font-weight: normal; margin-right: 0.5em; }
215215+ .section-break .note { color: var(--dim); text-transform: none; letter-spacing: 0; margin-left: 0.8em; font-style: italic; }
216216+217217+ .blocks-grid {
218218+ display: grid;
219219+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
220220+ gap: 0.8em;
221221+ }
222222+ .block {
223223+ display: flex;
224224+ flex-direction: column;
225225+ background: var(--box-bg);
226226+ border: 1px solid var(--box-border);
227227+ border-radius: 3px;
228228+ overflow: hidden;
229229+ transition: border-color 0.1s;
230230+ }
231231+ .block:hover { border-color: var(--cyan); }
232232+ .block.text { grid-column: 1 / -1; padding: 0.9em 1.1em; background: transparent; border: none; border-left: 2px solid var(--gold); border-radius: 0; }
233233+ .block.text .txt {
234234+ color: var(--gold);
235235+ font-size: 1.05em;
236236+ line-height: 1.4;
237237+ font-family: 'YWFT Processing', monospace;
238238+ }
239239+ .block .thumb {
240240+ aspect-ratio: 1 / 1;
241241+ background: #0a0a18 center/cover no-repeat;
242242+ display: block;
243243+ border-bottom: 1px solid var(--box-border);
244244+ }
245245+ .block .body { padding: 0.6em 0.8em; display: flex; flex-direction: column; gap: 0.25em; flex: 1; }
246246+ .block .title { color: var(--cyan); font-size: 0.92em; line-height: 1.3; word-break: break-word; }
247247+ .block .title:hover { color: var(--pink); }
248248+ .block .why { color: var(--dim); font-size: 0.82em; line-height: 1.4; margin-top: 0.2em; }
249249+ .block .host { color: var(--purple); font-size: 0.72em; letter-spacing: 0.05em; margin-top: auto; padding-top: 0.4em; }
250250+251251+ /* ── CHECKLIST ────────────────────────────── */
252252+ .check { list-style: none; padding: 0; font-size: 0.9em; }
253253+ .check li { padding: 0.35em 0; display: flex; gap: 0.6em; }
254254+ .check li::before { font-family: 'Berkeley Mono Variable', monospace; width: 1em; text-align: center; }
255255+ .check .done { color: var(--dim); }
256256+ .check .done::before { content: "✓"; color: var(--green); }
257257+ .check .todo::before { content: "▸"; color: var(--gold); }
258258+ .check .todo { color: var(--text); }
259259+ .check .todo em { color: var(--gold); font-style: normal; }
260260+261261+ footer {
262262+ margin-top: 4em;
263263+ padding-top: 1em;
264264+ border-top: 1px solid var(--box-border);
265265+ color: var(--dim);
266266+ font-size: 0.75em;
267267+ letter-spacing: 0.08em;
268268+ }
269269+ footer a { color: var(--dim); }
270270+ footer a:hover { color: var(--cyan); }
271271+272272+ @media (max-width: 560px) {
273273+ body { padding: 1.2em 1em 3em; font-size: 13px; }
274274+ .lineage .row { grid-template-columns: 1fr; gap: 0.1em; }
275275+ .meta { grid-template-columns: 1fr; gap: 0.05em; }
276276+ .meta dt { padding-top: 0.6em; }
277277+ }
278278+</style>
279279+</head>
280280+<body>
281281+<main class="wrap">
282282+283283+ <header class="mast">
284284+ <div class="eyebrow"><span class="tag">Are<span class="dot" style="color:var(--pink)">.</span>na Annual Vol. 8</span> · Theme: Score · Open Call</div>
285285+ <h1>Whistlegraph<br>and the <em>Self-Teaching<br>Score</em><span class="dot">.</span></h1>
286286+ <p class="sub">Pitch by <strong>@jeffrey</strong> (Jeffrey Alan Scudder) · <a href="https://aesthetic.computer">aesthetic.computer</a></p>
287287+ <dl class="meta">
288288+ <dt>Channel</dt><dd><a href="https://www.are.na/aesthetic-computer/self-teaching-scores">are.na/aesthetic-computer/self-teaching-scores</a></dd>
289289+ <dt>Form</dt><dd><a href="https://aredotna.notion.site/3178a0f816d9815abdf3cb1624bb9e88">aredotna.notion.site — Vol. 8 submission</a></dd>
290290+ <dt>Due</dt><dd class="due">Mon, 20 Apr 2026 · 11:59 PM EST</dd>
291291+ <dt>Honorarium</dt><dd>$200 · book released Dec 2026</dd>
292292+ </dl>
293293+ </header>
294294+295295+ <!-- ── PITCH ───────────────────────────────── -->
296296+ <section class="pitch">
297297+ <h2><span class="ord">§</span>Pitch</h2>
298298+299299+ <p>I want to write about <em>whistlegraph</em> — a drawing form I invented in 2019, where every line is a sung syllable of a poem. You draw it while you sing it. The finished drawing is a score. The score teaches you how to play it.</p>
300300+301301+ <p>Between 2019 and 2023, a trio — me, <span class="name">Alex Freundlich</span>, and <span class="name">Camille Klein</span> — scaled the form from a cabin in <span class="name">Ashland, Oregon</span> to <em>2.6 million</em> followers on TikTok. No paid promotion. No trend-jacking. No dance. The early viral pieces were four strokes, four sung lines — kids learned them faster than most kids learn <em>Twinkle, Twinkle</em>.</p>
302302+303303+ <p>I want to write about <em>why</em> it worked there.</p>
304304+305305+ <span class="drop">The graphic-score tradition failed at one thing.</span>
306306+307307+ <p>Cardew's <em>Treatise</em> is 193 pages of beautiful, unreadable geometry. Cage's <em>Fontana Mix</em> needs a transparent overlay. Riley's <em>In C</em> wants 53 phrases and an ensemble. The 20th-century graphic score succeeded as art — <em>Notations</em>, MoMA, 1969 — and failed as communication. The premise was that notation could be anything. The consequence was that performance required a trained interpreter.</p>
308308+309309+ <p>Whistlegraph takes the same ingredients and swaps one assumption: the relationship between mark and sound is <em>one-to-one</em>, fixed during the first performance and preserved in the recording. No interpretation. No ambiguity. A child who can hold a pen can perform one after watching it once.</p>
310310+311311+ <p>That is the actual innovation, and it is the reason a drawing went viral on a platform designed for lip-sync. A dance challenge works because the choreography is the score; a whistlegraph works for the same reason, except the score is also original composition, also the artwork, also the instruction manual. Papert would have called it a <em>microworld</em>. Eshun's <em>sonic fiction</em> has a visual analogue here: a drawing that constructs the performance it depicts.</p>
312312+313313+ <span class="drop">Three registers, one object.</span>
314314+315315+ <p><strong>As art</strong>, whistlegraph lives downstream of Cardew, Cage, and the Fluxus event score. Rhizome / New Museum commissioned a 22-minute chalk whistlegraph in 2022; Feral File sold 45 editions the same year; SMK, KADIST, and the Schneider Museum hold work in collection. <strong>As content</strong>, it broke the viral-mechanics assumption that TikTok only rewards bodies and pop audio. <strong>As interface design</strong>, it is the founding principle of <a href="https://aesthetic.computer">aesthetic.computer</a> — every piece a self-documenting score, every URL a memorizable performance.</p>
316316+317317+ <p>The trio separated in November 2023. The form did not. What is left is the claim: <em>reproducibility, not novelty, is the real score of a form.</em> The essay is about what it would mean to design more things this way — objects whose instructions and performance are the same object.</p>
318318+319319+ <p style="color:var(--dim);font-size:0.88em;margin-top:1.4em">~570 words of actual pitch, too long for the form (~170–250). The condensed version lives at the bottom of this page and in the channel description.</p>
320320+ </section>
321321+322322+ <!-- ── ANCHORS ─────────────────────────────── -->
323323+ <section>
324324+ <h2><span class="ord">§</span>Three Registers</h2>
325325+ <div class="anchors">
326326+ <div class="anchor">
327327+ <b>Art</b>
328328+ <p>Downstream of Cardew's <em>Scratch Orchestra</em> and Cage's <em>Notations</em>, but refuses interpretation in favor of one-to-one legibility. The first graphic score a child can play.</p>
329329+ </div>
330330+ <div class="anchor">
331331+ <b>Content</b>
332332+ <p>2.6 million on TikTok, no dance. The viral mechanism is the form itself — not the algorithm, not the trend. Reproducibility as distribution.</p>
333333+ </div>
334334+ <div class="anchor">
335335+ <b>Interface</b>
336336+ <p>The founding principle of aesthetic.computer: every piece a self-documenting score, every URL a memorizable performance.</p>
337337+ </div>
338338+ </div>
339339+ </section>
340340+341341+ <!-- ── LINEAGE ─────────────────────────────── -->
342342+ <section>
343343+ <h2><span class="ord">§</span>The Lineage The Essay Argues<span class="count">specific, not canonical</span></h2>
344344+ <div class="lineage">
345345+ <div class="row"><div class="who">Goodiepal</div><div class="claim"><em>El Camino del Hardcore</em> (2012, ALKU 83) — a 191-page score printed in Comic Sans and Courier on grey cardboard. Radical Computer Music for alternative intelligences. The most honest predecessor.</div></div>
346346+ <div class="row"><div class="who">Jacob Ciocci</div><div class="claim">Paper Rad co-founder. Wrote <em>The Butterfly Effect / Rules Set You Free</em> for the 2023 Whistlegraph Zine (ed. Asher Penn, <em>Sex Magazine</em>, 750 copies) — the essay that placed the form in the rule-based community-art lineage.</div></div>
347347+ <div class="row"><div class="who">Cornelius Cardew</div><div class="claim"><em>Scratch Orchestra</em> and <em>Nature Study Notes</em> — not just <em>Treatise</em>. The group was the attempt to solve the interpretation problem. Whistlegraph attacks the same problem from the other side: remove interpretation from the score.</div></div>
348348+ <div class="row"><div class="who">Pauline Oliveros</div><div class="claim"><em>Sonic Meditations</em>. Attention as performance. Whistlegraph asks the same of the viewer: watch, learn, reproduce.</div></div>
349349+ <div class="row"><div class="who">Seymour Papert</div><div class="claim"><em>Mindstorms</em> (1980) — microworlds. A whistlegraph is a microworld: constrained, memorizable, constructible by the viewer.</div></div>
350350+ <div class="row"><div class="who">Kodwo Eshun</div><div class="claim"><em>More Brilliant Than the Sun</em> (1998) — sonic fiction. Whistlegraph is its visual analogue: a drawing that constructs the performance it depicts.</div></div>
351351+ <div class="row"><div class="who">Paul Klee</div><div class="claim"><em>Pedagogical Sketchbook</em> (1925) — "the line that goes for a walk." Whistlegraph's lines take walks while singing.</div></div>
352352+ <div class="row"><div class="who">Olia Lialina / JODI</div><div class="claim">Rhizome ArtBase neighbours. Whistlegraph sits in the same collection as early net art — the precedent for TikTok-native work entering an institutional archive.</div></div>
353353+ <div class="row"><div class="who">Jalaiah Harmon</div><div class="claim"><em>Renegade</em> (2019). Proof that a reproducible body-score can spread through diffusion, not recommendation. Whistlegraph is the drawing equivalent.</div></div>
354354+ </div>
355355+ </section>
356356+357357+ <!-- ── CHANNEL (live) ──────────────────────── -->
358358+ <section>
359359+ <h2><span class="ord">§</span>The Channel<span class="count">live from are.na · 68 blocks</span></h2>
360360+ <div id="channel-status" class="channel-status">
361361+ Fetching <span class="ok">api.are.na</span> …
362362+ </div>
363363+ <div id="channel" class="channel"></div>
364364+ </section>
365365+366366+ <!-- ── CONDENSED ──────────────────────────── -->
367367+ <section>
368368+ <h2><span class="ord">§</span>Submission-Form Version<span class="count">~200 words</span></h2>
369369+ <div class="pitch" style="border-left: 2px solid var(--pink); padding-left: 1em">
370370+ <p><em>Whistlegraph and the Self-Teaching Score.</em> I want to write about <em>whistlegraph</em> — a drawing form I invented in 2019, where every line is a sung syllable. Between 2020 and 2023, a trio I was in scaled it to 2.6 million TikTok followers from a cabin in Ashland, Oregon. No paid promotion. The distribution model was the form itself.</p>
371371+ <p>The 20th-century graphic-score tradition — Cardew, Cage, Brown, Feldman — is an art-historical success and a communication failure. Notation could be anything, so performance required a trained interpreter. Whistlegraph keeps the graphic score and removes the interpretation: mark-to-sound is one-to-one, fixed at performance time. A child who can hold a pen can reproduce it.</p>
372372+ <p>The essay moves through three registers. <strong>As art</strong> — downstream of Cardew, Ciocci, and Goodiepal, but for anyone. <strong>As content</strong> — the first viral graphic score. <strong>As interface design</strong> — the founding principle of aesthetic.computer.</p>
373373+ <p>What I want to work out: why <em>reproducibility</em>, not novelty, is the real score of a form, and what it would mean to design more objects this way — instructions and performance as the same object.</p>
374374+ </div>
375375+ </section>
376376+377377+ <!-- ── CHECKLIST ──────────────────────────── -->
378378+ <section>
379379+ <h2><span class="ord">§</span>Status</h2>
380380+ <ul class="check">
381381+ <li class="done">Channel live and public, 68 blocks, all annotated.</li>
382382+ <li class="done">Channel description set (via web UI — the v2 PUT does not persist it).</li>
383383+ <li class="done">Other personal channels set to private; profile reads as focused.</li>
384384+ <li class="done"><code>ARENA_TOKEN</code> stashed in <code>aesthetic-computer-vault/.env</code>.</li>
385385+ <li class="todo">Fill the Notion form — paste the 200-word version and channel URL. <em>Due tonight, 11:59 PM EST.</em></li>
386386+ </ul>
387387+ </section>
388388+389389+ <footer>
390390+ <a href="https://aesthetic.computer/are.na-annual">aesthetic.computer/are.na-annual</a> · 2026-04-20 · live channel fetch from api.are.na
391391+ </footer>
392392+393393+</main>
394394+395395+<script>
396396+(async () => {
397397+ const SLUG = "self-teaching-scores";
398398+ const API = `https://api.are.na/v2/channels/${SLUG}/contents?per=100&direction=desc`;
399399+ const statusEl = document.getElementById("channel-status");
400400+ const root = document.getElementById("channel");
401401+402402+ // Reading-order sections (top → bottom of channel page)
403403+ // positions are 1..68 in the channel, 68 = top
404404+ const SECTIONS = [
405405+ { name: "§10 — Whistlegraph", note: "The subject. Everything lands here.", min: 64 },
406406+ { name: "§9 — Framing Text", note: "The thesis spoken plainly.", min: 61 },
407407+ { name: "§8 — Computational / Card-Sized Kin", note: "Environments where the program is its own score.", min: 54 },
408408+ { name: "§7 — 20th-Century Graphic Scores", note: "The canon we diverge from.", min: 39 },
409409+ { name: "§6 — Fluxus & Event Scores", note: "Single-instruction, card-sized scores — closest kin.", min: 33 },
410410+ { name: "§5 — Vernacular / Folk Notation", note: "Notations that teach by use.", min: 25 },
411411+ { name: "§4 — Sport as Line", note: "Spatial scores on real terrain.", min: 20 },
412412+ { name: "§3 — Body / Movement Notation", note: "The West's attempts to notate bodies.", min: 15 },
413413+ { name: "§2 — Instructional / Craft", note: "The \"score teaches itself\" claim made mundane.", min: 7 },
414414+ { name: "§1 — Viral / Social Kin", note: "The contemporary record: formats that spread by being reproducible.", min: 1 },
415415+ ];
416416+417417+ function hostname(u) {
418418+ try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return ""; }
419419+ }
420420+421421+ function sectionFor(pos) {
422422+ for (const s of SECTIONS) if (pos >= s.min) return s;
423423+ return SECTIONS[SECTIONS.length - 1];
424424+ }
425425+426426+ function renderBlocks(blocks) {
427427+ // Sort top-of-channel first
428428+ const sorted = [...blocks].sort((a, b) => b.position - a.position);
429429+ // Group by section
430430+ const groups = new Map();
431431+ for (const b of sorted) {
432432+ const s = sectionFor(b.position);
433433+ if (!groups.has(s.name)) groups.set(s.name, { s, blocks: [] });
434434+ groups.get(s.name).blocks.push(b);
435435+ }
436436+437437+ const out = [];
438438+ for (const [name, { s, blocks: bs }] of groups) {
439439+ out.push(`<div class="section-break"><b>${s.name}</b><span class="note">${s.note}</span></div>`);
440440+ out.push(`<div class="blocks-grid">`);
441441+ for (const b of bs) {
442442+ if (b.class === "Text") {
443443+ const txt = (b.content || "").replace(/\*([^*]+)\*/g, "<em>$1</em>");
444444+ out.push(`<div class="block text"><div class="txt">${txt}</div></div>`);
445445+ continue;
446446+ }
447447+ const url = b.source?.url || "#";
448448+ const title = b.title || b.generated_title || url;
449449+ const img = b.image?.display?.url || b.image?.thumb?.url || "";
450450+ const desc = b.description || "";
451451+ const host = hostname(url);
452452+ out.push(
453453+ `<a class="block" href="${url}" target="_blank" rel="noreferrer">` +
454454+ (img ? `<span class="thumb" style="background-image:url('${img}')"></span>` : "") +
455455+ `<div class="body">` +
456456+ `<div class="title">${title}</div>` +
457457+ (desc ? `<div class="why">${desc}</div>` : "") +
458458+ (host ? `<div class="host">${host}</div>` : "") +
459459+ `</div>` +
460460+ `</a>`
461461+ );
462462+ }
463463+ out.push(`</div>`);
464464+ }
465465+ root.innerHTML = out.join("");
466466+ }
467467+468468+ try {
469469+ const r = await fetch(API);
470470+ if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
471471+ const data = await r.json();
472472+ const blocks = data.contents || [];
473473+ statusEl.innerHTML = `<span class="ok">● Live</span> · ${blocks.length} blocks fetched from <a href="https://www.are.na/aesthetic-computer/${SLUG}">are.na/aesthetic-computer/${SLUG}</a>`;
474474+ renderBlocks(blocks);
475475+ } catch (err) {
476476+ statusEl.innerHTML = `<span class="err">● API error</span> · ${err.message} · <a href="https://www.are.na/aesthetic-computer/${SLUG}">View channel on are.na</a>`;
477477+ }
478478+})();
479479+</script>
480480+</body>
481481+</html>
+4-3
system/public/kidlisp.com/keeps.html
···3690369036913691 async function fetchAllCodes(sort = 'recent') {
36923692 try {
36933693- // 2000 is well above any plausible per-user piece count; the server
36943694- // already filtered to acHandle so this is just a safety ceiling.
36953695- const url = buildStoreUrl(`&limit=2000&sort=${sort}`);
36933693+ // 20000 is a ceiling — @jeffrey already has ~4K pieces and the
36943694+ // server-side handle filter needs headroom, otherwise older
36953695+ // kept pieces get cut off before they reach the client.
36963696+ const url = buildStoreUrl(`&limit=20000&sort=${sort}`);
36963697 const res = await fetch(url, { cache: 'no-store' });
36973698 if (!res.ok) throw new Error(`HTTP ${res.status}`);
36983699 const data = await res.json();