Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

Merge branch 'claude/suspicious-leakey-7bb322': notepat/arena polish + are.na-annual submission

+3782 -1064
-1
.github
··· 1 - /home/me/aesthetic-computer/modes
+4
.gitignore
··· 71 71 !system/public/assets/papers/readings/text/ 72 72 system/public/assets/papers/readings/text/* 73 73 !system/public/assets/papers/readings/text/Gallope-Harren-Hicks-The-Scores-Project-2025.txt 74 + # M4L .amxd binaries hosted on lith directly (small, versioned, no CDN) 75 + # Use system/public/m4l/ (not /assets/m4l/) to bypass Caddy's /assets/*→CDN redirect. 76 + !system/public/m4l/ 74 77 75 78 # AestheticAnts runtime (logs, runs, test output - not part of the score) 76 79 ants/*.log ··· 336 339 system/.env 337 340 ac-vst/vst3sdk/ 338 341 *.amxd 342 + !system/public/m4l/*.amxd 339 343 340 344 # Emacs performance logs (keep directory, ignore log files) 341 345 .emacs-logs/*.log
+135 -22
ac-m4l/AC-NotepatRemote.amxd.json
··· 3 3 "fileversion": 1, 4 4 "appversion": { "major": 9, "minor": 0, "revision": 7, "architecture": "x64", "modernui": 1 }, 5 5 "classnamespace": "box", 6 - "rect": [100, 100, 700, 500], 7 - "openrect": [0, 0, 360, 220], 6 + "rect": [100, 100, 900, 520], 7 + "openrect": [0, 0, 360, 169], 8 8 "openinpresentation": 1, 9 9 "gridsize": [15, 15], 10 10 "enablehscroll": 0, 11 11 "enablevscroll": 0, 12 12 "devicewidth": 360, 13 - "description": "Receive ac-native notepat MIDI via session-server relay and emit to this track", 13 + "description": "ac-native notepat relay + local hotkey input. BIOS in the jweb~ iframe owns keyboard + octave state and emits finished pitches via window.max.outlet (notedown/noteup). Patcher just routes to [noteout].", 14 14 "boxes": [ 15 15 { 16 16 "box": { ··· 21 21 "numinlets": 1, 22 22 "numoutlets": 3, 23 23 "outlettype": ["signal", "signal", ""], 24 - "patching_rect": [10, 10, 360, 220], 24 + "patching_rect": [10, 10, 360, 169], 25 25 "presentation": 1, 26 - "presentation_rect": [0, 0, 360, 220], 26 + "presentation_rect": [0, 0, 360, 169], 27 27 "rendermode": 1, 28 - "url": "https://aesthetic.computer/notepat-remote?daw=1&nogap" 28 + "url": "https://aesthetic.computer/notepat-remote?daw=1&density=1&nogap&v=12" 29 29 } 30 30 }, 31 31 { 32 32 "box": { 33 - "comment": "Strip 'note' and 'channel' keywords from jweb messages", 33 + "comment": "Split jweb messages by symbol", 34 34 "id": "obj-route", 35 35 "maxclass": "newobj", 36 36 "numinlets": 1, 37 - "numoutlets": 3, 38 - "outlettype": ["", "", ""], 39 - "patching_rect": [10, 250, 180, 22], 40 - "text": "route note channel" 37 + "numoutlets": 7, 38 + "outlettype": ["", "", "", "", "", "", ""], 39 + "patching_rect": [10, 250, 560, 22], 40 + "text": "route note channel notedown noteup octave focus ping" 41 41 } 42 42 }, 43 43 { 44 44 "box": { 45 - "comment": "Emit MIDI into the track's next device", 45 + "comment": "MIDI output into the track's device chain", 46 46 "id": "obj-noteout", 47 47 "maxclass": "newobj", 48 48 "numinlets": 2, 49 49 "numoutlets": 0, 50 - "patching_rect": [10, 300, 60, 22], 50 + "patching_rect": [10, 400, 60, 22], 51 51 "text": "noteout" 52 52 } 53 53 }, 54 54 { 55 55 "box": { 56 - "comment": "Debug: unmatched messages land here", 57 - "id": "obj-print", 56 + "comment": "Note-on: pitch → (pitch, 100)", 57 + "id": "obj-pack-on", 58 + "maxclass": "newobj", 59 + "numinlets": 2, 60 + "numoutlets": 1, 61 + "outlettype": ["list"], 62 + "patching_rect": [10, 360, 90, 22], 63 + "text": "pack 0 100" 64 + } 65 + }, 66 + { 67 + "box": { 68 + "comment": "Note-off: pitch → (pitch, 0)", 69 + "id": "obj-pack-off", 70 + "maxclass": "newobj", 71 + "numinlets": 2, 72 + "numoutlets": 1, 73 + "outlettype": ["list"], 74 + "patching_rect": [120, 360, 90, 22], 75 + "text": "pack 0 0" 76 + } 77 + }, 78 + { 79 + "box": { 80 + "comment": "Debug: relay note pitch/vel (from WS path)", 81 + "id": "obj-print-note", 82 + "maxclass": "newobj", 83 + "numinlets": 1, 84 + "numoutlets": 0, 85 + "patching_rect": [600, 290, 200, 22], 86 + "text": "print NOTEPAT-NOTE" 87 + } 88 + }, 89 + { 90 + "box": { 91 + "comment": "Debug: channel from relay", 92 + "id": "obj-print-chan", 93 + "maxclass": "newobj", 94 + "numinlets": 1, 95 + "numoutlets": 0, 96 + "patching_rect": [600, 320, 200, 22], 97 + "text": "print NOTEPAT-CHAN" 98 + } 99 + }, 100 + { 101 + "box": { 102 + "comment": "Debug: BIOS-computed pitch (keydown)", 103 + "id": "obj-print-keydown", 104 + "maxclass": "newobj", 105 + "numinlets": 1, 106 + "numoutlets": 0, 107 + "patching_rect": [600, 360, 200, 22], 108 + "text": "print NOTEPAT-DOWN" 109 + } 110 + }, 111 + { 112 + "box": { 113 + "comment": "Debug: BIOS-computed pitch (keyup)", 114 + "id": "obj-print-keyup", 115 + "maxclass": "newobj", 116 + "numinlets": 1, 117 + "numoutlets": 0, 118 + "patching_rect": [600, 390, 200, 22], 119 + "text": "print NOTEPAT-UP" 120 + } 121 + }, 122 + { 123 + "box": { 124 + "comment": "Debug: current base octave", 125 + "id": "obj-print-octave", 126 + "maxclass": "newobj", 127 + "numinlets": 1, 128 + "numoutlets": 0, 129 + "patching_rect": [600, 420, 200, 22], 130 + "text": "print NOTEPAT-OCT" 131 + } 132 + }, 133 + { 134 + "box": { 135 + "comment": "Debug: iframe focus state (1 / 0)", 136 + "id": "obj-print-focus", 58 137 "maxclass": "newobj", 59 138 "numinlets": 1, 60 139 "numoutlets": 0, 61 - "patching_rect": [200, 300, 200, 22], 62 - "text": "print NOTEPAT-REMOTE" 140 + "patching_rect": [600, 450, 200, 22], 141 + "text": "print NOTEPAT-FOCUS" 142 + } 143 + }, 144 + { 145 + "box": { 146 + "comment": "Debug: unmatched jweb messages", 147 + "id": "obj-print-other", 148 + "maxclass": "newobj", 149 + "numinlets": 1, 150 + "numoutlets": 0, 151 + "patching_rect": [600, 480, 200, 22], 152 + "text": "print NOTEPAT-OTHER" 153 + } 154 + }, 155 + { 156 + "box": { 157 + "comment": "RTT: echo the ping back into jweb as a script call → window.acMaxPong(t0)", 158 + "id": "obj-sprintf-pong", 159 + "maxclass": "newobj", 160 + "numinlets": 1, 161 + "numoutlets": 1, 162 + "outlettype": [""], 163 + "patching_rect": [10, 290, 320, 22], 164 + "text": "sprintf script window.acMaxPong(%ld)" 63 165 } 64 166 }, 65 167 { ··· 69 171 "numinlets": 1, 70 172 "numoutlets": 3, 71 173 "outlettype": ["bang", "int", "int"], 72 - "patching_rect": [420, 250, 90, 22], 174 + "patching_rect": [240, 290, 90, 22], 73 175 "text": "live.thisdevice" 74 176 } 75 177 }, ··· 80 182 "numinlets": 1, 81 183 "numoutlets": 2, 82 184 "outlettype": ["", ""], 83 - "patching_rect": [420, 280, 60, 22], 185 + "patching_rect": [240, 320, 60, 22], 84 186 "text": "route ready" 85 187 } 86 188 }, 87 189 { 88 190 "box": { 89 - "comment": "Tell jweb~ page we are live (page can use this to trigger subscribe)", 191 + "comment": "Activate jweb once Live finishes init", 90 192 "id": "obj-activate", 91 193 "maxclass": "message", 92 194 "numinlets": 2, 93 195 "numoutlets": 1, 94 196 "outlettype": [""], 95 - "patching_rect": [420, 310, 90, 22], 197 + "patching_rect": [240, 350, 120, 22], 96 198 "text": "script daw-activate" 97 199 } 98 200 } ··· 100 202 "lines": [ 101 203 { "patchline": { "source": ["obj-jweb", 2], "destination": ["obj-route", 0] } }, 102 204 { "patchline": { "source": ["obj-route", 0], "destination": ["obj-noteout", 0] } }, 205 + { "patchline": { "source": ["obj-route", 0], "destination": ["obj-print-note", 0] } }, 103 206 { "patchline": { "source": ["obj-route", 1], "destination": ["obj-noteout", 1] } }, 104 - { "patchline": { "source": ["obj-route", 2], "destination": ["obj-print", 0] } }, 207 + { "patchline": { "source": ["obj-route", 1], "destination": ["obj-print-chan", 0] } }, 208 + { "patchline": { "source": ["obj-route", 2], "destination": ["obj-pack-on", 0] } }, 209 + { "patchline": { "source": ["obj-route", 2], "destination": ["obj-print-keydown", 0] } }, 210 + { "patchline": { "source": ["obj-route", 3], "destination": ["obj-pack-off", 0] } }, 211 + { "patchline": { "source": ["obj-route", 3], "destination": ["obj-print-keyup", 0] } }, 212 + { "patchline": { "source": ["obj-route", 4], "destination": ["obj-print-octave", 0] } }, 213 + { "patchline": { "source": ["obj-route", 5], "destination": ["obj-print-focus", 0] } }, 214 + { "patchline": { "source": ["obj-route", 6], "destination": ["obj-sprintf-pong", 0] } }, 215 + { "patchline": { "source": ["obj-sprintf-pong", 0], "destination": ["obj-jweb", 0] } }, 216 + { "patchline": { "source": ["obj-pack-on", 0], "destination": ["obj-noteout", 0] } }, 217 + { "patchline": { "source": ["obj-pack-off", 0], "destination": ["obj-noteout", 0] } }, 105 218 { "patchline": { "source": ["obj-thisdevice", 0], "destination": ["obj-routeready", 0] } }, 106 219 { "patchline": { "source": ["obj-routeready", 0], "destination": ["obj-activate", 0] } }, 107 220 { "patchline": { "source": ["obj-activate", 0], "destination": ["obj-jweb", 0] } }
+1 -1
ac-m4l/devices.json
··· 63 63 "piece": "notepat-remote", 64 64 "description": "Relay MIDI from ac-native notepat (ThinkPad) to this track via session-server", 65 65 "width": 360, 66 - "height": 220, 66 + "height": 169, 67 67 "type": "midi", 68 68 "source": "AC-NotepatRemote.amxd.json", 69 69 "version": "0.1.0"
+91 -47
fedac/native/initramfs/init
··· 18 18 mkdir -p /sys/kernel/debug 2>/dev/null 19 19 mount -t debugfs debugfs /sys/kernel/debug 2>/dev/null 20 20 21 - # zram swap 22 - modprobe zram 2>/dev/null || true 23 - if [ -e /sys/block/zram0/disksize ] && [ -b /dev/zram0 ]; then 24 - echo 1G > /sys/block/zram0/disksize && 25 - mkswap /dev/zram0 >/dev/null 2>&1 && 26 - swapon /dev/zram0 2>/dev/null 27 - fi 21 + # zram swap — skipped by default. notepat fits in ~200 MB, every ThinkPad 22 + # we target has ≥4 GB RAM, and the zram modprobe + mkswap chain adds 23 + # ~100-200 ms to boot for no observable benefit. Set AC_ZRAM=1 on the 24 + # kernel cmdline if you ever need it back (e.g. low-memory tablet boot). 25 + case " $(cat /proc/cmdline 2>/dev/null) " in 26 + *" AC_ZRAM=1 "*) 27 + modprobe zram 2>/dev/null || true 28 + if [ -e /sys/block/zram0/disksize ] && [ -b /dev/zram0 ]; then 29 + echo 1G > /sys/block/zram0/disksize && 30 + mkswap /dev/zram0 >/dev/null 2>&1 && 31 + swapon /dev/zram0 2>/dev/null 32 + fi 33 + ;; 34 + esac 28 35 29 36 # Loopback 30 37 ip link set lo up 2>/dev/null 31 38 32 - # Restore baked Claude credentials (tmpfs mount hid the originals) 33 - if [ -f /claude-creds.json ]; then 39 + # Restore baked Claude credentials (tmpfs mount hid the originals). 40 + # Two bake paths produce creds at /: 41 + # /claude-creds.json legacy full JSON (linux ac-os, reads ~/.claude/.credentials.json) 42 + # /claude-token plain OAuth bearer (macOS flash-mac.sh, MongoDB year-long token) 43 + # Either presence triggers setup of /tmp/.claude/ so Claude Code boots without 44 + # onboarding/login prompts. pty.c separately reads /claude-token to set 45 + # CLAUDE_CODE_OAUTH_TOKEN in the child's env. 46 + if [ -f /claude-creds.json ] || [ -f /claude-token ]; then 34 47 mkdir -p /tmp/.claude 35 - cp /claude-creds.json /tmp/.claude/.credentials.json 48 + [ -f /claude-creds.json ] && cp /claude-creds.json /tmp/.claude/.credentials.json 36 49 cp /claude-state.json /tmp/.claude.json 2>/dev/null 37 50 printf '{"permissions":{"allow":["Bash(*)","Read(*)","Write(*)","Edit(*)","Glob(*)","Grep(*)","WebFetch(*)","WebSearch(*)"]},"autoUpdates":false,"installMethod":"native"}\n' > /tmp/.claude/settings.json 38 51 fi ··· 50 63 echo "root:x:0:" > /etc/group 51 64 echo "root:x:0:root" > /etc/passwd 52 65 53 - # Wait for GPU (up to 3 seconds) 66 + # Kick off USB mount in the background — independent of GPU probe, so 67 + # we overlap the two waits instead of running them serially. The main 68 + # thread then waits for GPU (below) and finally waits for this mount 69 + # result before continuing. Shaves up to ~2s on cold boot. 70 + modprobe vfat 2>/dev/null 71 + modprobe nls_cp437 2>/dev/null 72 + modprobe nls_ascii 2>/dev/null 73 + USB_MOUNTED=0 74 + 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" 75 + 76 + mount_usb_partition() { 77 + pass="$1" 78 + for p in $USB_PARTS; do 79 + if [ -b "$p" ]; then 80 + mkdir -p /mnt 81 + mount -t vfat "$p" /mnt 2>/dev/null || continue 82 + if [ "$pass" = "config" ] && [ -f /mnt/config.json ]; then 83 + return 0 84 + fi 85 + if [ "$pass" = "boot" ] && { [ -f /mnt/EFI/BOOT/BOOTX64.EFI ] || [ -f /mnt/EFI/BOOT/KERNEL.EFI ]; }; then 86 + return 0 87 + fi 88 + umount /mnt 2>/dev/null 89 + fi 90 + done 91 + return 1 92 + } 93 + 94 + # Background USB mount: try up to 10 times with 1s delay, write result 95 + # to /run/usb-mounted so the foreground thread can check after GPU wait. 96 + ( 97 + for attempt in 1 2 3 4 5 6 7 8 9 10; do 98 + mount_usb_partition config && { echo 1 > /run/usb-mounted; exit 0; } 99 + mount_usb_partition boot && { echo 1 > /run/usb-mounted; exit 0; } 100 + sleep 1 101 + done 102 + echo 0 > /run/usb-mounted 103 + ) & 104 + USB_MOUNT_PID=$! 105 + 106 + # Wait for GPU (up to 3 seconds) — runs in parallel with USB mount above. 54 107 i=0 55 108 while [ ! -e /dev/dri/card0 ] && [ ! -e /dev/dri/card1 ] && [ ! -e /dev/fb0 ] && [ $i -lt 300 ]; do 56 109 usleep 10000 2>/dev/null || sleep 1 57 110 i=$((i+1)) 58 111 done 112 + 113 + # Converge: wait for the background USB mount to settle, pick up its result. 114 + wait $USB_MOUNT_PID 2>/dev/null 115 + [ -f /run/usb-mounted ] && USB_MOUNTED=$(cat /run/usb-mounted 2>/dev/null) && [ -z "$USB_MOUNTED" ] && USB_MOUNTED=0 59 116 60 117 # Performance governor (silently skip if cpufreq not available) 61 118 if [ -d /sys/devices/system/cpu/cpu0/cpufreq ]; then ··· 88 145 export COLORTERM="truecolor" 89 146 export EDITOR="/bin/vi" 90 147 91 - # ── Mount USB config/log partition (for config.json, wifi creds, logs) ── 92 - # Prefer the writable config partition over the boot partitions. 93 - modprobe vfat 2>/dev/null 94 - modprobe nls_cp437 2>/dev/null 95 - modprobe nls_ascii 2>/dev/null 96 - USB_MOUNTED=0 97 - 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" 98 - 99 - mount_usb_partition() { 100 - pass="$1" 101 - for p in $USB_PARTS; do 102 - if [ -b "$p" ]; then 103 - mkdir -p /mnt 104 - mount -t vfat "$p" /mnt 2>/dev/null || continue 105 - if [ "$pass" = "config" ] && [ -f /mnt/config.json ]; then 106 - USB_MOUNTED=1 107 - return 0 108 - fi 109 - if [ "$pass" = "boot" ] && { [ -f /mnt/EFI/BOOT/BOOTX64.EFI ] || [ -f /mnt/EFI/BOOT/KERNEL.EFI ]; }; then 110 - USB_MOUNTED=1 111 - return 0 112 - fi 113 - umount /mnt 2>/dev/null 114 - fi 115 - done 116 - return 1 117 - } 118 - 119 - for attempt in 1 2 3 4 5 6 7 8 9 10; do 120 - mount_usb_partition config && break 121 - mount_usb_partition boot && break 122 - [ "$USB_MOUNTED" = "1" ] && break 123 - sleep 1 124 - done 148 + # USB mount already settled above (kicked off in parallel with GPU wait). 149 + # USB_MOUNTED is populated from /run/usb-mounted at that wait() point. 125 150 126 151 # Create samples directory on boot media + mount point for music USB 127 152 if [ "$USB_MOUNTED" = "1" ]; then ··· 141 166 # Run ac-native in a loop — if it crashes, restart; if clean exit, shutdown 142 167 export LD_LIBRARY_PATH="/lib64:/usr/lib64:${LD_LIBRARY_PATH:-}" 143 168 144 - # Write diagnostics to console AND USB 169 + # Pre-launch diagnostics — useful for audio probe / GPU / PCI debugging, 170 + # but the full dump writes ~60 KB to FAT32 (slow USB), does dozens of 171 + # shell forks over sysfs, and on older ThinkPads adds 1–2 s of wall time 172 + # to boot. The whole block now runs in a backgrounded subshell so 173 + # ac-native launches immediately; the dump completes concurrently while 174 + # the splash fade is drawing. Downstream tools (os-install-report, 175 + # post-mortem analysis) still get the same pre-launch.log at the same 176 + # path — just a second later than before. 177 + # 178 + # Set AC_NOLAUNCH_DIAG=1 on the kernel cmdline to skip the dump entirely 179 + # (useful for locked-down production kiosks that don't want the sysfs 180 + # scraping cost at all). 145 181 echo "[init] USB_MOUNTED=$USB_MOUNTED" > /dev/tty0 2>/dev/null 146 182 if [ "$USB_MOUNTED" = "1" ]; then 147 183 LOG=/mnt/pre-launch.log 148 184 else 149 - # No USB config partition — try writing logs to /tmp 150 185 LOG=/tmp/pre-launch.log 151 186 fi 187 + 188 + _pre_launch_diag() { 152 189 echo "=== PRE-LAUNCH ===" > $LOG 153 190 ls /dev/dri/ >> $LOG 2>&1 154 191 echo "binary: $(ls -la /ac-native 2>&1)" >> $LOG ··· 357 394 echo "=== CMDLINE ===" >> $LOG 358 395 cat /proc/cmdline >> $LOG 2>&1 359 396 sync 397 + } # end _pre_launch_diag 398 + 399 + # Launch pre-launch diagnostics in the background unless cmdline disables. 400 + case " $(cat /proc/cmdline 2>/dev/null) " in 401 + *" AC_NOLAUNCH_DIAG=1 "*) : ;; 402 + *) _pre_launch_diag & ;; 403 + esac 360 404 echo "[init] GPU: $(ls /dev/dri/ 2>/dev/null || echo NONE) USB=$USB_MOUNTED" > /dev/tty0 2>/dev/null 361 405 362 406 # Start Swank server in background (if SBCL image exists)
+258 -99
fedac/native/pieces/notepat.mjs
··· 111 111 let clockSyncFrame = 0; // frame counter for periodic resync 112 112 function syncedNow() { return Date.now() + clockOffset; } 113 113 114 - // FX rows: dry/wet, echo, pitch, bitcrush. 114 + // FX rows: dry/wet, echo, pitch, bitcrush, volume, drive. 115 115 let echoMix = 0; 116 116 let bitcrushMix = 0; 117 117 let echoDragging = false; ··· 124 124 let volDragging = false; 125 125 let brtDragging = false; 126 126 127 + // Master volume (user-controlled, separate from system_volume hardware 128 + // mixer). Slider range 0..1 maps to audio gain 0..2 (0..200%), so 129 + // slider at 0.5 = unity gain (1.0×). Default 0.5 means the slider 130 + // starts at the unity tick so the device sounds identical to pre-slider 131 + // builds out of the box. 132 + let masterVolMix = 0.5; 133 + let masterVolDragging = false; 134 + 135 + // Drive (tanh soft-sat dry/wet). 0 = clean, 1 = full drive. Applied 136 + // BEFORE master volume so the slider feels like a tone control. 137 + let driveMix = 0; 138 + let driveDragging = false; 139 + 140 + // Waveform-strip view cursor — how many seconds in the past the playhead 141 + // sits relative to the live audio edge. 0 = live (wave drifts LEFT as 142 + // real time advances). Grows when spacebar is held (wave drifts RIGHT, 143 + // backwards-replay scrub). Shrinks back to 0 on release so the display 144 + // catches up to live audio over ~0.5 s. 145 + let waveViewOffsetSec = 0; 146 + // Max retreat — also caps how much of the right half can fill in with 147 + // post-cursor audio. Matches recordStripSeconds / 2 so the right half 148 + // fully paints when the cursor has retreated half the visible window. 149 + const WAVE_VIEW_MAX_OFFSET_SEC = 2.0; 150 + 127 151 // Pitch shift — assignable to either trackpad axis 128 152 let pitchShift = 0; // -1 to +1, 0 = no shift 129 153 let lastAppliedPitch = 0; // last pitch actually sent to synths (throttle) ··· 131 155 // Trackpad FX control (\ toggles on/off) 132 156 let trackpadFX = false; 133 157 let trackpadEffectBindings = { 134 - echo: { x: true, y: false }, 135 - pitch: { x: false, y: true }, 136 - crush: { x: false, y: false }, 158 + echo: { x: true, y: false }, 159 + pitch: { x: false, y: true }, 160 + crush: { x: false, y: false }, 161 + volume: { x: false, y: false }, 162 + drive: { x: false, y: false }, 137 163 }; 138 164 139 165 function clampRange(value, min, max) { ··· 166 192 return changed; 167 193 } 168 194 195 + function setMasterVolMixValue(value, sound, commit = true) { 196 + // Slider range 0..1 maps to audio gain 0..2 so the UI stays in the 197 + // same 0..100% pattern as the other FX rows. 50% = unity, 100% = 2x. 198 + const next = clamp01(value); 199 + const changed = Math.abs(next - masterVolMix) > 0.0005; 200 + masterVolMix = next; 201 + if (commit) sound?.volume?.setMix?.(masterVolMix * 2); 202 + return changed; 203 + } 204 + 205 + function setDriveMixValue(value, sound, commit = true) { 206 + const next = clamp01(value); 207 + const changed = Math.abs(next - driveMix) > 0.0005; 208 + driveMix = next; 209 + if (commit) sound?.drive?.setMix?.(driveMix); 210 + return changed; 211 + } 212 + 169 213 function applyPitchShiftToActiveSounds(force = false) { 170 214 const ep = effectivePitchShift(); 171 215 if (!force && Math.abs(ep - lastAppliedPitch) <= 0.001) return false; ··· 204 248 if (commit) sound?.fx?.setMix?.(fxMix); 205 249 return true; 206 250 } 207 - if (rowId === "echo") return setEchoMixValue(norm, sound, commit); 208 - if (rowId === "crush") return setBitcrushMixValue(norm, sound, commit); 209 - if (rowId === "pitch") return setPitchShiftValue(norm * 2 - 1, commit); 251 + if (rowId === "echo") return setEchoMixValue(norm, sound, commit); 252 + if (rowId === "crush") return setBitcrushMixValue(norm, sound, commit); 253 + if (rowId === "pitch") return setPitchShiftValue(norm * 2 - 1, commit); 254 + if (rowId === "volume") return setMasterVolMixValue(norm, sound, commit); 255 + if (rowId === "drive") return setDriveMixValue(norm, sound, commit); 210 256 return false; 211 257 } 212 258 ··· 1821 1867 } 1822 1868 1823 1869 function loadUdpMidiConfig(system) { 1870 + // Default ON: every notepat install should broadcast to the amxd plugin 1871 + // unless the user explicitly opts out by writing 1872 + // {"udpMidiBroadcast": false} 1873 + // into /mnt/config.json. Previously the flag defaulted to false and 1874 + // required `true` to enable, which silently blocked the relay on every 1875 + // OTA-updated device whose pre-existing config.json predates the flag. 1876 + udpMidiBroadcast = true; 1824 1877 try { 1825 1878 const raw = system?.readFile?.("/mnt/config.json"); 1826 - if (!raw) { 1879 + if (!raw) return; 1880 + const cfg = JSON.parse(raw); 1881 + if (cfg.udpMidiBroadcast === false || cfg.udpMidiBroadcast === "false") { 1827 1882 udpMidiBroadcast = false; 1828 - return; 1829 1883 } 1830 - const cfg = JSON.parse(raw); 1831 - udpMidiBroadcast = cfg.udpMidiBroadcast === true || cfg.udpMidiBroadcast === "true"; 1832 1884 } catch (_) { 1833 - udpMidiBroadcast = false; 1885 + // Keep default (true) on parse failure. 1834 1886 } 1835 1887 } 1836 1888 ··· 1855 1907 udpMidiNextHeartbeatFrame = frame + 300; 1856 1908 } 1857 1909 1910 + // Build the UDP-MIDI status line — parallel in form to usbMidiStatusText so 1911 + // the two indicators read as siblings in the top bar. States: 1912 + // "UDP MIDI OFF" broadcast disabled in /mnt/config.json 1913 + // "UDP MIDI ..." broadcast enabled, socket not yet up 1914 + // "UDP MIDI ON" socket up, no notes sent yet 1915 + // "UDP MIDI ON 60v100 ▸ 42" socket up, last note + total-sent count 1916 + // The caller colors based on whether a note was sent in the last ~1.5s 1917 + // (bright green) vs idle-but-connected (dim green) vs disconnected (amber). 1858 1918 function udpMidiRelayStatusText(system) { 1859 - if (!udpMidiBroadcast) return ""; 1860 - const handle = system?.udp?.handle || system?.config?.handle || ""; 1919 + if (!udpMidiBroadcast) return "UDP MIDI OFF"; 1861 1920 const connected = !!system?.udp?.connected; 1862 - const prefix = connected 1863 - ? (handle ? "relay @" + handle : "relay on") 1864 - : (handle ? "relay ...@" + handle : "relay ..."); 1865 - // Append counters + last note when actively sending so the overlay shows 1866 - // the ThinkPad actually broadcasts notes (and not just that the socket is up). 1867 - if (!connected) return prefix; 1868 - if (udpMidiSentCount === 0) return prefix + " 0"; 1869 - const recent = frame - udpMidiLastSentFrame < 90; // ~1.5s fresh window 1870 - const tail = recent && udpMidiLastPitch >= 0 1871 - ? ` ${udpMidiSentCount} ${udpMidiLastPitch}v${udpMidiLastVelocity}` 1872 - : ` ${udpMidiSentCount}`; 1873 - return prefix + tail; 1921 + if (!connected) return "UDP MIDI ..."; 1922 + if (udpMidiSentCount === 0) return "UDP MIDI ON"; 1923 + const recent = frame - udpMidiLastSentFrame < 90; 1924 + if (recent && udpMidiLastPitch >= 0) { 1925 + return `UDP MIDI ON ${udpMidiLastPitch}v${udpMidiLastVelocity} \u25B8 ${udpMidiSentCount}`; 1926 + } 1927 + return `UDP MIDI ON \u25B8 ${udpMidiSentCount}`; 1928 + } 1929 + 1930 + // Returns 0 when no note has ever been sent; otherwise 0..1 recency with 1931 + // 1.0 at the moment of the send and fading to 0 over ~90 frames (1.5s). 1932 + // Used to pulse the badge color from bright-green → dim-green as notes fire. 1933 + function udpMidiSendRecency() { 1934 + if (udpMidiSentCount === 0) return 0; 1935 + const age = frame - udpMidiLastSentFrame; 1936 + if (age < 0 || age > 90) return 0; 1937 + return 1 - (age / 90); 1874 1938 } 1875 1939 1876 1940 function rememberSound(key, entry, system, velocity = 1) { ··· 1961 2025 function playWaveSound(sound, waveType) { 1962 2026 if (!sound?.synth) return; 1963 2027 if (waveType === "sample") { 1964 - // Short percussive click for sample mode 1965 - sound.synth({ type: "noise", tone: 800 * pf, duration: 0.03, volume: 0.12, attack: 0.001, decay: 0.025, pan: 0 }); 2028 + // Short percussive click for sample mode. Previously this referenced 2029 + // `pf` (a local from playZoo/playLaser/playPercussion that never made 2030 + // it into this scope) — throwing a ReferenceError the moment anyone 2031 + // switched wave to "sample". Plain tone is fine for a UI blip. 2032 + sound.synth({ type: "noise", tone: 800, duration: 0.03, volume: 0.12, attack: 0.001, decay: 0.025, pan: 0 }); 1966 2033 return; 1967 2034 } 1968 2035 const tones = { sine: 660, triangle: 550, sawtooth: 440, square: 330, harp: 440, whistle: 880 }; ··· 2059 2126 sound?.room?.setMix?.(echoMix); 2060 2127 sound?.glitch?.setMix?.(bitcrushMix); 2061 2128 sound?.fx?.setMix?.(fxMix); 2129 + sound?.volume?.setMix?.(masterVolMix * 2); // slider 0..1 → audio 0..2 2130 + sound?.drive?.setMix?.(driveMix); 2062 2131 loadUdpMidiConfig(system); 2063 2132 udpMidiNextHeartbeatFrame = 0; 2064 2133 const mic = sound?.microphone || null; ··· 2820 2889 return; 2821 2890 } 2822 2891 if (pointInRect(x, y, { x: row.sliderX, y: row.y, w: row.sliderW, h: row.h })) { 2823 - if (rowId === "fx") fxDragging = true; 2824 - else if (rowId === "echo") echoDragging = true; 2825 - else if (rowId === "pitch") pitchDragging = true; 2826 - else if (rowId === "crush") bitcrushDragging = true; 2892 + if (rowId === "fx") fxDragging = true; 2893 + else if (rowId === "echo") echoDragging = true; 2894 + else if (rowId === "pitch") pitchDragging = true; 2895 + else if (rowId === "crush") bitcrushDragging = true; 2896 + else if (rowId === "volume") masterVolDragging = true; 2897 + else if (rowId === "drive") driveDragging = true; 2827 2898 setEffectRowFromPointer(rowId, x, row, sound, true); 2828 2899 return; 2829 2900 } ··· 3000 3071 djDragLastX = x; 3001 3072 } 3002 3073 const fxRows = globalThis.__fxRows || {}; 3003 - if (fxDragging) setEffectRowFromPointer("fx", x, fxRows.fx, sound, true); 3004 - if (echoDragging) setEffectRowFromPointer("echo", x, fxRows.echo, sound, true); 3005 - if (pitchDragging) setEffectRowFromPointer("pitch", x, fxRows.pitch, sound, true); 3006 - if (bitcrushDragging) setEffectRowFromPointer("crush", x, fxRows.crush, sound, true); 3074 + if (fxDragging) setEffectRowFromPointer("fx", x, fxRows.fx, sound, true); 3075 + if (echoDragging) setEffectRowFromPointer("echo", x, fxRows.echo, sound, true); 3076 + if (pitchDragging) setEffectRowFromPointer("pitch", x, fxRows.pitch, sound, true); 3077 + if (bitcrushDragging) setEffectRowFromPointer("crush", x, fxRows.crush, sound, true); 3078 + if (masterVolDragging) setEffectRowFromPointer("volume", x, fxRows.volume, sound, true); 3079 + if (driveDragging) setEffectRowFromPointer("drive", x, fxRows.drive, sound, true); 3007 3080 if (volDragging) { 3008 3081 const vb = globalThis.__volBar; 3009 3082 if (vb) { ··· 3106 3179 if (echoDragging) echoDragging = false; 3107 3180 if (pitchDragging) pitchDragging = false; 3108 3181 if (bitcrushDragging) bitcrushDragging = false; 3182 + if (masterVolDragging) masterVolDragging = false; 3183 + if (driveDragging) driveDragging = false; 3109 3184 if (volDragging) volDragging = false; 3110 3185 if (brtDragging) brtDragging = false; 3111 3186 // Release touch-triggered note ··· 3160 3235 if (trackpadFX && trackpad) { 3161 3236 let echoDirty = false; 3162 3237 let crushDirty = false; 3238 + let volDirty = false; 3239 + let driveDirty = false; 3163 3240 if (trackpad.dx !== 0) { 3164 3241 const dxNorm = trackpad.dx / Math.max(1, w); 3165 3242 if (trackpadEffectBindings.echo.x) { ··· 3171 3248 if (trackpadEffectBindings.pitch.x) { 3172 3249 setPitchShiftValue(pitchShift + dxNorm, false); 3173 3250 } 3251 + if (trackpadEffectBindings.volume.x) { 3252 + volDirty = setMasterVolMixValue(masterVolMix + dxNorm * 3, sound, false) || volDirty; 3253 + } 3254 + if (trackpadEffectBindings.drive.x) { 3255 + driveDirty = setDriveMixValue(driveMix + dxNorm * 3, sound, false) || driveDirty; 3256 + } 3174 3257 } 3175 3258 if (trackpad.dy !== 0) { 3176 3259 const dyNorm = -trackpad.dy / Math.max(1, h); ··· 3183 3266 if (trackpadEffectBindings.pitch.y) { 3184 3267 setPitchShiftValue(pitchShift + dyNorm, false); 3185 3268 } 3269 + if (trackpadEffectBindings.volume.y) { 3270 + volDirty = setMasterVolMixValue(masterVolMix + dyNorm * 3, sound, false) || volDirty; 3271 + } 3272 + if (trackpadEffectBindings.drive.y) { 3273 + driveDirty = setDriveMixValue(driveMix + dyNorm * 3, sound, false) || driveDirty; 3274 + } 3186 3275 } 3187 3276 if (echoDirty && frame % 3 === 0) sound?.room?.setMix?.(echoMix); 3188 3277 if (crushDirty && frame % 3 === 0) sound?.glitch?.setMix?.(bitcrushMix); 3278 + if (volDirty && frame % 3 === 0) sound?.volume?.setMix?.(masterVolMix * 2); 3279 + if (driveDirty && frame % 3 === 0) sound?.drive?.setMix?.(driveMix); 3189 3280 // Apply pitch shift to active voices — throttled to every 4th frame 3190 3281 // and only when pitch actually changed 3191 3282 if (frame % 4 === 0) applyPitchShiftToActiveSounds(false); ··· 3325 3416 } 3326 3417 3327 3418 // === STATUS BAR === 3328 - // Bar is tall enough to fit a 25px QR code (21-module QR version 1 with 3329 - // a 2-module quiet-zone margin, scale=1) in the top-left corner. 3330 - const topBarH = 26; 3331 - const barY = 10; // vertical offset for status text — matrix font is ~7px tall 3419 + // Bar is tall enough to fit a 50×50 QR code (21-module version-1 QR + 3420 + // 2-module quiet-zone margin at scale=2) in the top-left corner, with 3421 + // the text row sitting vertically centered below the QR's midline so 3422 + // readability at arm's length works on a phone-camera scan too. 3423 + const topBarH = 54; 3424 + const barY = 22; // center of text row — leaves 2 px top/bottom padding for status text at size=1 3332 3425 3333 3426 ink(BAR_BG[0], BAR_BG[1], BAR_BG[2]); 3334 3427 box(0, 0, w, topBarH, true); ··· 3353 3446 if (reserveSysBrt >= 0) statusRightReserve += 4 + 16 + 2 + 3 * CH; 3354 3447 const statusRightLimit = Math.max(80, w - statusRightReserve - 8); 3355 3448 3356 - // Left: tiny QR code → notepat.com (25x25 at scale=1), then label. 3357 - // Clicking anywhere in the label zone still jumps to prompt piece. 3449 + // Left: QR code → notepat.com. Scale=1, version-1 with 2-module quiet 3450 + // zone = 25×25 px. Taller top bar (54 px) leaves breathing room below 3451 + // the QR for the label + status text without cramping. C side caches 3452 + // the Reed-Solomon encoding so the inner module-grid blit is the only 3453 + // per-frame cost. 3358 3454 if (globalThis.qr) { 3359 - // qr() handles its own white background + margin. 3360 - globalThis.qr("https://notepat.com", 1, 1, 1); 3455 + globalThis.qr("https://notepat.com", 2, 2, 1); 3361 3456 } 3362 - const qrW = 26; // 25px QR + 1px padding before label 3457 + const qrW = 28; // 25px QR + 2px left inset + 1px right padding before label 3363 3458 const labelX = qrW + 4; 3364 3459 const labelW = 48; // "notepat.com" label width in matrix font at size=1 3365 3460 const npHovered = hoverX >= 0 && hoverX <= labelX + labelW && hoverY < topBarH; ··· 3460 3555 statusWrite("key:" + lastKeyPressed, 180, 220, 255, fadeA); 3461 3556 } 3462 3557 3558 + // UDP MIDI — sibling indicator to USB MIDI. Color encodes state: 3559 + // disabled → FG_DIM (flat "OFF") 3560 + // enabled + down → amber (255,190,80) "..." 3561 + // enabled + up → dim green (100,220,140) "ON" 3562 + // actively sending→ bright green, pulsing with recency 3563 + // The pulse decays over ~1.5s after each note so rapid play visibly 3564 + // lights the indicator vs just sitting on "connected". 3463 3565 const relayText = udpMidiRelayStatusText(system); 3464 3566 if (relayText) { 3465 - statusWrite( 3466 - relayText, 3467 - system?.udp?.connected ? 80 : 255, 3468 - system?.udp?.connected ? 180 : 180, 3469 - system?.udp?.connected ? 255 : 90, 3470 - 210 3471 - ); 3567 + if (!udpMidiBroadcast) { 3568 + statusWrite(relayText, FG_DIM, FG_DIM, FG_DIM, 200); 3569 + } else if (!system?.udp?.connected) { 3570 + statusWrite(relayText, 255, 190, 80, 220); 3571 + } else { 3572 + const recency = udpMidiSendRecency(); 3573 + // dim green (100,220,140) → bright green (160,255,190) as recency rises 3574 + const r = Math.round(100 + recency * 60); 3575 + const g = Math.round(220 + recency * 35); 3576 + const b = Math.round(140 + recency * 50); 3577 + statusWrite(relayText, r, g, b, 220); 3578 + } 3472 3579 } 3473 3580 3474 3581 // Metronome indicator (pendulum) in status bar — shown when enabled ··· 4137 4244 const leftX = margin; 4138 4245 const rightX = w - gridW - margin; 4139 4246 4140 - // Scrolling record-needle strip: the last ~4 seconds of mixed speaker 4141 - // output, always rolling regardless of whether notes are active. Think 4142 - // classic DJ turntable display — you can see the waveform the spacebar 4143 - // reverse-play would snap back into. Refreshed every 4 frames to keep 4144 - // paint cheap; downsampled to one peak value per pixel column. 4247 + // Scrolling record-needle strip with continuous-drift playback cursor. 4248 + // 4249 + // waveViewOffsetSec drives the cursor's position relative to live audio: 4250 + // = 0 → cursor at live edge, past on LEFT, right empty; 4251 + // wave drifts LEFT as new audio arrives 4252 + // > 0, growing → cursor retreats into the past; right half fills in 4253 + // with samples captured AFTER the cursor; wave drifts 4254 + // RIGHT (backwards-replay visual) 4255 + // > 0, shrinking→ cursor catches back up to "now"; wave drifts LEFT 4256 + // faster than normal until offset hits 0 4257 + // 4258 + // waveViewOffsetSec is advanced/retreated in sim() below based on 4259 + // spaceHeld. The C drawStrip reads the offset and renders accordingly 4260 + // in a single call (no JS peak loop). 4145 4261 const recordStripH = 22; 4146 4262 const recordStripSeconds = 4; 4147 4263 const recordStripTop = Math.max(topBarH + 1, gridTop - recordStripH - 2); 4148 - const recordStripBottom = recordStripTop + recordStripH; 4149 - if (frame % 4 === 0 && sound?.speaker?.getRecentBuffer) { 4150 - const snap = sound.speaker.getRecentBuffer(recordStripSeconds); 4151 - if (snap && snap.data && snap.data.length > 0) { 4152 - globalThis.__recordStripData = snap.data; 4153 - } 4154 - } 4155 - const rsData = globalThis.__recordStripData; 4156 - if (rsData && rsData.length > 4) { 4264 + if (sound?.speaker?.drawStrip) { 4157 4265 const rsX = margin; 4158 4266 const rsW = w - margin * 2; 4159 - const midY = Math.floor((recordStripTop + recordStripBottom) / 2); 4160 - // Background strip 4161 - ink(dark ? 20 : 235, dark ? 15 : 225, dark ? 30 : 210, 160); 4162 - box(rsX, recordStripTop, rsW, recordStripH, true); 4163 - // Center zero-line 4164 - ink(dark ? 80 : 140, dark ? 80 : 140, dark ? 90 : 150, 120); 4165 - line(rsX, midY, rsX + rsW, midY); 4166 - // Per-pixel-column peak of that time-slice. 4167 - const samplesPerCol = rsData.length / rsW; 4168 - const amp = Math.floor(recordStripH * 0.45); 4169 - for (let x = 0; x < rsW; x++) { 4170 - const i0 = Math.floor(x * samplesPerCol); 4171 - const i1 = Math.min(rsData.length, Math.floor((x + 1) * samplesPerCol)); 4172 - let peak = 0; 4173 - for (let i = i0; i < i1; i++) { 4174 - const a = Math.abs(rsData[i]); 4175 - if (a > peak) peak = a; 4176 - } 4177 - // Clip extreme outliers so a transient hot sample doesn't dominate the 4178 - // column-height math and flatten everything else visually. 4179 - if (peak > 1.0) peak = 1.0; 4180 - const h = Math.max(1, Math.round(peak * amp)); 4181 - // Color fades from warm (loud) through amber (mid) to cold (quiet). 4182 - const r = Math.min(255, Math.round(120 + peak * 140)); 4183 - const g = Math.round(120 + peak * 80); 4184 - const b = Math.round(90 + (1 - peak) * 120); 4185 - ink(r, g, b, 220); 4186 - line(rsX + x, midY - h, rsX + x, midY + h); 4187 - } 4188 - // Right-edge "record needle" — where new samples are being written. 4189 - ink(240, 80, 80, 220); 4190 - line(rsX + rsW - 1, recordStripTop, rsX + rsW - 1, recordStripBottom); 4267 + sound.speaker.drawStrip(rsX, recordStripTop, rsW, recordStripH, 4268 + recordStripSeconds, 0.5, waveViewOffsetSec); 4191 4269 } 4192 4270 4193 4271 // Waveform visualizer bars only in lanes above pad grids (not full-screen). ··· 4830 4908 fxRows.crush = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox }; 4831 4909 } 4832 4910 4911 + // Master volume slider — user gain 0..200% (50% on slider = unity). 4912 + // Sits below crush so it feels like a "last stage" like a mixing board 4913 + // master fader. A tick at 50% marks unity for visual reference. 4914 + { 4915 + const sliderY = settingsY + sliderH * 4; 4916 + const sliderW = w - axisAreaW; 4917 + const hov = hoverY >= sliderY && hoverY < sliderY + sliderH; 4918 + ink(dark ? (hov ? 40 : 25) : (hov ? 220 : 235), 4919 + dark ? (hov ? 40 : 25) : (hov ? 220 : 235), 4920 + dark ? (hov ? 45 : 28) : (hov ? 225 : 238)); 4921 + box(0, sliderY, w, sliderH, true); 4922 + const fillW = Math.floor(masterVolMix * sliderW); 4923 + if (fillW > 0) { 4924 + ink(100, 220, 150, trackpadFX ? 240 : 180); 4925 + box(0, sliderY, fillW, sliderH, true); 4926 + } 4927 + // Unity tick at the 50% position. 4928 + const unityX = Math.floor(sliderW * 0.5); 4929 + ink(dark ? 90 : 160, dark ? 110 : 180, dark ? 110 : 170, 200); 4930 + box(unityX, sliderY, 1, sliderH, true); 4931 + if (masterVolMix > 0.005) { 4932 + const knobX = Math.max(1, Math.min(sliderW - 3, Math.floor(masterVolMix * sliderW))); 4933 + ink(160, 255, 190, 220); 4934 + box(knobX - 1, sliderY, 3, sliderH, true); 4935 + } 4936 + ink(dark ? 90 : 160, dark ? 90 : 160, dark ? 100 : 170); 4937 + write("vol " + Math.round(masterVolMix * 200) + "%", 4938 + { x: 2, y: sliderY + 2, size: 1, font: "font_1" }); 4939 + const xBox = { x: sliderW, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; 4940 + const yBox = { x: sliderW + axisBoxSize + axisGap, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; 4941 + drawAxisToggle(xBox, "x", !!trackpadEffectBindings.volume.x, [100, 220, 150]); 4942 + drawAxisToggle(yBox, "y", !!trackpadEffectBindings.volume.y, [100, 220, 150]); 4943 + fxRows.volume = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox }; 4944 + } 4945 + 4946 + // Drive slider — tanh soft-sat dry/wet (0 = clean, 100% = fully driven). 4947 + // Subtle warmth in the 10-30% range, obvious distortion above 60%. 4948 + { 4949 + const sliderY = settingsY + sliderH * 5; 4950 + const sliderW = w - axisAreaW; 4951 + const hov = hoverY >= sliderY && hoverY < sliderY + sliderH; 4952 + ink(dark ? (hov ? 40 : 25) : (hov ? 220 : 235), 4953 + dark ? (hov ? 40 : 25) : (hov ? 220 : 235), 4954 + dark ? (hov ? 45 : 28) : (hov ? 225 : 238)); 4955 + box(0, sliderY, w, sliderH, true); 4956 + const fillW = Math.floor(driveMix * sliderW); 4957 + if (fillW > 0) { 4958 + ink(220, 90, 70, trackpadFX ? 240 : 180); 4959 + box(0, sliderY, fillW, sliderH, true); 4960 + } 4961 + if (driveMix > 0.005) { 4962 + const knobX = Math.max(1, Math.min(sliderW - 3, Math.floor(driveMix * sliderW))); 4963 + ink(255, 160, 120, 220); 4964 + box(knobX - 1, sliderY, 3, sliderH, true); 4965 + } 4966 + ink(dark ? 90 : 160, dark ? 90 : 160, dark ? 100 : 170); 4967 + write("drive " + Math.round(driveMix * 100) + "%", 4968 + { x: 2, y: sliderY + 2, size: 1, font: "font_1" }); 4969 + const xBox = { x: sliderW, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; 4970 + const yBox = { x: sliderW + axisBoxSize + axisGap, y: sliderY + 1, w: axisBoxSize, h: axisBoxSize }; 4971 + drawAxisToggle(xBox, "x", !!trackpadEffectBindings.drive.x, [220, 90, 70]); 4972 + drawAxisToggle(yBox, "y", !!trackpadEffectBindings.drive.y, [220, 90, 70]); 4973 + fxRows.drive = { y: sliderY, h: sliderH, sliderX: 0, sliderW, xBox, yBox }; 4974 + } 4975 + 4833 4976 globalThis.__fxRows = fxRows; 4834 - const waveRowY = settingsY + sliderH * 4; 4977 + const waveRowY = settingsY + sliderH * 6; 4835 4978 const waveRowH = 14; 4836 4979 4837 4980 // === WAVE TYPE BUTTONS (below sliders, modular GUI) === ··· 5514 5657 if (recording && (Date.now() - recStartTime) / 1000 >= MAX_REC_SECS) { 5515 5658 stopSampleRecording(sound, "max-duration"); 5516 5659 } 5660 + 5661 + // Advance / retreat the waveform-strip view cursor based on spacebar. 5662 + // 1x audio-rate retreat on press → the wave drifts RIGHT at a natural 5663 + // speed. 2x catch-up on release → wave snaps forward noticeably faster 5664 + // than normal drift so the eye can tell the cursor is "coming back". 5665 + const dtSec = 1 / 60; 5666 + if (spaceHeld) { 5667 + waveViewOffsetSec = Math.min(WAVE_VIEW_MAX_OFFSET_SEC, 5668 + waveViewOffsetSec + dtSec); 5669 + } else if (waveViewOffsetSec > 0) { 5670 + waveViewOffsetSec = Math.max(0, waveViewOffsetSec - dtSec * 2); 5671 + } 5517 5672 // Update dark/light mode via global theme (every ~5 seconds) 5518 5673 if (frame % 300 === 0) { 5519 5674 const wasDark = dark; ··· 5613 5768 pitchShift = 0; 5614 5769 lastAppliedPitch = 0; 5615 5770 fxMix = 1; 5771 + masterVolMix = 0.5; // unity (slider 0..1 → audio 0..2) 5772 + driveMix = 0; 5616 5773 trackpadFX = false; 5617 5774 soundAPI?.room?.setMix?.(0); 5618 5775 soundAPI?.glitch?.setMix?.(0); 5619 5776 soundAPI?.fx?.setMix?.(1); 5777 + soundAPI?.volume?.setMix?.(1); // unity 5778 + soundAPI?.drive?.setMix?.(0); // clean 5620 5779 stopAllSounds(soundAPI, systemAPI, 0.02); 5621 5780 } 5622 5781
+130 -13
fedac/native/scripts/flash-mac.sh
··· 34 34 USB_DEV="${1:?usage: $0 /dev/diskN [SRC_DIR]}" 35 35 SRC_DIR="${2:-/tmp/ac-os-pull}" 36 36 37 - # This script needs root for diskutil/sgdisk/dd/newfs_msdos/mount_msdos. 38 - # Re-exec under sudo if invoked as a regular user (sudoers.d/ac-flash-mac 39 - # whitelists this exact path NOPASSWD). 40 - if [ "$(id -u)" != "0" ]; then 41 - exec sudo --preserve-env=PATH "$0" "$@" 42 - fi 43 - 44 37 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 45 38 REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" 39 + REAL_REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" 46 40 NATIVE_DIR="${REPO_ROOT}/native" 47 41 [ -d "${NATIVE_DIR}/boot" ] || NATIVE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" 48 42 43 + # --- step 1: ensure ~/.ac-token is fresh (run ac-login if stale) --- 44 + # Done BEFORE the sudo exec so the OAuth browser dance happens as the 45 + # actual user. If ac-login.mjs isn't findable we skip the refresh and 46 + # leave the downstream bake to fail-soft with a warning. 47 + if [ "$(id -u)" != "0" ]; then 48 + NEEDS_LOGIN=1 49 + if [ -f "${HOME}/.ac-token" ] && command -v node >/dev/null 2>&1; then 50 + NEEDS_LOGIN="$(node -e ' 51 + try { 52 + const t = JSON.parse(require("fs").readFileSync(process.env.HOME+"/.ac-token", "utf8")); 53 + const rawExp = t.expires_at || 0; 54 + const expMs = rawExp > 10_000_000_000 ? rawExp : rawExp * 1000; 55 + // Consider stale if expired or within 60s of expiring 56 + process.stdout.write((!expMs || Date.now() >= expMs - 60000) ? "1" : "0"); 57 + } catch { process.stdout.write("1"); } 58 + ')" 59 + fi 60 + if [ "${NEEDS_LOGIN}" = "1" ]; then 61 + AC_LOGIN="" 62 + for p in "${REAL_REPO_ROOT}/tezos/ac-login.mjs" \ 63 + "${HOME}/aesthetic-computer/tezos/ac-login.mjs"; do 64 + [ -f "${p}" ] && AC_LOGIN="${p}" && break 65 + done 66 + if [ -n "${AC_LOGIN}" ] && command -v node >/dev/null 2>&1; then 67 + echo "[flash-mac] ~/.ac-token is stale — running ac-login to refresh…" 68 + echo "[flash-mac] script: ${AC_LOGIN}" 69 + node "${AC_LOGIN}" || { 70 + echo "[flash-mac] ac-login failed (non-fatal; proceeding without Claude bake)" >&2 71 + } 72 + else 73 + echo "[flash-mac] WARN: ac-login.mjs not found — Claude creds won't be baked" >&2 74 + echo "[flash-mac] searched: ${REAL_REPO_ROOT}/tezos/ac-login.mjs" >&2 75 + fi 76 + fi 77 + fi 78 + 79 + # --- step 2: re-exec under sudo --- 80 + # Needs root for diskutil/sgdisk/dd/newfs_msdos/mount_msdos. sudoers.d/ 81 + # ac-flash-mac whitelists this exact path NOPASSWD. 82 + if [ "$(id -u)" != "0" ]; then 83 + exec sudo --preserve-env=PATH "$0" "$@" 84 + fi 85 + 49 86 KERNEL="${SRC_DIR}/vmlinuz" 50 87 INITRAMFS="${SRC_DIR}/initramfs.cpio.gz" 51 88 SPLASH_EFI="${NATIVE_DIR}/bootloader/splash.efi" ··· 71 108 TOKEN_FILE="${TOKEN_HOME}/.ac-token" 72 109 73 110 USER_HANDLE=""; USER_SUB=""; USER_EMAIL="" 111 + AC_ACCESS_TOKEN=""; AC_TOKEN_EXPIRED=0 74 112 if [ -f "${TOKEN_FILE}" ] && command -v node >/dev/null 2>&1; then 75 113 eval "$(node -e ' 76 114 const t = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); 77 115 let h = t.user?.handle || t.user?.name || ""; 78 116 if (h.startsWith("@")) h = h.slice(1); 117 + const now = Date.now(); 118 + const rawExp = t.expires_at || 0; 119 + const expMs = rawExp > 10_000_000_000 ? rawExp : rawExp * 1000; 120 + const fresh = expMs && now < expMs; 79 121 const out = (k, v) => process.stdout.write(`${k}=${JSON.stringify(v || "")}\n`); 80 - out("USER_HANDLE", h); 81 - out("USER_SUB", t.user?.sub); 82 - out("USER_EMAIL", t.user?.email); 122 + out("USER_HANDLE", h); 123 + out("USER_SUB", t.user?.sub); 124 + out("USER_EMAIL", t.user?.email); 125 + out("AC_ACCESS_TOKEN", fresh ? (t.access_token || "") : ""); 126 + process.stdout.write(`AC_TOKEN_EXPIRED=${fresh ? 0 : 1}\n`); 83 127 ' "${TOKEN_FILE}" 2>/dev/null)" 84 128 [ -n "${USER_HANDLE}" ] && log "Authenticated as @${USER_HANDLE}" 85 129 fi 86 130 [ -z "${USER_HANDLE}${USER_SUB}${USER_EMAIL}" ] && \ 87 131 log "No ~/.ac-token (run \`ac-login\` first to bake credentials in)" 88 132 133 + # --- fetch Claude OAuth token + GitHub PAT from MongoDB --- 134 + # /api/claude-token returns { handle, token, githubPat } from @handles. 135 + # `token` is the year-long Claude Code OAuth bearer (sk-ant-...) — pty.c 136 + # reads /claude-token at spawn time and sets CLAUDE_CODE_OAUTH_TOKEN so 137 + # `claude` launches without interactive login. `githubPat` lets on-device 138 + # git push to the GitHub mirror without SSH. 139 + CLAUDE_TOKEN="" 140 + GITHUB_PAT="" 141 + if [ -n "${AC_ACCESS_TOKEN}" ]; then 142 + log "Fetching Claude token + GitHub PAT from MongoDB…" 143 + CT_RESP="$(curl -fsS -H "Authorization: Bearer ${AC_ACCESS_TOKEN}" \ 144 + "https://aesthetic.computer/api/claude-token" 2>/dev/null || echo "")" 145 + if [ -n "${CT_RESP}" ]; then 146 + eval "$(node -e ' 147 + try { 148 + const r = JSON.parse(process.argv[1]); 149 + const out = (k, v) => process.stdout.write(`${k}=${JSON.stringify(v || "")}\n`); 150 + out("CLAUDE_TOKEN", r.token); 151 + out("GITHUB_PAT", r.githubPat); 152 + } catch {} 153 + ' "${CT_RESP}" 2>/dev/null)" 154 + fi 155 + if [ -n "${CLAUDE_TOKEN}" ]; then 156 + log " claude token: ${#CLAUDE_TOKEN} bytes" 157 + else 158 + log " claude token: none stored in MongoDB (POST to /api/claude-token to save)" 159 + fi 160 + [ -n "${GITHUB_PAT}" ] && log " github pat: ${#GITHUB_PAT} bytes" 161 + elif [ "${AC_TOKEN_EXPIRED}" = "1" ]; then 162 + log " creds fetch: skipped (~/.ac-token expired — run \`ac-login\` to refresh)" 163 + fi 164 + 165 + # --- bake creds into initramfs via concatenated cpio archive --- 166 + # The Linux kernel's unpack_to_rootfs() accepts multiple concatenated 167 + # gzipped cpio archives in the initrd stream (same trick intel-ucode 168 + # uses). We build a tiny supplementary archive containing the baked 169 + # files and append it to the end of initramfs.cpio.gz. 170 + # Files baked (matches ac-os Linux layout): 171 + # /claude-token plain bearer — pty.c reads + sets CLAUDE_CODE_OAUTH_TOKEN 172 + # /claude-state.json init copies to /tmp/.claude.json (skips CC onboarding) 173 + # /github-pat plain bearer — pty.c sets GITHUB_TOKEN 174 + if [ -n "${CLAUDE_TOKEN}${GITHUB_PAT}" ]; then 175 + BAKE_DIR="$(mktemp -d /tmp/ac-bake.XXXXXX)" 176 + BAKED_INITRAMFS="/tmp/ac-initramfs-baked.$$.cpio.gz" 177 + ORIG_INITRD_SIZE="$(stat -f%z "${INITRAMFS}")" 178 + BAKE_LIST="" 179 + if [ -n "${CLAUDE_TOKEN}" ]; then 180 + printf %s "${CLAUDE_TOKEN}" > "${BAKE_DIR}/claude-token" 181 + chmod 600 "${BAKE_DIR}/claude-token" 182 + cat > "${BAKE_DIR}/claude-state.json" <<STATE 183 + {"oauthAccount":{"emailAddress":"${USER_EMAIL}","organizationName":"","accountUuid":""},"hasCompletedOnboarding":true,"installMethod":"manual","numStartups":1,"autoUpdates":false,"autoUpdatesProtectedForNative":true} 184 + STATE 185 + BAKE_LIST="${BAKE_LIST}claude-token 186 + claude-state.json 187 + " 188 + fi 189 + if [ -n "${GITHUB_PAT}" ]; then 190 + printf %s "${GITHUB_PAT}" > "${BAKE_DIR}/github-pat" 191 + chmod 600 "${BAKE_DIR}/github-pat" 192 + BAKE_LIST="${BAKE_LIST}github-pat 193 + " 194 + fi 195 + cp "${INITRAMFS}" "${BAKED_INITRAMFS}" 196 + ( cd "${BAKE_DIR}" && printf '%s' "${BAKE_LIST}" \ 197 + | cpio -o -H newc 2>/dev/null ) \ 198 + | gzip -9 >> "${BAKED_INITRAMFS}" \ 199 + || die "Failed to append creds cpio to initramfs" 200 + rm -rf "${BAKE_DIR}" 201 + INITRAMFS="${BAKED_INITRAMFS}" 202 + NEW_INITRD_SIZE="$(stat -f%z "${INITRAMFS}")" 203 + log " baked initramfs: ${NEW_INITRD_SIZE} bytes (+$(( NEW_INITRD_SIZE - ORIG_INITRD_SIZE )) bytes for creds cpio)" 204 + fi 205 + 89 206 # --- preserve existing wifi_creds.json from target USB before we wipe it --- 90 207 # Linux `ac-os flash` does this via ac_media_merge_wifi_creds: read the 91 208 # previously-flashed USB for user-added networks, then merge with the ··· 100 217 log "Preserving wifi_creds.json from ${mnt}" && break 101 218 fi 102 219 done 103 - trap "rm -f '${PRESERVE_WIFI}' 2>/dev/null" EXIT 220 + trap "rm -f '${PRESERVE_WIFI}' '${BAKED_INITRAMFS:-}' 2>/dev/null" EXIT 104 221 105 222 # --- hardcoded preset networks (kept in sync with media-layout.sh + src/wifi.c) --- 106 223 WIFI_PRESETS_JSON='[ ··· 184 301 # --- mount --- 185 302 M1=$(mktemp -d /tmp/ac-main.XXXXXX) 186 303 M2=$(mktemp -d /tmp/ac-efi.XXXXXX) 187 - trap "umount '${M1}' 2>/dev/null; umount '${M2}' 2>/dev/null; rmdir '${M1}' '${M2}' 2>/dev/null; true" EXIT 304 + 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 188 305 189 306 log "Mounting partitions…" 190 307 mount_msdos "${P1}" "${M1}" ··· 195 312 mkdir -p "${M1}/EFI/BOOT" 196 313 cp "${KERNEL}" "${M1}/EFI/BOOT/BOOTX64.EFI" 197 314 cp "${INITRAMFS}" "${M1}/initramfs.cpio.gz" 198 - printf '{"handle":"%s","piece":"notepat","sub":"%s","email":"%s"}\n' \ 315 + printf '{"handle":"%s","piece":"notepat","sub":"%s","email":"%s","udpMidiBroadcast":true}\n' \ 199 316 "${USER_HANDLE}" "${USER_SUB}" "${USER_EMAIL}" | tee "${M1}/config.json" >/dev/null 200 317 201 318 # Build merged wifi_creds.json (presets + preserved + optional override)
+58
fedac/native/src/audio.c
··· 1434 1434 audio->glitch_mix += (audio->target_glitch_mix - audio->glitch_mix) * 0.00005f; 1435 1435 } 1436 1436 1437 + // Smooth master volume + drive toward target (same 1s time const) 1438 + if (audio->master_volume != audio->target_master_volume) { 1439 + audio->master_volume += (audio->target_master_volume - audio->master_volume) * 0.00005f; 1440 + } 1441 + if (audio->drive_mix != audio->target_drive_mix) { 1442 + audio->drive_mix += (audio->target_drive_mix - audio->drive_mix) * 0.00005f; 1443 + } 1444 + 1437 1445 // Save dry signal before FX chain 1438 1446 double dry_l = mix_l, dry_r = mix_r; 1439 1447 ··· 1572 1580 mix_l *= reduction; 1573 1581 mix_r *= reduction; 1574 1582 } 1583 + } 1584 + 1585 + // User-controlled drive (tanh soft-saturation) BEFORE system 1586 + // volume so the harmonic character is independent of hardware 1587 + // gain. drive_mix is a dry/wet blend: 0 = pure bypass, 1 = fully 1588 + // driven (pre-gain × 6 into tanh, attenuated back to roughly 1589 + // unity peak). At mid settings you get pleasing tube-ish warmth. 1590 + if (audio->drive_mix > 0.001f) { 1591 + float dm = audio->drive_mix; 1592 + float pre_gain = 1.0f + dm * 5.0f; 1593 + double driven_l = tanh(mix_l * pre_gain) * 0.8; 1594 + double driven_r = tanh(mix_r * pre_gain) * 0.8; 1595 + mix_l = mix_l * (1.0 - dm) + driven_l * dm; 1596 + mix_r = mix_r * (1.0 - dm) + driven_r * dm; 1597 + } 1598 + 1599 + // User-controlled master volume (0..2 = 0..200%). Applied after 1600 + // drive so the slider feels like a "louder/quieter" control that 1601 + // doesn't change the tone character the user dialled in. 1602 + { 1603 + float mv = audio->master_volume; 1604 + mix_l *= mv; 1605 + mix_r *= mv; 1575 1606 } 1576 1607 1577 1608 // Apply system volume (software gain). system_volume can go ··· 1797 1828 audio->target_glitch_mix = 0.0f; 1798 1829 audio->fx_mix = 1.0f; // FX chain fully wet by default 1799 1830 audio->target_fx_mix = 1.0f; 1831 + // User master volume starts at 1.0 (unity gain) — the pre-existing 1832 + // system_volume path still provides the hardware mixer control, so 1833 + // this is a per-user soft gain on top. 1834 + audio->master_volume = 1.0f; 1835 + audio->target_master_volume = 1.0f; 1836 + audio->drive_mix = 0.0f; // Clean bypass until user dials drive 1837 + audio->target_drive_mix = 0.0f; 1800 1838 audio->room_buf_l = calloc(ROOM_SIZE, sizeof(float)); 1801 1839 audio->room_buf_r = calloc(ROOM_SIZE, sizeof(float)); 1802 1840 ··· 2977 3015 if (mix < 0.0f) mix = 0.0f; 2978 3016 if (mix > 1.0f) mix = 1.0f; 2979 3017 audio->target_fx_mix = mix; 3018 + } 3019 + 3020 + // User-exposed master gain. Range 0..2 (200%) — above that you're almost 3021 + // certainly just hitting soft_clip and colouring the signal, so clamp 3022 + // before that to avoid giving false "louder" feedback in the UI slider. 3023 + void audio_set_master_volume(ACAudio *audio, float value) { 3024 + if (!audio) return; 3025 + if (value < 0.0f) value = 0.0f; 3026 + if (value > 2.0f) value = 2.0f; 3027 + audio->target_master_volume = value; 3028 + } 3029 + 3030 + // Drive amount 0..1 dry/wet blend. 0 = clean bypass, 1 = fully driven 3031 + // (pre-gain × 6 into tanh, attenuated back). Smoothed per-sample so 3032 + // sliding the fader doesn't audibly zipper. 3033 + void audio_set_drive_mix(ACAudio *audio, float value) { 3034 + if (!audio) return; 3035 + if (value < 0.0f) value = 0.0f; 3036 + if (value > 1.0f) value = 1.0f; 3037 + audio->target_drive_mix = value; 2980 3038 } 2981 3039 2982 3040 // --- Hot-mic capture thread ---
+14
fedac/native/src/audio.h
··· 253 253 float fx_mix; // 0.0 = fully dry, 1.0 = fully wet (smoothed) 254 254 float target_fx_mix; // target (set by JS, smoothed per sample) 255 255 256 + // User-controlled master output gain (applied right before soft_clip). 257 + // Defaults to 1.0; 0.0 silent; >1.0 amplifies (use carefully — soft_clip 258 + // still protects against speaker-blowing peaks). 259 + float master_volume; 260 + float target_master_volume; 261 + 262 + // Drive / tanh soft-saturation (dry/wet blend). 0.0 = clean pass-through, 263 + // 1.0 = fully driven (pre-gain 6× → tanh → attenuation). Adds harmonic 264 + // warmth at low settings and obvious distortion at high settings. 265 + float drive_mix; 266 + float target_drive_mix; 267 + 256 268 // System mixer volume (0-100 percent) 257 269 int system_volume; 258 270 int card_index; // ALSA card number (0 or 1) ··· 392 404 void audio_set_room_mix(ACAudio *audio, float mix); 393 405 void audio_set_glitch_mix(ACAudio *audio, float mix); 394 406 void audio_set_fx_mix(ACAudio *audio, float mix); 407 + void audio_set_master_volume(ACAudio *audio, float value); 408 + void audio_set_drive_mix(ACAudio *audio, float value); 395 409 396 410 // Microphone — hot-mic mode (device stays open, recording toggles buffering) 397 411 int audio_mic_open(ACAudio *audio); // open device + start hot-mic thread
+25 -10
fedac/native/src/graph.c
··· 488 488 g->fb = target ? target : g->screen; 489 489 } 490 490 491 + // Module-level cache so repeat calls with the same text don't re-run 492 + // qrcodegen_encodeText (Reed-Solomon + masking — multi-ms on slow CPUs). 493 + // Notepat calls qr("https://notepat.com", ...) every paint frame; caching 494 + // drops the cost to ~1 memcmp + the draw loop. 495 + static char qr_cache_text[256] = {0}; 496 + static uint8_t qr_cache_buf[qrcodegen_BUFFER_LEN_FOR_VERSION(10)]; 497 + static int qr_cache_size = 0; 498 + 491 499 void graph_qr(ACGraph *g, const char *text, int x, int y, int scale) { 492 500 if (!g || !text || !text[0]) return; 493 501 if (scale < 1) scale = 1; 494 502 495 - uint8_t qr_buf[qrcodegen_BUFFER_LEN_FOR_VERSION(10)]; 496 - uint8_t tmp_buf[qrcodegen_BUFFER_LEN_FOR_VERSION(10)]; 497 - 498 - if (!qrcodegen_encodeText(text, tmp_buf, qr_buf, 499 - qrcodegen_Ecc_LOW, qrcodegen_VERSION_MIN, 10, 500 - qrcodegen_Mask_AUTO, true)) { 501 - return; // encode failed (text too long for version 10) 503 + // Cache hit: skip the encode entirely. 504 + if (qr_cache_size == 0 || 505 + strncmp(qr_cache_text, text, sizeof(qr_cache_text)) != 0) { 506 + uint8_t tmp_buf[qrcodegen_BUFFER_LEN_FOR_VERSION(10)]; 507 + if (!qrcodegen_encodeText(text, tmp_buf, qr_cache_buf, 508 + qrcodegen_Ecc_LOW, qrcodegen_VERSION_MIN, 10, 509 + qrcodegen_Mask_AUTO, true)) { 510 + qr_cache_size = 0; 511 + qr_cache_text[0] = '\0'; 512 + return; 513 + } 514 + qr_cache_size = qrcodegen_getSize(qr_cache_buf); 515 + strncpy(qr_cache_text, text, sizeof(qr_cache_text) - 1); 516 + qr_cache_text[sizeof(qr_cache_text) - 1] = '\0'; 502 517 } 503 518 504 - int size = qrcodegen_getSize(qr_buf); 505 - int margin = 2; // quiet zone 519 + const int size = qr_cache_size; 520 + const int margin = 2; // quiet zone 506 521 507 522 // Draw white background with margin 508 523 int total = (size + margin * 2) * scale; ··· 514 529 graph_ink(g, (ACColor){0, 0, 0, 255}); 515 530 for (int qy = 0; qy < size; qy++) { 516 531 for (int qx = 0; qx < size; qx++) { 517 - if (qrcodegen_getModule(qr_buf, qx, qy)) { 532 + if (qrcodegen_getModule(qr_cache_buf, qx, qy)) { 518 533 graph_box(g, x + (qx + margin) * scale, y + (qy + margin) * scale, 519 534 scale, scale, 1); 520 535 }
+227
fedac/native/src/js-bindings.c
··· 1184 1184 return JS_UNDEFINED; 1185 1185 } 1186 1186 1187 + // sound.volume.setMix(value) — user-controlled master output gain (0..2) 1188 + static JSValue js_set_master_volume(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { 1189 + (void)this_val; 1190 + if (argc < 1 || !current_rt->audio) return JS_UNDEFINED; 1191 + double v; 1192 + JS_ToFloat64(ctx, &v, argv[0]); 1193 + audio_set_master_volume(current_rt->audio, (float)v); 1194 + return JS_UNDEFINED; 1195 + } 1196 + 1197 + // sound.drive.setMix(value) — tanh soft-clip dry/wet blend (0..1) 1198 + static JSValue js_set_drive_mix(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { 1199 + (void)this_val; 1200 + if (argc < 1 || !current_rt->audio) return JS_UNDEFINED; 1201 + double v; 1202 + JS_ToFloat64(ctx, &v, argv[0]); 1203 + audio_set_drive_mix(current_rt->audio, (float)v); 1204 + return JS_UNDEFINED; 1205 + } 1206 + 1187 1207 // sound.microphone.open() — open device + start hot-mic thread 1188 1208 static JSValue js_mic_open(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { 1189 1209 (void)this_val; (void)argc; (void)argv; ··· 1566 1586 audio_replay_load_data(audio, data, len, rate); 1567 1587 JS_FreeValue(ctx, ab); 1568 1588 return JS_TRUE; 1589 + } 1590 + 1591 + // sound.speaker.drawStrip(x, y, w, h, seconds, needleFrac, viewOffsetSec) 1592 + // Renders a scrolling waveform strip in one C call. The needle stays at 1593 + // `needleFrac` (0.0..1.0) of the strip width and represents the current 1594 + // PLAYBACK CURSOR — what's being heard right now. The wave never jumps: 1595 + // it continuously drifts based on how the cursor moves through the buffer 1596 + // across frames. 1597 + // 1598 + // viewOffsetSec controls the cursor's position relative to "now": 1599 + // 1600 + // 0.0 → cursor sits at the latest captured sample. Past audio 1601 + // extends to the LEFT of the needle. Right of needle is empty 1602 + // (no future). As real time advances, freshly-captured samples 1603 + // appear at the needle and the wave drifts LEFT — the natural 1604 + // scroll for live capture. 1605 + // 1606 + // > 0 → cursor is OFFSET-seconds in the past. Left of needle still 1607 + // shows the LEFT-PAST (older than cursor). Right of needle 1608 + // shows the RIGHT-PAST (samples captured AFTER the cursor 1609 + // position, up to the live edge). As JS grows the offset (e.g. 1610 + // while spacebar is held), the cursor retreats further into 1611 + // the past and the wave appears to drift RIGHT — exactly 1612 + // matching a backwards-replay scrub. 1613 + // 1614 + // shrinking offset on release → cursor catches back up to "now" and 1615 + // the wave drifts LEFT (forward) until the right side empties 1616 + // and we're back in live capture mode. 1617 + // 1618 + // Per-pixel peak math is C-native (no JS loop overhead); the audio ring 1619 + // slice is copied under audio->lock then drawn unlocked. 1620 + static JSValue js_speaker_draw_strip(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { 1621 + (void)this_val; 1622 + if (!current_rt || argc < 4) return JS_UNDEFINED; 1623 + ACAudio *audio = current_rt->audio; 1624 + ACGraph *g = current_rt->graph; 1625 + if (!audio || !audio->output_history_buf || audio->output_history_size <= 0 || !g) { 1626 + return JS_UNDEFINED; 1627 + } 1628 + 1629 + int x = 0, y = 0, w = 0, h = 0; 1630 + JS_ToInt32(ctx, &x, argv[0]); 1631 + JS_ToInt32(ctx, &y, argv[1]); 1632 + JS_ToInt32(ctx, &w, argv[2]); 1633 + JS_ToInt32(ctx, &h, argv[3]); 1634 + double seconds = 4.0; 1635 + if (argc >= 5 && JS_IsNumber(argv[4])) JS_ToFloat64(ctx, &seconds, argv[4]); 1636 + double needle_frac = 0.5; 1637 + if (argc >= 6 && JS_IsNumber(argv[5])) JS_ToFloat64(ctx, &needle_frac, argv[5]); 1638 + double view_offset_sec = 0.0; 1639 + if (argc >= 7 && JS_IsNumber(argv[6])) JS_ToFloat64(ctx, &view_offset_sec, argv[6]); 1640 + if (view_offset_sec < 0.0) view_offset_sec = 0.0; 1641 + 1642 + if (w <= 2 || h <= 2 || seconds <= 0.0) return JS_UNDEFINED; 1643 + if (needle_frac < 0.0) needle_frac = 0.0; 1644 + if (needle_frac > 1.0) needle_frac = 1.0; 1645 + 1646 + int needle_off = (int)((double)w * needle_frac + 0.5); 1647 + if (needle_off < 0) needle_off = 0; 1648 + if (needle_off >= w) needle_off = w - 1; 1649 + int needle_x = x + needle_off; 1650 + 1651 + // Compute the time window the strip needs to span: 1652 + // [cursor - left_seconds, cursor + right_seconds] 1653 + // where cursor = now - view_offset_sec, and the LEFT/RIGHT widths in 1654 + // seconds are proportional to the LEFT/RIGHT pixel widths so each 1655 + // pixel covers the same temporal slice end-to-end. 1656 + int left_w = needle_off; 1657 + int right_w = w - needle_off; 1658 + double total_w = (double)w; 1659 + double left_seconds = seconds * ((double)left_w / total_w); 1660 + double right_seconds_max = seconds * ((double)right_w / total_w); 1661 + // Right side only fills to view_offset_sec — no future audio exists. 1662 + double right_seconds = view_offset_sec < right_seconds_max ? view_offset_sec : right_seconds_max; 1663 + 1664 + pthread_mutex_lock(&audio->lock); 1665 + unsigned int rate = audio->output_history_rate ? audio->output_history_rate 1666 + : AUDIO_OUTPUT_HISTORY_RATE; 1667 + int hist_size = audio->output_history_size; 1668 + uint64_t write_pos = audio->output_history_write_pos; 1669 + int available = write_pos < (uint64_t)hist_size ? (int)write_pos : hist_size; 1670 + 1671 + // cursor_pos: ring index of "where the playhead sits" in samples. 1672 + // = write_pos - offset_samples 1673 + uint64_t offset_samples = (uint64_t)(view_offset_sec * (double)rate + 0.5); 1674 + if (offset_samples > write_pos) offset_samples = write_pos; 1675 + uint64_t cursor_pos = write_pos - offset_samples; 1676 + 1677 + // Snapshot the LEFT-past slice [cursor - left_seconds, cursor] 1678 + int left_samples_want = (int)(left_seconds * (double)rate + 0.5); 1679 + if (left_samples_want < 1) left_samples_want = 1; 1680 + if ((uint64_t)left_samples_want > cursor_pos) left_samples_want = (int)cursor_pos; 1681 + if (left_samples_want > available) left_samples_want = available; 1682 + 1683 + // Snapshot the RIGHT-past slice [cursor, cursor + right_seconds] 1684 + int right_samples_want = (int)(right_seconds * (double)rate + 0.5); 1685 + if (right_samples_want < 0) right_samples_want = 0; 1686 + if ((uint64_t)right_samples_want > write_pos - cursor_pos) { 1687 + right_samples_want = (int)(write_pos - cursor_pos); 1688 + } 1689 + 1690 + float *left_copy = NULL; 1691 + float *right_copy = NULL; 1692 + if (left_samples_want > 0) { 1693 + left_copy = (float *)malloc((size_t)left_samples_want * sizeof(float)); 1694 + if (left_copy) { 1695 + uint64_t start = cursor_pos - (uint64_t)left_samples_want; 1696 + for (int i = 0; i < left_samples_want; i++) { 1697 + left_copy[i] = audio->output_history_buf[(start + (uint64_t)i) % (uint64_t)hist_size]; 1698 + } 1699 + } 1700 + } 1701 + if (right_samples_want > 0) { 1702 + right_copy = (float *)malloc((size_t)right_samples_want * sizeof(float)); 1703 + if (right_copy) { 1704 + uint64_t start = cursor_pos; 1705 + for (int i = 0; i < right_samples_want; i++) { 1706 + right_copy[i] = audio->output_history_buf[(start + (uint64_t)i) % (uint64_t)hist_size]; 1707 + } 1708 + } 1709 + } 1710 + pthread_mutex_unlock(&audio->lock); 1711 + 1712 + int midY = y + h / 2; 1713 + int amp = (int)((double)h * 0.45); 1714 + if (amp < 1) amp = 1; 1715 + ACColor saved = g->ink; 1716 + 1717 + // Background + zero-line 1718 + graph_ink(g, (ACColor){20, 15, 30, 160}); 1719 + graph_box(g, x, y, w, h, 1); 1720 + graph_ink(g, (ACColor){80, 80, 90, 120}); 1721 + graph_line(g, x, midY, x + w - 1, midY); 1722 + 1723 + // Inner draw helper: render `len` samples across `pixel_w` pixels 1724 + // starting at draw_x, with column 0 = oldest sample. 1725 + #define DRAW_SLICE(buf, len, pixel_w, draw_x_off) do { \ 1726 + if ((buf) && (len) > 0 && (pixel_w) > 0) { \ 1727 + double spc = (double)(len) / (double)(pixel_w); \ 1728 + for (int col = 0; col < (pixel_w); col++) { \ 1729 + int i0 = (int)((double)col * spc); \ 1730 + int i1 = (int)((double)(col + 1) * spc); \ 1731 + if (i1 > (len)) i1 = (len); \ 1732 + if (i0 < 0) i0 = 0; \ 1733 + if (i1 <= i0) continue; \ 1734 + float peak = 0.0f; \ 1735 + for (int i = i0; i < i1; i++) { \ 1736 + float a = (buf)[i]; \ 1737 + if (a < 0) a = -a; \ 1738 + if (a > peak) peak = a; \ 1739 + } \ 1740 + if (peak > 1.0f) peak = 1.0f; \ 1741 + int bar_h = (int)(peak * (float)amp + 0.5f); \ 1742 + if (bar_h < 1) bar_h = 1; \ 1743 + int r = 120 + (int)(peak * 140.0f + 0.5f); if (r > 255) r = 255; \ 1744 + int gc = 120 + (int)(peak * 80.0f + 0.5f); if (gc > 255) gc = 255; \ 1745 + int b_ = 90 + (int)((1.0f - peak) * 120.0f + 0.5f); if (b_ > 255) b_ = 255; \ 1746 + graph_ink(g, (ACColor){(uint8_t)r, (uint8_t)gc, (uint8_t)b_, 220}); \ 1747 + int dx = x + (draw_x_off) + col; \ 1748 + graph_line(g, dx, midY - bar_h, dx, midY + bar_h); \ 1749 + } \ 1750 + } \ 1751 + } while (0) 1752 + 1753 + // LEFT half: samples cover full left_w pixels (oldest at column 0). 1754 + DRAW_SLICE(left_copy, left_samples_want, left_w, 0); 1755 + 1756 + // RIGHT half: samples cover only the portion proportional to view_offset. 1757 + // If offset is small, the right half is mostly empty (background shows 1758 + // through). If offset reaches max, right half is fully drawn. 1759 + int right_pixels_filled = right_samples_want > 0 1760 + ? (int)((double)right_samples_want / (double)rate / right_seconds_max * (double)right_w + 0.5) 1761 + : 0; 1762 + if (right_pixels_filled > right_w) right_pixels_filled = right_w; 1763 + DRAW_SLICE(right_copy, right_samples_want, right_pixels_filled, needle_off); 1764 + 1765 + #undef DRAW_SLICE 1766 + 1767 + if (left_copy) free(left_copy); 1768 + if (right_copy) free(right_copy); 1769 + 1770 + // Playhead needle — draw last so it sits on top of the bars. Color 1771 + // tints toward orange when the cursor is offset (replay mode) so it's 1772 + // visually distinct from the live red needle. 1773 + if (view_offset_sec > 0.001) { 1774 + graph_ink(g, (ACColor){255, 160, 60, 230}); 1775 + } else { 1776 + graph_ink(g, (ACColor){240, 80, 80, 220}); 1777 + } 1778 + graph_line(g, needle_x, y, needle_x, y + h - 1); 1779 + 1780 + g->ink = saved; 1781 + return JS_UNDEFINED; 1569 1782 } 1570 1783 1571 1784 // sound.speaker.getRecentBuffer(seconds) -> { data: Float32Array, rate: number } ··· 2626 2839 JS_SetPropertyStr(ctx, speaker, "poll", JS_NewCFunction(ctx, js_noop, "poll", 0)); 2627 2840 JS_SetPropertyStr(ctx, speaker, "getRecentBuffer", 2628 2841 JS_NewCFunction(ctx, js_speaker_get_recent_buffer, "getRecentBuffer", 1)); 2842 + JS_SetPropertyStr(ctx, speaker, "drawStrip", 2843 + JS_NewCFunction(ctx, js_speaker_draw_strip, "drawStrip", 7)); 2629 2844 JS_SetPropertyStr(ctx, speaker, "sampleRate", 2630 2845 JS_NewInt32(ctx, rt->audio ? (int)rt->audio->actual_rate : AUDIO_SAMPLE_RATE)); 2631 2846 ··· 2697 2912 JS_SetPropertyStr(ctx, fx, "setMix", JS_NewCFunction(ctx, js_set_fx_mix, "setMix", 1)); 2698 2913 JS_SetPropertyStr(ctx, fx, "mix", JS_NewFloat64(ctx, rt->audio ? rt->audio->fx_mix : 1.0)); 2699 2914 JS_SetPropertyStr(ctx, sound, "fx", fx); 2915 + 2916 + // volume (user master output gain) 2917 + JSValue volume = JS_NewObject(ctx); 2918 + JS_SetPropertyStr(ctx, volume, "setMix", JS_NewCFunction(ctx, js_set_master_volume, "setMix", 1)); 2919 + JS_SetPropertyStr(ctx, volume, "mix", JS_NewFloat64(ctx, rt->audio ? rt->audio->master_volume : 1.0)); 2920 + JS_SetPropertyStr(ctx, sound, "volume", volume); 2921 + 2922 + // drive (tanh soft-clip saturation) 2923 + JSValue drive = JS_NewObject(ctx); 2924 + JS_SetPropertyStr(ctx, drive, "setMix", JS_NewCFunction(ctx, js_set_drive_mix, "setMix", 1)); 2925 + JS_SetPropertyStr(ctx, drive, "mix", JS_NewFloat64(ctx, rt->audio ? rt->audio->drive_mix : 0.0)); 2926 + JS_SetPropertyStr(ctx, sound, "drive", drive); 2700 2927 2701 2928 // microphone 2702 2929 JSValue mic = JS_NewObject(ctx);
+3 -3
gigs/are-na-annual-vol-8/README.md
··· 6 6 - **Submission form:** https://aredotna.notion.site/3178a0f816d9815abdf3cb1624bb9e88 7 7 - **Deadline:** Monday, April 20, 2026 — 11:59pm EST 8 8 - **Honorarium:** $200 (published pieces, book releases December 2026) 9 - - **Submitted channel:** [the score that teaches itself](https://www.are.na/aesthetic-computer/the-score-that-teaches-itself) — 68 blocks 9 + - **Submitted channel:** [Self-Teaching Scores](https://www.are.na/aesthetic-computer/self-teaching-scores) — 68 blocks 10 10 11 11 ## Pitch (tightened, ~170 words) 12 12 13 - > **Channel:** *The Score That Teaches Itself* — whistlegraphs alongside Cardew's *Treatise*, Cage's *Fontana Mix*, shape-note hymnals, Fluxus event scores, skateboard lines. 13 + > **Channel:** *Self-Teaching Scores* — whistlegraphs alongside Cardew's *Treatise*, Cage's *Fontana Mix*, shape-note hymnals, Fluxus event scores, skateboard lines. 14 14 > 15 15 > 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. 16 16 > ··· 51 51 52 52 Two 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). 53 53 54 - - [seed-channel.mjs](seed-channel.mjs) — posts 68 blocks to `the-score-that-teaches-itself` in reverse reading order (so whistlegraph lands on top). 54 + - [seed-channel.mjs](seed-channel.mjs) — posts 68 blocks to `self-teaching-scores` in reverse reading order (so whistlegraph lands on top). 55 55 - [set-descriptions.mjs](set-descriptions.mjs) — walks the channel and PUTs a per-block description from the lookup map. 56 56 57 57 ```sh
+2 -2
gigs/are-na-annual-vol-8/channel-blocks.md
··· 1 - # Channel — the score that teaches itself 1 + # Channel — Self-Teaching Scores 2 2 3 - 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). 3 + 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). 4 4 5 5 ## §10 — Whistlegraph (top of channel) 6 6
+2 -2
gigs/are-na-annual-vol-8/seed-channel.mjs
··· 1 1 #!/usr/bin/env node 2 - // Seed blocks into the Are.na channel "the-score-that-teaches-itself". 2 + // Seed blocks into the Are.na channel "self-teaching-scores". 3 3 // Blocks are added in reading-bottom → reading-top order so whistlegraph lands 4 4 // first on the channel page. 5 5 // 6 6 // Usage: ARENA_TOKEN=... node seed-channel.mjs 7 7 8 8 const TOKEN = process.env.ARENA_TOKEN; 9 - const SLUG = "the-score-that-teaches-itself"; 9 + const SLUG = "self-teaching-scores"; 10 10 if (!TOKEN) { console.error("ARENA_TOKEN missing"); process.exit(1); } 11 11 12 12 const blocks = [
+1 -1
gigs/are-na-annual-vol-8/set-descriptions.mjs
··· 5 5 // Usage: ARENA_TOKEN=... node set-descriptions.mjs 6 6 7 7 const TOKEN = process.env.ARENA_TOKEN; 8 - const SLUG = "the-score-that-teaches-itself"; 8 + const SLUG = "self-teaching-scores"; 9 9 if (!TOKEN) { console.error("ARENA_TOKEN missing"); process.exit(1); } 10 10 11 11 const bySource = {
+118
gigs/are-na-annual-vol-8/source-swaps.md
··· 1 + # Source swaps — remove wikipedia, add primary sources 2 + 3 + current channel: 68 blocks · **52 are wikipedia** · 3 go to the wrong page entirely 4 + 5 + ## critical fixes (wikipedia redirected to unrelated page) 6 + 7 + | # | current (broken) | swap to | 8 + |---|---|---| 9 + | 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 | 10 + | 33 | `Water_Yam` → goes to "Dioscorea alata" (yam tuber!) | **Monoskop — George Brecht, Water Yam**: https://monoskop.org/Water_Yam — scans + bibliography | 11 + | 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 | 12 + | 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 | 13 + 14 + ## graphic-score canon — swap to ubuweb, monoskop, foundations 15 + 16 + | # | wiki | primary | 17 + |---|---|---| 18 + | 38 | Treatise (Cardew) | **Monoskop — Cornelius Cardew**: https://monoskop.org/Cornelius_Cardew | 19 + | 39 | Fontana Mix | **UbuWeb — Cage Fontana Mix**: https://www.ubu.com/sound/cage.html | 20 + | 40 | Aria (Cage) | **UbuWeb — Cage Aria**: https://www.ubu.com/film/cage_aria.html | 21 + | 41 | Concert for Piano and Orchestra (Cage) | **Edition Peters — Cage Concert**: https://www.edition-peters.com/product/concert-for-piano-and-orchestra/ep6705 | 22 + | 42 | Variations I (Cage) | **UbuWeb — Cage Variations**: https://www.ubu.com/historical/cage/ | 23 + | 43 | December 1952 (Earle Brown) | **Earle Brown Music Foundation**: https://earle-brown.org | 24 + | 44 | Morton Feldman | **CNVill Feldman archive**: https://cnvill.net/mfhome.htm | 25 + | 45 | Christian Wolff | **CNVill Wolff archive**: https://cnvill.net/mfwolff.htm | 26 + | 46 | Artikulation (Ligeti) | **UbuWeb — Wehinger listening score video**: https://www.ubu.com/film/wehinger_artikulation.html | 27 + | 47 | Metastaseis (Xenakis) | **Iannis Xenakis site / CIX**: https://www.iannis-xenakis.org | 28 + | 48 | Sonic Meditations (Oliveros) | **Pauline Oliveros Foundation / Deep Listening**: https://deeplistening.rpi.edu | 29 + | 49 | I Am Sitting in a Room | **UbuWeb — Lucier**: https://www.ubu.com/sound/lucier.html | 30 + | 50 | In C (Riley) | **Terry Riley**: https://terryriley.net | 31 + | 51 | Composition 1960 (La Monte Young) | **Mela Foundation**: https://www.melafoundation.org | 32 + | 53 | Notations 21 | **Mark Batty / Theresa Sauer**: https://www.notations21.com (or author site) | 33 + 34 + ## fluxus / event scores 35 + 36 + | # | wiki | primary | 37 + |---|---|---| 38 + | 32 | Grapefruit (Yoko Ono) | **imagine-peace.com / Ono Grapefruit scans on Monoskop**: https://monoskop.org/images/c/c7/Ono_Yoko_Grapefruit_1971.pdf | 39 + | 34 | Dick Higgins | **Something Else Press archive / Estate of Dick Higgins**: http://www.dickhiggins.org | 40 + | 35 | An Anthology of Chance Operations | **Monoskop**: https://monoskop.org/An_Anthology_of_Chance_Operations | 41 + | 36 | Alison Knowles | **aknowles.com** (primary): https://www.aknowles.com | 42 + 43 + ## vernacular / folk notation 44 + 45 + | # | wiki | primary | 46 + |---|---|---| 47 + | 24 | Shape note | **Fasola.org — Sacred Harp Musical Heritage**: https://fasola.org | 48 + | 25 | Sacred Harp | **Sacred Harp Publishing Co.**: https://originalsacredharp.com | 49 + | 26 | Tablature | **Lute Society of America**: https://lutesocietyofamerica.org (or guitar tablature primary source?) | 50 + | 27 | Neume | **Gregobase — Gregorian chant notation**: https://gregobase.selapa.net | 51 + | 28 | Jianpu | consider dropping, or: **Zhu Zaiyu / historical primer**; no strong primary online | 52 + | 29 | Gongche notation | consider dropping, or a Kunqu / Peking opera archive link | 53 + | 30 | Sargam | **ITC Sangeet Research Academy**: https://www.itcsra.org | 54 + 55 + ## body / movement notation 56 + 57 + | # | wiki | primary | 58 + |---|---|---| 59 + | 14 | Labanotation | **Dance Notation Bureau**: https://dancenotation.org | 60 + | 15 | Eshkol–Wachman | **Noa Eshkol Foundation**: https://www.noaeshkol.org | 61 + | 16 | Benesh | **Royal Academy of Dance — Benesh**: https://www.royalacademyofdance.org/benesh | 62 + | 17 | Kata | **JKA — Japan Karate Association / kata catalog**: https://jka.or.jp | 63 + | 18 | American football plays | **Walsh's West Coast Offense playbook scans / NFL Coaches' resources** — or drop for a Bill Walsh interview | 64 + 65 + ## sport / line 66 + 67 + | # | wiki | primary | 68 + |---|---|---| 69 + | 19 | Skateboarding | **Thrasher Mag**: https://www.thrashermagazine.com | 70 + | 20 | Dogtown and Z-Boys | **Stacy Peralta / Z-Boys documentary**: https://www.imdb.com/title/tt0275309/ or https://www.dogtownskateboards.com | 71 + | 21 | Surf break | **Surfline**: https://www.surfline.com | 72 + | 22 | Yardage book | **StrackaLine — modern yardage-book publisher**: https://strackaline.com | 73 + | 23 | Parkour | **ADAPT / parkour UK governing body**: https://parkour.uk | 74 + 75 + ## instructional / craft 76 + 77 + | # | wiki | primary | 78 + |---|---|---| 79 + | 6 | Knitting abbreviations | **Craft Yarn Council standards**: https://www.craftyarncouncil.com/standards/knit-abbreviations | 80 + | 7 | Crease pattern | **Erik Demaine — origami and folding**: https://erikdemaine.org/origami | 81 + | 9 | Sewing pattern | **McCall's / Vogue pattern archive**: https://mccall.com | 82 + | 10 | IKEA | **IKEA assembly instructions library**: https://www.ikea.com/us/en/customer-service/services/assembly | 83 + | 11 | Lego | **LEGO building instructions archive**: https://www.lego.com/en-us/service/buildinginstructions | 84 + | 12 | Julia Child | **WGBH — The French Chef / American Archive of Public Broadcasting**: https://americanarchive.org/catalog?q=julia+child | 85 + | 13 | Japanese tea ceremony | **Urasenke**: https://www.urasenke.or.jp | 86 + 87 + ## viral / social kin 88 + 89 + | # | current | primary | 90 + |---|---|---| 91 + | 2 | Harlem Shake (meme) | **Filthy Frank original video**: https://www.youtube.com/watch?v=384IUU43bfQ | 92 + | 3 | Know Your Meme — Squidward (404!) | **Squidward tutorial original video** (YouTube search needed) | 93 + | 4 | Pictogram | **Otl Aicher — Munich '72 pictogram archive**: https://www.designreviewed.com/otl-aicher-munich-1972 | 94 + | 5 | ISOTYPE | **Otto Neurath / Gerd Arntz Web Archive**: https://www.gerdarntz.org/isotype | 95 + 96 + ## additional blocks to *add* (from whistlegraph.tex citations) 97 + 98 + these are specific, real, less cliché: 99 + 100 + - **Goodiepal — El Camino del Hardcore** (ALKU 83, 2012) — https://alku.org/alku-83 or discogs 101 + - **Jacob Ciocci — "The Butterfly Effect / Rules Set You Free"** (Whistlegraph Zine, 2023) — zine catalog page if exists 102 + - **Asher Penn — Sex Magazine**: https://sexmagazine.us 103 + - **Dirt Magazine — "What is a Whistlegraph?"** (2023): https://dirt.fyi/article/2023/09/whistlegraph 104 + - **Rhizome — First Look: The Longest Whistlegraph Ever**: https://rhizome.org/editorial/2022/sep/13/first-look-the-longest-whistlegraph-ever-so-far 105 + - **Feral File — Ten Whistlegraphs exhibition page**: https://feralfile.com/exhibitions/ten-whistlegraphs-thv 106 + - **Schloss-Post — Manifesto for Radical Digital Painting** (2017): https://schloss-post.com/manifesto-radical-digital-painting 107 + - **Creative Independent — "Drawing is the Best Videogame"** (2019): https://thecreativeindependent.com/weekends/drawing-is-the-best-videogame-by-jeffrey-alan-scudder 108 + - **Paper Rad** (Jacob Ciocci): http://www.paperrad.org 109 + 110 + ## execution plan 111 + 112 + 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 113 + 2. keep positions stable — post in reverse reading order so nothing rearranges 114 + 3. text blocks (§9 framing, §6 Ono quote) are untouched — they're already primary 115 + 116 + ## what the `nearby` validator can't tell us without a token 117 + 118 + 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
··· 1 + #!/usr/bin/env node 2 + // Validate the blocks in "self-teaching-scores": 3 + // - flag every Wikipedia link (we want primary sources) 4 + // - for each block, search are.na for existing uses of the same URL 5 + // and of the topic name — so we can see what other people 6 + // are connecting to for the same idea (usually the real source) 7 + // 8 + // Usage: 9 + // ARENA_TOKEN=... node validate-sources.mjs # all blocks 10 + // ARENA_TOKEN=... node validate-sources.mjs --wiki # wiki only 11 + // ARENA_TOKEN=... node validate-sources.mjs --json > report.json 12 + // 13 + // ARENA_TOKEN is optional but greatly raises rate limits. 14 + 15 + const SLUG = "self-teaching-scores"; 16 + const TOKEN = process.env.ARENA_TOKEN; 17 + const API = "https://api.are.na/v2"; 18 + 19 + const args = new Set(process.argv.slice(2)); 20 + const WIKI_ONLY = args.has("--wiki"); 21 + const JSON_OUT = args.has("--json"); 22 + 23 + const headers = TOKEN 24 + ? { Authorization: `Bearer ${TOKEN}` } 25 + : {}; 26 + 27 + async function j(url) { 28 + const r = await fetch(url, { headers }); 29 + if (!r.ok) throw new Error(`${r.status} ${r.statusText} — ${url}`); 30 + return r.json(); 31 + } 32 + 33 + function isWiki(url) { 34 + return url && /wikipedia\.org/i.test(url); 35 + } 36 + 37 + function hostOf(url) { 38 + try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return ""; } 39 + } 40 + 41 + // Pull a short topic phrase out of a title like "Treatise (Cardew) - Wikipedia" 42 + function topicOf(title) { 43 + if (!title) return ""; 44 + return title 45 + .replace(/\s*[-–—|]\s*Wikipedia.*$/i, "") 46 + .replace(/^\s+|\s+$/g, ""); 47 + } 48 + 49 + // Search are.na for any blocks whose source matches the URL (or title) 50 + // Returns an array of { url, host, count, sample: [{channelSlug, user}] } 51 + async function searchSimilar(block) { 52 + const out = { exact: [], nearby: [] }; 53 + const url = block.source?.url; 54 + const topic = topicOf(block.title) || block.generated_title || ""; 55 + // 1) exact URL — search /search/blocks for the URL itself 56 + if (url) { 57 + try { 58 + const q = encodeURIComponent(url); 59 + const r = await j(`${API}/search/blocks?q=${q}&per=10`); 60 + for (const b of r.blocks || []) { 61 + if (b.source?.url === url) { 62 + out.exact.push({ 63 + id: b.id, 64 + title: b.title || b.generated_title, 65 + url: b.source.url, 66 + connections: b.connections_count || 0, 67 + }); 68 + } 69 + } 70 + } catch (e) { out.err = e.message; } 71 + } 72 + // 2) topic phrase — find the most-connected non-wiki blocks for the same idea 73 + if (topic) { 74 + try { 75 + const q = encodeURIComponent(topic); 76 + const r = await j(`${API}/search/blocks?q=${q}&per=20`); 77 + const seen = new Set(); 78 + for (const b of r.blocks || []) { 79 + const bu = b.source?.url; 80 + if (!bu) continue; 81 + if (seen.has(bu)) continue; 82 + seen.add(bu); 83 + if (bu === url) continue; 84 + if (isWiki(bu)) continue; 85 + out.nearby.push({ 86 + url: bu, 87 + host: hostOf(bu), 88 + title: b.title || b.generated_title, 89 + connections: b.connections_count || 0, 90 + }); 91 + } 92 + out.nearby.sort((a, b) => b.connections - a.connections); 93 + out.nearby = out.nearby.slice(0, 5); 94 + } catch (e) { out.err = (out.err || "") + "; " + e.message; } 95 + } 96 + return out; 97 + } 98 + 99 + function fmtReport(rows) { 100 + const lines = []; 101 + for (const row of rows) { 102 + const flag = row.isWiki ? "🟡 WIKI" : "⚪ primary"; 103 + const url = row.url || "(text)"; 104 + const host = hostOf(url) || "—"; 105 + lines.push(`\n#${row.position} ${flag} [${host}]`); 106 + lines.push(` title : ${row.title || row.content?.slice(0, 80) || "(empty)"}`); 107 + lines.push(` url : ${url}`); 108 + lines.push(` desc : ${(row.description || "").slice(0, 100)}`); 109 + if (row.sim) { 110 + if (row.sim.exact?.length) { 111 + 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("/")}`); 112 + } else if (url) { 113 + lines.push(` ↳ not on are.na (exact URL) — fresh`); 114 + } 115 + if (row.sim.nearby?.length) { 116 + lines.push(` ↳ alternate sources people connect for this topic:`); 117 + for (const n of row.sim.nearby) { 118 + lines.push(` • [${n.host}] ${n.connections}× conn — ${n.url}`); 119 + } 120 + } 121 + } 122 + } 123 + return lines.join("\n"); 124 + } 125 + 126 + async function main() { 127 + console.error(`fetching /channels/${SLUG}/contents …`); 128 + const ch = await j(`${API}/channels/${SLUG}/contents?per=100&direction=desc`); 129 + const blocks = ch.contents || []; 130 + console.error(`got ${blocks.length} blocks${TOKEN ? "" : " (unauthed, rate limits apply)"}\n`); 131 + 132 + const rows = []; 133 + for (const b of blocks) { 134 + const url = b.source?.url; 135 + const row = { 136 + position: b.position, 137 + class: b.class, 138 + title: b.title, 139 + content: b.content, 140 + description: b.description, 141 + url, 142 + isWiki: isWiki(url), 143 + }; 144 + if (WIKI_ONLY && !row.isWiki) { rows.push(row); continue; } 145 + if (b.class === "Link") { 146 + try { 147 + row.sim = await searchSimilar(b); 148 + process.stderr.write("."); 149 + } catch (e) { 150 + row.simErr = e.message; 151 + process.stderr.write("x"); 152 + } 153 + await new Promise(r => setTimeout(r, 400)); // gentle rate limit 154 + } 155 + rows.push(row); 156 + } 157 + console.error("\n"); 158 + 159 + if (JSON_OUT) { 160 + console.log(JSON.stringify({ channel: SLUG, at: new Date().toISOString(), rows }, null, 2)); 161 + } else { 162 + console.log(fmtReport(rows)); 163 + const wikiCount = rows.filter(r => r.isWiki).length; 164 + console.log(`\n\nsummary: ${rows.length} blocks · ${wikiCount} wikipedia link(s) flagged for swap`); 165 + } 166 + } 167 + 168 + main().catch(e => { console.error("error:", e.message); process.exit(1); });
+101
lith/mirror/README.md
··· 1 + # lith mirror — knot ↔ GitHub bidirectional sync 2 + 3 + Keeps `main` in lockstep between the two remotes for 4 + `aesthetic.computer/core`: 5 + 6 + - **knot** (`knot.aesthetic.computer:aesthetic.computer/core`, Tangled) — 7 + what `lith` deploy pulls from. 8 + - **GitHub** (`whistlegraph/aesthetic-computer`) — what `session-server` 9 + deploy pulls from, plus Claude/tools usage. 10 + 11 + Runs as a systemd timer on `lith.aesthetic.computer`, every 60 seconds. 12 + Idempotent: exits 0 when the tips match, pushes the ahead side to the 13 + behind side otherwise, and exits 2 with a warning when the tips truly 14 + diverged (requires manual merge). 15 + 16 + ## Files in this directory 17 + 18 + - [`mirror.sh`](./mirror.sh) — the bidirectional sync script. 19 + - [`ac-mirror.service`](./ac-mirror.service) — systemd oneshot unit. 20 + - [`ac-mirror.timer`](./ac-mirror.timer) — every-60s trigger. 21 + 22 + ## First-time setup 23 + 24 + On the lith host: 25 + 26 + ```sh 27 + # 1. Bare clone (fetched over anon HTTPS; push goes via SSH keys below). 28 + mkdir -p /opt/ac-mirror 29 + git clone --bare https://knot.aesthetic.computer/aesthetic.computer/core \ 30 + /opt/ac-mirror/core 31 + cd /opt/ac-mirror/core 32 + git remote rename origin knot 33 + git remote set-url --push knot git@knot.aesthetic.computer:aesthetic.computer/core 34 + git remote add github https://github.com/whistlegraph/aesthetic-computer.git 35 + git remote set-url --push github git@github.com:whistlegraph/aesthetic-computer.git 36 + 37 + # 2. SSH keys (ed25519). 38 + # /root/.ssh/knot_push ← copy of the vault's home/.ssh/tangled 39 + # (the key registered on @jeffrey's 40 + # Tangled account, allowed to push). 41 + # /root/.ssh/github_mirror ← fresh ed25519 keypair generated on lith; 42 + # public half registered as a repo deploy key 43 + # with write access on 44 + # whistlegraph/aesthetic-computer, 45 + # encrypted private copy in 46 + # aesthetic-computer-vault/lith/mirror/. 47 + # Both files must be mode 600. 48 + 49 + # 3. Pin host keys to avoid interactive prompts. 50 + ssh-keyscan -t ed25519,rsa knot.aesthetic.computer >> /root/.ssh/known_hosts 51 + ssh-keyscan -t ed25519,rsa github.com >> /root/.ssh/known_hosts 52 + sort -u /root/.ssh/known_hosts -o /root/.ssh/known_hosts 53 + 54 + # 4. Install the script + units and enable the timer. 55 + install -m 755 mirror.sh /opt/ac-mirror/mirror.sh 56 + install -m 644 ac-mirror.service /etc/systemd/system/ac-mirror.service 57 + install -m 644 ac-mirror.timer /etc/systemd/system/ac-mirror.timer 58 + systemctl daemon-reload 59 + systemctl enable --now ac-mirror.timer 60 + ``` 61 + 62 + ## Observe / debug 63 + 64 + ```sh 65 + systemctl list-timers ac-mirror.timer 66 + journalctl -u ac-mirror -n 50 67 + # In sync = no output per run. A sync push logs one line: 68 + # 2026-04-20T22:31:27+00:00 → knot behind; pushing <sha> to knot. 69 + ``` 70 + 71 + ## Manual force 72 + 73 + ```sh 74 + # Run immediately (the timer fires hourly-ish otherwise on boot). 75 + systemctl start ac-mirror.service 76 + ``` 77 + 78 + ## Divergent heads 79 + 80 + If both sides received independent commits (true fork), the script exits 81 + `2` and logs: 82 + 83 + ``` 84 + ⚠️ divergent heads: knot=<sha> github=<sha> — skipping (manual resolution required) 85 + ``` 86 + 87 + Resolve by pulling both locally, merging with `git merge`, and pushing 88 + the merge commit. The mirror will then see both sides equal the merge 89 + tip and go back to green. 90 + 91 + ## Why this instead of GitHub Actions? 92 + 93 + A `.github/workflows/mirror-to-knot.yml` was tried first but: 94 + 95 + 1. GitHub Actions is billing-locked on the repo at the moment — 96 + the workflow never fires. 97 + 2. Tangled knot *also* reads `.github/workflows/*.yml` as pipelines, 98 + and sent failure emails about them. 99 + 100 + The systemd timer on lith is free, runs even when GitHub is unavailable, 101 + and keeps all credentials on a host we already control.
+13
lith/mirror/ac-mirror.service
··· 1 + [Unit] 2 + Description=Mirror main between knot and GitHub for aesthetic-computer/core 3 + After=network-online.target 4 + Wants=network-online.target 5 + 6 + [Service] 7 + Type=oneshot 8 + ExecStart=/opt/ac-mirror/mirror.sh 9 + # Don't flood logs with normal exits (code 0 = in sync, 1 = error, 2 = divergent). 10 + SuccessExitStatus=0 11 + 12 + [Install] 13 + WantedBy=multi-user.target
+11
lith/mirror/ac-mirror.timer
··· 1 + [Unit] 2 + Description=Run ac-mirror every 60 seconds 3 + 4 + [Timer] 5 + OnBootSec=60 6 + OnUnitActiveSec=60 7 + AccuracySec=10 8 + Unit=ac-mirror.service 9 + 10 + [Install] 11 + WantedBy=timers.target
+37
lith/mirror/mirror.sh
··· 1 + #!/usr/bin/env bash 2 + # ac-mirror.sh — bidirectional knot ↔ github mirror for the core repo. 3 + # Runs via systemd timer every 60s. Idempotent, exits fast when in sync. 4 + set -euo pipefail 5 + 6 + REPO=/opt/ac-mirror/core 7 + KNOT_KEY=/root/.ssh/knot_push 8 + GH_KEY=/root/.ssh/github_mirror 9 + BRANCH=main 10 + 11 + cd "$REPO" 12 + 13 + export GIT_SSH_COMMAND="ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ 14 + -i $KNOT_KEY -i $GH_KEY" 15 + 16 + # "+" prefix → allow non-fast-forward fetches. A mirror must always track 17 + # wherever the remote actually is, even after force-pushes or rewinds. 18 + git fetch --quiet knot "+$BRANCH":refs/remotes/knot/$BRANCH 19 + git fetch --quiet github "+$BRANCH":refs/remotes/github/$BRANCH 20 + 21 + knot_head=$(git rev-parse "knot/$BRANCH") 22 + gh_head=$(git rev-parse "github/$BRANCH") 23 + 24 + if [ "$knot_head" = "$gh_head" ]; then 25 + exit 0 26 + fi 27 + 28 + if git merge-base --is-ancestor "$gh_head" "$knot_head"; then 29 + echo "$(date -Iseconds) → github behind; pushing $knot_head to github." 30 + git push --quiet github "$knot_head:refs/heads/$BRANCH" 31 + elif git merge-base --is-ancestor "$knot_head" "$gh_head"; then 32 + echo "$(date -Iseconds) → knot behind; pushing $gh_head to knot." 33 + git push --quiet knot "$gh_head:refs/heads/$BRANCH" 34 + else 35 + echo "$(date -Iseconds) ⚠️ divergent heads: knot=$knot_head github=$gh_head — skipping (manual resolution required)" >&2 36 + exit 2 37 + fi
+7 -3
oven/native-builder.mjs
··· 360 360 `if git rev-parse --verify origin/${NATIVE_BRANCH} >/dev/null 2>&1; then`, 361 361 ` git reset --hard origin/${NATIVE_BRANCH} --quiet`, 362 362 "fi", 363 - // Honor caller-specified ref. Fetch the exact commit first (in case 364 - // it's not on the default branch yet / on a PR branch), then detach. 365 - requestedRef ? `git fetch origin ${requestedRef} --quiet || true` : "", 363 + // Honor caller-specified ref. Skip the direct-SHA fetch — tangled/ 364 + // knot rejects short-SHA fetches over the wire (treats them as 365 + // missing refs), which caused preflight-sync failures for every 366 + // 9-char abbreviated ref. Since `fetch origin <branch>` above 367 + // already brings in all commits reachable from main, a local 368 + // `git checkout <ref>` resolves abbreviations against the local 369 + // object db — works for both full and short SHAs. 366 370 requestedRef ? `git checkout -f ${requestedRef} --quiet` : "", 367 371 "git clean -fdq -- fedac/native fedac/nixos", 368 372 ].filter(Boolean).join("\n")], repoDir);
+228
plans/usb-midi-gadget.md
··· 1 + # USB MIDI Gadget for ac-native → Ableton direct 2 + 3 + > Status: **plan / unscoped**. Lets the ac-native ThinkPad present itself 4 + > to the MacBook (running Ableton) as a USB MIDI controller, bypassing the 5 + > session-server WebSocket relay entirely for the lowest possible 6 + > ThinkPad→Ableton latency. 7 + 8 + ## Motivation 9 + 10 + 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. 11 + 12 + Direct USB MIDI gets us: 13 + 14 + - **~1-3ms** ThinkPad key → MacBook MIDI-in (USB controller polling + kernel hop) 15 + - No internet dependency, no session-server required 16 + - No WS subscriptions, no handle/machineId negotiation 17 + - Ableton sees it as a native MIDI controller — appears in MIDI input list, can be mapped, recorded, etc. 18 + 19 + 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. 20 + 21 + ## Architecture 22 + 23 + ``` 24 + ┌───────────────────────────┐ USB-C ┌──────────────────────┐ 25 + │ ThinkPad (ac-native OS) │ ═════════▶ │ MacBook (Ableton) │ 26 + │ Linux g_midi gadget │ │ CoreMIDI auto-mount │ 27 + │ /dev/snd/midiCxDy │ │ "Linux USB MIDI" │ 28 + │ ↑ notepat.mjs writes │ │ → MIDI input list │ 29 + │ raw MIDI bytes │ │ → Ableton track │ 30 + └───────────────────────────┘ └──────────────────────┘ 31 + ``` 32 + 33 + `notepat.mjs` on ac-native gains a new output sink alongside the existing UDP→session-server path: 34 + 35 + ``` 36 + keypress → playSoundKey → [ UDP relay | USB MIDI | USB audio synth | ... ] 37 + ``` 38 + 39 + 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. 40 + 41 + ## Hardware prerequisites 42 + 43 + ### ThinkPad 11e Yoga Gen 6 44 + 45 + 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: 46 + 47 + 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". 48 + 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. 49 + 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. 50 + 51 + **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. 52 + 53 + ### MacBook 54 + 55 + No setup required. macOS auto-detects USB MIDI devices on plug-in and exposes them through CoreMIDI. They show up in: 56 + 57 + - Audio MIDI Setup → Window → Show MIDI Studio 58 + - Ableton → Preferences → Link Tempo MIDI → MIDI Ports → check "Track" for the gadget 59 + 60 + ### Cable 61 + 62 + 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). 63 + 64 + ## Linux gadget setup (ac-native) 65 + 66 + ### Option A — legacy `g_midi` driver (simplest) 67 + 68 + ```bash 69 + modprobe g_midi \ 70 + iProduct="AC Notepat" \ 71 + iManufacturer="Aesthetic Computer" \ 72 + id="AC_NOTEPAT" 73 + ``` 74 + 75 + Creates `/dev/snd/midiC<N>D0` and shows up on the Mac as "AC Notepat". 76 + 77 + ### Option B — configfs composite gadget (more control) 78 + 79 + Scripted at boot time so it survives reboots and can be composed with 80 + other gadget functions later (e.g. serial console). 81 + 82 + ```bash 83 + #!/bin/sh 84 + # /usr/local/bin/ac-usb-gadget-start 85 + 86 + set -e 87 + GADGET=/sys/kernel/config/usb_gadget/ac_notepat 88 + mkdir -p "$GADGET" 89 + 90 + echo 0x1d6b > "$GADGET/idVendor" # Linux Foundation 91 + echo 0x0104 > "$GADGET/idProduct" # Multifunction Composite Gadget 92 + echo 0x0100 > "$GADGET/bcdDevice" 93 + echo 0x0200 > "$GADGET/bcdUSB" 94 + 95 + mkdir -p "$GADGET/strings/0x409" 96 + echo "ACNP-$(cat /etc/machine-id | cut -c1-8)" > "$GADGET/strings/0x409/serialnumber" 97 + echo "Aesthetic Computer" > "$GADGET/strings/0x409/manufacturer" 98 + echo "AC Notepat" > "$GADGET/strings/0x409/product" 99 + 100 + mkdir -p "$GADGET/configs/c.1/strings/0x409" 101 + echo "MIDI config" > "$GADGET/configs/c.1/strings/0x409/configuration" 102 + echo 250 > "$GADGET/configs/c.1/MaxPower" 103 + 104 + mkdir -p "$GADGET/functions/midi.usb0" 105 + echo 1 > "$GADGET/functions/midi.usb0/in_ports" 106 + echo 1 > "$GADGET/functions/midi.usb0/out_ports" 107 + echo 64 > "$GADGET/functions/midi.usb0/buflen" # small buffer = low latency 108 + echo 32 > "$GADGET/functions/midi.usb0/qlen" 109 + 110 + ln -s "$GADGET/functions/midi.usb0" "$GADGET/configs/c.1/" 111 + 112 + # Bind to the first available UDC. ls /sys/class/udc shows the devices. 113 + UDC=$(ls /sys/class/udc | head -n1) 114 + echo "$UDC" > "$GADGET/UDC" 115 + ``` 116 + 117 + Run at boot via an ac-native init hook. A matching teardown script writes empty to `UDC` and `rmdir`s the tree. 118 + 119 + ### Verifying on the Mac 120 + 121 + With the cable plugged in: 122 + 123 + ```bash 124 + system_profiler SPUSBDataType | grep -A 4 "AC Notepat" 125 + # Should list it as a USB device. 126 + 127 + # In Audio MIDI Setup app: "AC Notepat" appears with a MIDI icon. 128 + ``` 129 + 130 + 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. 131 + 132 + ## ac-native integration 133 + 134 + ### New code in `fedac/native/src/` 135 + 136 + `usb-midi.c` (new file): 137 + 138 + ```c 139 + int usb_midi_open(const char *device); // opens /dev/snd/midiC0D0 140 + void usb_midi_close(int fd); 141 + int usb_midi_send_note_on(int fd, int channel, int pitch, int velocity); 142 + int usb_midi_send_note_off(int fd, int channel, int pitch); 143 + ``` 144 + 145 + Uses raw ALSA MIDI bytes: 146 + 147 + - note-on: `0x90 | channel, pitch, velocity` (3 bytes) 148 + - note-off: `0x80 | channel, pitch, 0` 149 + 150 + `write(fd, buf, 3)` on the MIDI char device. Non-blocking mode preferred so a stalled receiver doesn't wedge the audio thread. 151 + 152 + ### JS bindings (`fedac/native/src/js-bindings.c`) 153 + 154 + Mirror the existing `system.udp.sendMidi` helpers: 155 + 156 + ```js 157 + system.usbGadget.open() // → bool 158 + system.usbGadget.sendMidi(event, note, vel, ch) 159 + system.usbGadget.close() 160 + system.usbGadget.status // { connected, device, bytesSent } 161 + ``` 162 + 163 + ### notepat.mjs wiring 164 + 165 + At the existing `sendUdpMidiEvent(…)` call site (around `fedac/native/pieces/notepat.mjs:1847-1850`), also emit USB MIDI when enabled: 166 + 167 + ```js 168 + const usbMidiGadgetEnabled = 169 + cfg.usbMidiGadget === true || cfg.usbMidiGadget === "true"; 170 + 171 + function sendMidiEvent(system, event, midiNote, velocity, channel = 0) { 172 + // Existing: UDP relay 173 + if (udpMidiBroadcast && system?.udp?.connected) { 174 + system.udp.sendMidi(event, midiNote, velocity, channel, "notepat"); 175 + } 176 + // NEW: USB gadget direct to Mac 177 + if (usbMidiGadgetEnabled && system?.usbGadget?.status?.connected) { 178 + system.usbGadget.sendMidi(event, midiNote, velocity, channel); 179 + } 180 + // Existing telemetry counters 181 + udpMidiSentCount += 1; 182 + 183 + } 184 + ``` 185 + 186 + ### Prompt command 187 + 188 + 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`. 189 + 190 + ## Expected latency budget 191 + 192 + | Stage | Cost | 193 + |---|---| 194 + | ThinkPad key press → notepat handler | <1 ms | 195 + | JS → C `sendMidi()` binding | <0.1 ms | 196 + | `write()` → kernel USB gadget | <0.2 ms | 197 + | USB bus traversal (2× full-speed frame ≈ 1 ms polling interval) | ~1-2 ms | 198 + | MacBook USB host → CoreMIDI callback | <0.5 ms | 199 + | Ableton MIDI-in → track → instrument | <1 ms | 200 + | **Audio buffer output** (Live at 64 samples / 48 kHz) | ~1.3 ms | 201 + | **Total keypress → audible** | **~5-7 ms** | 202 + 203 + Versus current session-server path (~40-100 ms), that's **~10-15× faster**. 204 + 205 + ## Out of scope / follow-ups 206 + 207 + - **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. 208 + - **MIDI clock sync**: ac-native can emit `0xF8` every 24 PPQ to sync Ableton's transport. 209 + - **Multi-channel**: currently all notes fire on channel 0. Exposing channel selection in notepat's UI is trivial once the gadget is up. 210 + - **Power**: the ThinkPad draws from its own battery; USB in device mode doesn't supply power to the Mac. Bus power is one-directional. 211 + - **SysEx / MPE**: raw `write()` handles arbitrary MIDI bytes — both just work. 212 + 213 + ## Test plan 214 + 215 + 1. On the ThinkPad: `dmesg | grep gadget` after `g_midi` modprobe — expect "using random self ethernet address" + MIDI gadget init lines. 216 + 2. `aconnect -l` lists the gadget port. 217 + 3. `amidi -l` shows the device with its hw:N,M address. 218 + 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. 219 + 5. Wire into `notepat.mjs`, rebuild ac-native, flash to ThinkPad. 220 + 6. Plug into Mac. Enable in Live's MIDI preferences. 221 + 7. Press keys in ac-native notepat — notes should fire instantly on the Mac. 222 + 8. A/B against the session-server relay path (`midi relay on`) to confirm the latency improvement. 223 + 224 + ## Why not this? 225 + 226 + - 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. 227 + - If you're primarily using ac-native remotely (not physically next to the Mac), keep the session-server path — USB obviously requires a cable. 228 + - 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
··· 1 + // Catch-up sync: Mongo kidlisp → Datomic sidecar. 2 + // 3 + // Unlike backfill-kidlisp-to-datomic.mjs, this script is idempotent: for 4 + // each piece it checks what's already in Datomic and only sends the 5 + // delta. Intended to be run any time the sidecar has missed writes 6 + // (e.g., everything between the 2026-03-24 backfill and the dual-write 7 + // rollout). 8 + // 9 + // Env: 10 + // SIDECAR_URL default http://127.0.0.1:8891 11 + // CLIENT_SECRET required 12 + // SINCE default 2026-03-24T00:00:00Z — low-water mark for 13 + // filtering Mongo docs. Only docs touched after this 14 + // (by `when`, `kept.keptAt`, `ipfsMedia.createdAt`, 15 + // `pendingRebake.createdAt`, or a contract mint date) 16 + // are processed. 17 + // DRY_RUN if "true", logs intended writes without sending 18 + // ONLY_CODE optional — process a single piece by code 19 + // 20 + // Usage (from silo): 21 + // SIDECAR_URL=http://127.0.0.1:8891 \ 22 + // CLIENT_SECRET=... \ 23 + // node system/backend/catchup-kidlisp-to-datomic.mjs 24 + 25 + import { connect } from "./database.mjs"; 26 + 27 + const SIDECAR_URL = process.env.SIDECAR_URL || "http://127.0.0.1:8891"; 28 + const CLIENT_SECRET = process.env.CLIENT_SECRET; 29 + const DRY_RUN = process.env.DRY_RUN === "true"; 30 + const SINCE = new Date(process.env.SINCE || "2026-03-24T00:00:00Z"); 31 + const ONLY_CODE = process.env.ONLY_CODE || null; 32 + 33 + if (!DRY_RUN && !CLIENT_SECRET) { 34 + console.error("CLIENT_SECRET is required (or set DRY_RUN=true)"); 35 + process.exit(1); 36 + } 37 + 38 + function sidecarHeaders() { 39 + return { 40 + "content-type": "application/json", 41 + "x-sidecar-secret": CLIENT_SECRET, 42 + }; 43 + } 44 + 45 + async function sidecarReq(method, path, body) { 46 + if (DRY_RUN && method !== "GET") { 47 + return { ok: true, status: 200, body: { dryRun: true } }; 48 + } 49 + const res = await fetch(`${SIDECAR_URL}${path}`, { 50 + method, 51 + headers: sidecarHeaders(), 52 + body: body != null ? JSON.stringify(body) : undefined, 53 + }); 54 + const text = await res.text(); 55 + let json = null; 56 + try { json = text ? JSON.parse(text) : null; } catch { /* not JSON */ } 57 + return { ok: res.ok, status: res.status, body: json ?? text }; 58 + } 59 + 60 + function normInstant(value) { 61 + if (!value) return null; 62 + if (value instanceof Date) return value.toISOString(); 63 + if (typeof value === "string") return value; 64 + if (typeof value === "number") return new Date(value).toISOString(); 65 + return null; 66 + } 67 + 68 + function normalizeKeep(k, defaults = {}) { 69 + const tokenId = Number(k?.tokenId); 70 + if (!Number.isInteger(tokenId) || tokenId < 0) return null; 71 + const contractAddress = k?.contractAddress || defaults.contractAddress || null; 72 + if (!contractAddress) return null; 73 + return { 74 + tokenId, 75 + contractAddress, 76 + network: k?.network || defaults.network || "mainnet", 77 + txHash: k?.txHash || defaults.txHash || null, 78 + contractProfile: k?.contractProfile || k?.profile || defaults.contractProfile || null, 79 + contractVersion: k?.contractVersion || k?.version || defaults.contractVersion || null, 80 + keptAt: normInstant(k?.keptAt || k?.mintedAt || defaults.keptAt), 81 + keptBy: k?.keptBy || defaults.keptBy || null, 82 + walletAddress: k?.walletAddress || k?.owner || defaults.walletAddress || null, 83 + artifactUri: k?.artifactUri || defaults.artifactUri || null, 84 + thumbnailUri: k?.thumbnailUri || defaults.thumbnailUri || null, 85 + metadataUri: k?.metadataUri || defaults.metadataUri || null, 86 + source: defaults.source || "catchup", 87 + }; 88 + } 89 + 90 + function existingKeepKey(k) { 91 + return `${k?.tokenId}::${(k?.contractAddress || "").toLowerCase()}::${k?.txHash || ""}`; 92 + } 93 + 94 + function docTouchedSince(doc, since) { 95 + const t = since.getTime(); 96 + const dates = [ 97 + doc.when, 98 + doc.kept?.keptAt, 99 + doc.ipfsMedia?.createdAt, 100 + doc.pendingRebake?.createdAt, 101 + ]; 102 + if (doc.tezos?.contracts && typeof doc.tezos.contracts === "object") { 103 + for (const v of Object.values(doc.tezos.contracts)) { 104 + if (v?.mintedAt) dates.push(v.mintedAt); 105 + if (v?.lastUpdatedAt) dates.push(v.lastUpdatedAt); 106 + if (v?.lastConfirmAt) dates.push(v.lastConfirmAt); 107 + } 108 + } 109 + for (const d of dates) { 110 + if (!d) continue; 111 + const dt = new Date(d).getTime(); 112 + if (Number.isFinite(dt) && dt >= t) return true; 113 + } 114 + return false; 115 + } 116 + 117 + async function ensureEntity(doc, stats) { 118 + // Idempotent by hash — if present, sidecar bumps hits and returns code. 119 + const res = await sidecarReq("POST", "/kidlisp", { 120 + code: doc.code, 121 + source: doc.source, 122 + hash: doc.hash, 123 + user_sub: doc.user || null, 124 + when: doc.when ? new Date(doc.when).toISOString() : null, 125 + hits: typeof doc.hits === "number" ? doc.hits : 1, 126 + }); 127 + if (!res.ok) { 128 + stats.errors++; 129 + console.error(` ! ensure failed ${doc.code}: ${res.status} ${JSON.stringify(res.body)}`); 130 + return false; 131 + } 132 + return true; 133 + } 134 + 135 + async function syncIpfsMedia(doc, stats) { 136 + if (!doc.ipfsMedia) return; 137 + const res = await sidecarReq("POST", `/kidlisp/${doc.code}/ipfs-media`, { 138 + artifactUri: doc.ipfsMedia.artifactUri || null, 139 + thumbnailUri: doc.ipfsMedia.thumbnailUri || null, 140 + sourceHash: doc.ipfsMedia.sourceHash || null, 141 + authorHandle: doc.ipfsMedia.authorHandle || null, 142 + depCount: doc.ipfsMedia.depCount ?? null, 143 + packDate: doc.ipfsMedia.packDate || null, 144 + createdAt: normInstant(doc.ipfsMedia.createdAt), 145 + }); 146 + if (res.ok) stats.ipfs++; 147 + else { 148 + stats.errors++; 149 + console.error(` ! ipfs-media ${doc.code}: ${res.status}`); 150 + } 151 + } 152 + 153 + async function syncPendingRebake(doc, stats) { 154 + if (!doc.pendingRebake) return; 155 + const res = await sidecarReq("POST", `/kidlisp/${doc.code}/pending-rebake`, doc.pendingRebake); 156 + if (res.ok) stats.pendingRebake++; 157 + else { 158 + stats.errors++; 159 + console.error(` ! pending-rebake ${doc.code}: ${res.status}`); 160 + } 161 + } 162 + 163 + async function syncTezosState(doc, stats) { 164 + if (!doc.tezos || typeof doc.tezos !== "object") return; 165 + const t = doc.tezos; 166 + const res = await sidecarReq("POST", `/kidlisp/${doc.code}/tezos-state`, { 167 + minted: !!t.minted, 168 + exists: !!t.exists, 169 + tokenId: t.tokenId ?? null, 170 + txHash: t.txHash ?? null, 171 + creatorAddress: t.creatorAddress ?? null, 172 + codeHash: t.codeHash ?? null, 173 + network: t.network ?? null, 174 + reason: t.reason ?? null, 175 + error: t.error ?? null, 176 + }); 177 + if (res.ok) stats.tezos++; 178 + else { 179 + stats.errors++; 180 + console.error(` ! tezos-state ${doc.code}: ${res.status}`); 181 + } 182 + } 183 + 184 + async function syncKeeps(doc, stats) { 185 + // Collect candidate keeps from the Mongo doc 186 + const candidates = []; 187 + if (doc.kept) { 188 + const k = normalizeKeep(doc.kept, { source: "kept" }); 189 + if (k) candidates.push(k); 190 + } 191 + if (doc.tezos?.contracts && typeof doc.tezos.contracts === "object") { 192 + for (const [contractAddress, v] of Object.entries(doc.tezos.contracts)) { 193 + if (!v || typeof v !== "object" || !v.minted) continue; 194 + const k = normalizeKeep(v, { 195 + source: "contract_keyed", 196 + contractAddress, 197 + keptAt: v.mintedAt, 198 + }); 199 + if (k) candidates.push(k); 200 + } 201 + } 202 + if (candidates.length === 0) return; 203 + 204 + // Ask datomic what it already has so we skip duplicates. 205 + let existing = new Set(); 206 + if (!DRY_RUN) { 207 + const lookup = await sidecarReq("GET", `/kidlisp/${doc.code}`); 208 + if (lookup.ok && lookup.body?.keeps) { 209 + for (const k of lookup.body.keeps) existing.add(existingKeepKey(k)); 210 + } 211 + } 212 + 213 + for (const k of candidates) { 214 + if (existing.has(existingKeepKey(k))) { 215 + stats.keepsSkipped++; 216 + continue; 217 + } 218 + const res = await sidecarReq("POST", `/kidlisp/${doc.code}/mint`, k); 219 + if (res.ok) stats.keeps++; 220 + else { 221 + stats.errors++; 222 + console.error(` ! mint ${doc.code} token ${k.tokenId}: ${res.status} ${JSON.stringify(res.body)}`); 223 + } 224 + } 225 + } 226 + 227 + async function processDoc(doc, stats) { 228 + stats.processed++; 229 + const ok = await ensureEntity(doc, stats); 230 + if (!ok) return; 231 + await syncIpfsMedia(doc, stats); 232 + await syncPendingRebake(doc, stats); 233 + await syncTezosState(doc, stats); 234 + await syncKeeps(doc, stats); 235 + } 236 + 237 + async function main() { 238 + const started = Date.now(); 239 + console.log(`▶ catchup start — dryRun=${DRY_RUN} sidecar=${SIDECAR_URL} since=${SINCE.toISOString()}`); 240 + 241 + const database = await connect(); 242 + const coll = database.db.collection("kidlisp"); 243 + 244 + // Broad filter then precise check — picks up docs that were touched 245 + // after SINCE via any of the mutable sub-fields. 246 + const filter = ONLY_CODE 247 + ? { code: ONLY_CODE } 248 + : { 249 + $or: [ 250 + { when: { $gte: SINCE } }, 251 + { "kept.keptAt": { $gte: SINCE } }, 252 + { "ipfsMedia.createdAt": { $gte: SINCE } }, 253 + { "pendingRebake.createdAt": { $gte: SINCE } }, 254 + { "tezos.mintedAt": { $gte: SINCE } }, 255 + ], 256 + }; 257 + 258 + const total = await coll.countDocuments(filter); 259 + console.log(` candidate docs: ${total}`); 260 + 261 + const cursor = coll.find(filter).sort({ when: 1 }).batchSize(200); 262 + 263 + const stats = { 264 + processed: 0, ensured: 0, keeps: 0, keepsSkipped: 0, 265 + ipfs: 0, pendingRebake: 0, tezos: 0, errors: 0, 266 + }; 267 + 268 + let last = Date.now(); 269 + for await (const doc of cursor) { 270 + if (!docTouchedSince(doc, SINCE) && !ONLY_CODE) continue; 271 + await processDoc(doc, stats); 272 + if (Date.now() - last > 3000) { 273 + console.log(` ${stats.processed} — ${JSON.stringify(stats)}`); 274 + last = Date.now(); 275 + } 276 + } 277 + 278 + await database.disconnect(); 279 + const secs = ((Date.now() - started) / 1000).toFixed(1); 280 + console.log(`✓ catchup done in ${secs}s — ${JSON.stringify(stats)}`); 281 + if (stats.errors > 0) process.exit(1); 282 + } 283 + 284 + main().catch((err) => { 285 + console.error("✗ catchup fatal:", err); 286 + process.exit(1); 287 + });
+116
system/backend/kidlisp-dual-write.mjs
··· 1 + // Dual-write helpers for the kidlisp Datomic cutover. 2 + // 3 + // Each function mirrors a Mongo write into the sidecar when 4 + // KIDLISP_DATOMIC=on. They are fire-and-forget from the caller's 5 + // perspective: errors are logged and swallowed so that the live 6 + // Mongo write remains the source of truth for request success. 7 + // 8 + // Gate these calls on `kidlispDatomicEnabled()` in the caller so we 9 + // don't pay for the sidecar round-trip when the flag is off. 10 + 11 + import { sidecar, kidlispDatomicEnabled } from "./kidlisp-sidecar.mjs"; 12 + 13 + function normalizeInstant(value) { 14 + if (!value) return null; 15 + if (value instanceof Date) return value.toISOString(); 16 + if (typeof value === "string") return value; 17 + if (typeof value === "number") return new Date(value).toISOString(); 18 + return null; 19 + } 20 + 21 + function swallow(label, code) { 22 + return (err) => { 23 + const msg = err?.message || String(err); 24 + console.warn(`⚠️ sidecar ${label} for $${code} failed: ${msg}`); 25 + }; 26 + } 27 + 28 + // Record a mint in Datomic. No-ops without a tokenId or contract address, 29 + // since the sidecar's record-mint endpoint requires both. `source` labels 30 + // the origin (kept, update, server_mint, contract_keyed, …) so the primary 31 + // keep selector can prefer the freshest record. 32 + export async function mirrorRecordMint(code, keep, { source = "kept" } = {}) { 33 + if (!kidlispDatomicEnabled()) return; 34 + if (!code || !keep) return; 35 + const tokenId = Number(keep.tokenId); 36 + if (!Number.isInteger(tokenId) || tokenId < 0) return; 37 + const contractAddress = keep.contractAddress || null; 38 + if (!contractAddress) return; 39 + 40 + const body = { 41 + tokenId, 42 + contractAddress, 43 + network: keep.network || "mainnet", 44 + txHash: keep.txHash || null, 45 + contractProfile: keep.contractProfile || null, 46 + contractVersion: keep.contractVersion || null, 47 + keptAt: normalizeInstant(keep.keptAt || keep.mintedAt || new Date()), 48 + keptBy: keep.keptBy || null, 49 + walletAddress: keep.walletAddress || keep.owner || null, 50 + artifactUri: keep.artifactUri || null, 51 + thumbnailUri: keep.thumbnailUri || null, 52 + metadataUri: keep.metadataUri || null, 53 + source, 54 + }; 55 + 56 + try { 57 + await sidecar.recordMint(code, body); 58 + } catch (err) { 59 + swallow("recordMint", code)(err); 60 + } 61 + } 62 + 63 + export async function mirrorIpfsMedia(code, media) { 64 + if (!kidlispDatomicEnabled()) return; 65 + if (!code || !media) return; 66 + try { 67 + await sidecar.setIpfsMedia(code, { 68 + artifactUri: media.artifactUri || null, 69 + thumbnailUri: media.thumbnailUri || null, 70 + sourceHash: media.sourceHash || null, 71 + authorHandle: media.authorHandle || null, 72 + depCount: media.depCount ?? null, 73 + packDate: media.packDate || null, 74 + createdAt: normalizeInstant(media.createdAt), 75 + }); 76 + } catch (err) { 77 + swallow("setIpfsMedia", code)(err); 78 + } 79 + } 80 + 81 + export async function mirrorPendingRebake(code, rebake) { 82 + if (!kidlispDatomicEnabled()) return; 83 + if (!code || !rebake) return; 84 + try { 85 + await sidecar.setPendingRebake(code, { 86 + artifactUri: rebake.artifactUri || null, 87 + thumbnailUri: rebake.thumbnailUri || null, 88 + metadataUri: rebake.metadataUri || null, 89 + contractAddress: rebake.contractAddress || null, 90 + contractProfile: rebake.contractProfile || null, 91 + contractVersion: rebake.contractVersion || null, 92 + }); 93 + } catch (err) { 94 + swallow("setPendingRebake", code)(err); 95 + } 96 + } 97 + 98 + export async function mirrorTezosState(code, state) { 99 + if (!kidlispDatomicEnabled()) return; 100 + if (!code || !state) return; 101 + try { 102 + await sidecar.setTezosState(code, { 103 + minted: !!state.minted, 104 + exists: !!state.exists, 105 + tokenId: state.tokenId ?? null, 106 + txHash: state.txHash ?? null, 107 + creatorAddress: state.creatorAddress ?? null, 108 + codeHash: state.codeHash ?? null, 109 + network: state.network ?? null, 110 + reason: state.reason ?? null, 111 + error: state.error ?? null, 112 + }); 113 + } catch (err) { 114 + swallow("setTezosState", code)(err); 115 + } 116 + }
+6
system/netlify.toml
··· 1 + # DEPRECATED: Aesthetic Computer no longer deploys on Netlify. The production 2 + # host is the lith Express monolith at lith.aesthetic.computer (see CLAUDE.md). 3 + # This file is retained for historical reference only — do not add new routes 4 + # here. Backend handlers still live in system/netlify/functions/ (the path is 5 + # historical), and lith adapts them via app.all("/api/:fn", …) at runtime. 6 + 1 7 [build] 2 8 base = "system" 3 9 publish = "public"
+151
system/netlify/functions/commits.mjs
··· 1 + // Returns paginated commits from the Tangled remote (git log via local mirror). 2 + // Source of truth: tangled.org/aesthetic.computer/core (mirrored on knot.aesthetic.computer). 3 + 4 + import fs from "fs"; 5 + import path from "path"; 6 + import { execFile } from "child_process"; 7 + import { promisify } from "util"; 8 + 9 + const execFileAsync = promisify(execFile); 10 + const GIT_BRANCH = process.env.VERSION_GIT_BRANCH || "main"; 11 + const MAX_PER_PAGE = 100; 12 + const FETCH_TTL_MS = 60 * 1000; 13 + 14 + let lastFetchAt = 0; 15 + 16 + function getRepoRoot() { 17 + const candidates = [ 18 + path.resolve(process.cwd(), ".."), 19 + process.cwd(), 20 + ]; 21 + return candidates.find((c) => fs.existsSync(path.join(c, ".git"))) || null; 22 + } 23 + 24 + async function git(args, repoRoot) { 25 + const { stdout } = await execFileAsync("git", ["-C", repoRoot, ...args], { 26 + timeout: 15000, 27 + maxBuffer: 8 * 1024 * 1024, 28 + }); 29 + return stdout; 30 + } 31 + 32 + async function getPreferredRemote(repoRoot) { 33 + if (process.env.VERSION_GIT_REMOTE) return process.env.VERSION_GIT_REMOTE; 34 + const remotes = (await git(["remote"], repoRoot)) 35 + .split("\n") 36 + .map((r) => r.trim()) 37 + .filter(Boolean); 38 + if (remotes.includes("tangled")) return "tangled"; 39 + if (remotes.includes("origin")) return "origin"; 40 + return remotes[0] || "origin"; 41 + } 42 + 43 + async function maybeFetch(remote, repoRoot) { 44 + if (Date.now() - lastFetchAt < FETCH_TTL_MS) return; 45 + try { 46 + await git(["fetch", "--quiet", remote, GIT_BRANCH], repoRoot); 47 + lastFetchAt = Date.now(); 48 + } catch { 49 + // Ignore; stale data is better than an error. 50 + } 51 + } 52 + 53 + function parseCommits(raw) { 54 + const blocks = raw.split("<<COMMIT>>").map((b) => b.trim()).filter(Boolean); 55 + const commits = []; 56 + for (const block of blocks) { 57 + const lines = block.split("\n"); 58 + const header = lines[0]; 59 + const parts = header.split("|"); 60 + if (parts.length < 6) continue; 61 + const [sha, parentField, author, email, date, ...messageParts] = parts; 62 + const message = messageParts.join("|"); 63 + 64 + let additions = 0; 65 + let deletions = 0; 66 + let files = 0; 67 + for (let i = 1; i < lines.length; i++) { 68 + const line = lines[i].trim(); 69 + if (!line) continue; 70 + const [a, d, f] = line.split("\t"); 71 + if (!f) continue; 72 + files += 1; 73 + if (a === "-" || d === "-") continue; 74 + additions += Number(a) || 0; 75 + deletions += Number(d) || 0; 76 + } 77 + 78 + commits.push({ 79 + sha, 80 + shortSha: sha.slice(0, 7), 81 + parents: parentField ? parentField.split(" ").filter(Boolean).length : 0, 82 + author, 83 + email, 84 + date, 85 + message, 86 + additions, 87 + deletions, 88 + files, 89 + }); 90 + } 91 + return commits; 92 + } 93 + 94 + export default async (request) => { 95 + const url = new URL(request.url); 96 + const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10)); 97 + const perPage = Math.min( 98 + MAX_PER_PAGE, 99 + Math.max(1, parseInt(url.searchParams.get("per_page") || "30", 10)), 100 + ); 101 + const skip = (page - 1) * perPage; 102 + 103 + const repoRoot = getRepoRoot(); 104 + if (!repoRoot) { 105 + return new Response( 106 + JSON.stringify({ error: "repo not found", commits: [] }), 107 + { status: 500, headers: { "Content-Type": "application/json" } }, 108 + ); 109 + } 110 + 111 + try { 112 + const remote = await getPreferredRemote(repoRoot); 113 + await maybeFetch(remote, repoRoot); 114 + 115 + const raw = await git( 116 + [ 117 + "log", 118 + `${remote}/${GIT_BRANCH}`, 119 + `--skip=${skip}`, 120 + `--max-count=${perPage}`, 121 + "--pretty=format:<<COMMIT>>%H|%P|%an|%ae|%cI|%s", 122 + "--numstat", 123 + ], 124 + repoRoot, 125 + ); 126 + 127 + const commits = parseCommits(raw); 128 + 129 + return new Response( 130 + JSON.stringify({ 131 + page, 132 + perPage, 133 + hasMore: commits.length === perPage, 134 + commits, 135 + }), 136 + { 137 + headers: { 138 + "Content-Type": "application/json", 139 + "Cache-Control": "public, max-age=60", 140 + }, 141 + }, 142 + ); 143 + } catch (e) { 144 + return new Response( 145 + JSON.stringify({ error: e.message, commits: [] }), 146 + { status: 500, headers: { "Content-Type": "application/json" } }, 147 + ); 148 + } 149 + }; 150 + 151 + export const config = { path: "/api/commits" };
+56 -43
system/netlify/functions/index.mjs
··· 1302 1302 var isNotepat=location.hostname==='notepat.com'||location.hostname==='www.notepat.com'||location.pathname==='/notepat'||location.pathname.startsWith('/notepat?')||location.pathname.startsWith('/notepat/'); 1303 1303 // Notebook: Python/Jupyter notebook with scientific aesthetic 1304 1304 var isNotebook=qs.indexOf('notebook=true')>=0; 1305 - // Boot animation mode: 'spring' (turtle-graphics birds, default), 'serious' (clean/refined), or 'aesthetic' (VHS/glitch) 1305 + // Boot animation mode: 'spring' (turtle-graphics rainbows, default), 'serious' (clean/refined), or 'aesthetic' (VHS/glitch) 1306 1306 var bootTheme=params.get('boot')||'spring';var isSerious=bootTheme==='serious';var isSpring=bootTheme==='spring'; 1307 1307 // Density param for scaling (default 1, FF1 uses 8 for 4K) 1308 1308 var densityMatch=qs.match(/density=(\d+)/);var densityParam=densityMatch?parseInt(densityMatch[1]):1; ··· 1371 1371 var NP_KEYS=[];var NP_PARTICLES=[];var NP_LAST_KEY=0;var NP_KEY_INTERVAL=120; 1372 1372 var NP_NOTE_NAMES=['C','D','E','F','G','A','B']; 1373 1373 var NP_KEY_COLS=[[255,107,157],[78,205,196],[255,217,61],[149,225,211],[255,154,162],[170,150,218],[112,214,255],[255,183,77]]; 1374 - // 🐦 Spring boot animation state — procedural turtle-graphics birds 1375 - var SPRING_BIRDS=[],SPRING_INIT=false; 1374 + // 🌈 Spring boot animation state — procedural turtle-graphics rainbows 1375 + var SPRING_RAINBOWS=[],SPRING_INIT=false; 1376 1376 var SPRING_FONTS_LIGHT=['serif','monospace','YWFTProcessing-Bold, monospace','cursive','Georgia, serif','Courier New, monospace']; 1377 - var SPRING_BIRD_HUES=[205,185,50,30,340,280,160,15,100]; // sky/teal/canary/robin/rose/violet/mint/coral/moss 1378 - function springInit(S){SPRING_BIRDS=[];var n=14;for(var i=0;i<n;i++){SPRING_BIRDS.push({px:Math.random(),py:0.2+Math.random()*0.7,r:(4.5+Math.random()*6)*S,hue:SPRING_BIRD_HUES[i%SPRING_BIRD_HUES.length],phase:Math.random()*Math.PI*2,bob:0.5+Math.random()*1.2,flapSpeed:5+Math.random()*5,vx:(0.0014+Math.random()*0.0022)*(Math.random()>0.5?1:-1),bellyHue:40+Math.random()*30,beakHue:Math.random()>0.5?32:48});}SPRING_INIT=true;} 1379 - function drawTurtleBird(ctx,W,H,b,t,S,isLightMode){ 1377 + // ROYGBIV hues (red, orange, yellow, green, blue, indigo, violet) 1378 + var SPRING_RAINBOW_HUES=[0,28,54,120,210,255,290]; 1379 + function springInit(S){SPRING_RAINBOWS=[];var n=7;for(var i=0;i<n;i++){SPRING_RAINBOWS.push({px:Math.random(),py:0.28+Math.random()*0.55,r:(22+Math.random()*34)*S,phase:Math.random()*Math.PI*2,bob:0.6+Math.random()*1.3,tilt:(Math.random()-0.5)*0.28,vx:(0.0009+Math.random()*0.0019)*(Math.random()>0.5?1:-1),twinkle:Math.random()*Math.PI*2,puffHue:40+Math.random()*30});}SPRING_INIT=true;} 1380 + function drawRainbow(ctx,W,H,b,t,S,isLightMode){ 1380 1381 // Drift horizontally, wrap around edges 1381 - b.px+=b.vx;if(b.px>1.15)b.px=-0.15;if(b.px<-0.15)b.px=1.15; 1382 - var cx=b.px*W,cy=b.py*H+Math.sin(t*1.2+b.phase)*b.bob*3*S,r=b.r; 1383 - var dir=b.vx>=0?1:-1,flap=Math.sin(t*b.flapSpeed+b.phase); 1384 - ctx.save();ctx.translate(cx,cy);ctx.scale(dir,1); 1385 - var outline=isLightMode?'hsla('+b.hue+',70%,20%,0.7)':'hsla('+b.hue+',60%,15%,0.7)'; 1386 - ctx.strokeStyle=outline;ctx.lineWidth=Math.max(0.5,0.7*S); 1387 - // Tail — forked (turtle-style polyline) 1388 - ctx.beginPath();ctx.moveTo(-r*0.85,0);ctx.lineTo(-r*1.8,-r*0.35);ctx.lineTo(-r*1.5,0);ctx.lineTo(-r*1.8,r*0.35);ctx.closePath(); 1389 - ctx.fillStyle='hsl('+b.hue+',70%,'+(isLightMode?40:52)+'%)';ctx.fill();ctx.stroke(); 1390 - // Body (egg-shape) 1391 - ctx.beginPath();ctx.ellipse(0,0,r,r*0.65,0,0,Math.PI*2); 1392 - ctx.fillStyle='hsl('+b.hue+',65%,'+(isLightMode?55:60)+'%)';ctx.fill();ctx.stroke(); 1393 - // Belly patch 1394 - ctx.beginPath();ctx.ellipse(0,r*0.15,r*0.7,r*0.4,0,0,Math.PI*2); 1395 - ctx.fillStyle='hsl('+b.bellyHue+',85%,'+(isLightMode?78:80)+'%)';ctx.globalAlpha=0.75;ctx.fill();ctx.globalAlpha=1; 1396 - // Wing — flaps; turtle-style sin(theta) petal shape 1397 - ctx.save();ctx.translate(-r*0.1,-r*0.15);ctx.rotate(flap*0.55);ctx.beginPath(); 1398 - var wL=r*1.2,wsteps=14;for(var wi=0;wi<=wsteps;wi++){var wth=wi/wsteps*Math.PI;var wrr=Math.sin(wth)*wL;ctx.lineTo(wrr*Math.cos(wth)-r*0.3,wrr*Math.sin(wth)*0.55-r*0.2);} 1399 - ctx.closePath();ctx.fillStyle='hsl('+b.hue+',60%,'+(isLightMode?42:48)+'%)';ctx.fill();ctx.stroke();ctx.restore(); 1400 - // Head 1401 - ctx.beginPath();ctx.arc(r*0.85,-r*0.35,r*0.5,0,Math.PI*2); 1402 - ctx.fillStyle='hsl('+b.hue+',68%,'+(isLightMode?58:64)+'%)';ctx.fill();ctx.stroke(); 1403 - // Beak 1404 - ctx.beginPath();ctx.moveTo(r*1.25,-r*0.35);ctx.lineTo(r*1.75,-r*0.22);ctx.lineTo(r*1.25,-r*0.12);ctx.closePath(); 1405 - ctx.fillStyle='hsl('+b.beakHue+',95%,'+(isLightMode?48:55)+'%)';ctx.fill(); 1406 - ctx.strokeStyle='hsla('+b.beakHue+',90%,25%,0.8)';ctx.stroke(); 1407 - // Eye + sparkle 1408 - ctx.beginPath();ctx.arc(r*0.95,-r*0.5,Math.max(0.8,1.0*S),0,Math.PI*2);ctx.fillStyle='#111';ctx.fill(); 1409 - ctx.beginPath();ctx.arc(r*1.02,-r*0.56,Math.max(0.3,0.35*S),0,Math.PI*2);ctx.fillStyle='#fff';ctx.fill(); 1410 - // Tiny feet tucked under body 1411 - ctx.strokeStyle=outline;ctx.lineWidth=Math.max(0.5,0.6*S); 1412 - ctx.beginPath();ctx.moveTo(-r*0.15,r*0.6);ctx.lineTo(-r*0.1,r*0.85);ctx.moveTo(r*0.15,r*0.6);ctx.lineTo(r*0.2,r*0.85);ctx.stroke(); 1382 + b.px+=b.vx;if(b.px>1.2)b.px=-0.2;if(b.px<-0.2)b.px=1.2; 1383 + var cx=b.px*W,cy=b.py*H+Math.sin(t*0.9+b.phase)*b.bob*2.5*S,r=b.r; 1384 + var tilt=b.tilt+Math.sin(t*0.45+b.phase)*0.07; 1385 + ctx.save();ctx.translate(cx,cy);ctx.rotate(tilt); 1386 + var bandW=Math.max(1.4,r*0.095); 1387 + ctx.lineCap='round'; 1388 + // Soft halo behind the bow 1389 + ctx.beginPath();ctx.arc(0,0,r+bandW*0.3,Math.PI,Math.PI*2); 1390 + ctx.lineWidth=bandW*1.6; 1391 + ctx.strokeStyle=isLightMode?'rgba(255,250,210,0.55)':'rgba(255,240,180,0.22)'; 1392 + ctx.stroke(); 1393 + // ROYGBIV arcs from outermost to innermost (turtle-style concentric strokes) 1394 + for(var bi=0;bi<SPRING_RAINBOW_HUES.length;bi++){ 1395 + var hue=SPRING_RAINBOW_HUES[bi];var bandR=r-bi*bandW;if(bandR<bandW)break; 1396 + ctx.beginPath();ctx.arc(0,0,bandR,Math.PI,Math.PI*2); 1397 + ctx.lineWidth=bandW*0.92; 1398 + ctx.strokeStyle='hsla('+hue+',88%,'+(isLightMode?52:62)+'%,0.85)'; 1399 + ctx.stroke(); 1400 + } 1401 + // Little cloud puffs at each foot 1402 + var puffY=bandW*0.2,puffR=bandW*1.9; 1403 + for(var side=-1;side<=1;side+=2){ 1404 + var fx=side*(r-bandW*3.5); 1405 + ctx.beginPath();ctx.arc(fx,puffY,puffR,0,Math.PI*2); 1406 + ctx.fillStyle=isLightMode?'hsla('+b.puffHue+',60%,92%,0.9)':'hsla('+b.puffHue+',40%,82%,0.55)'; 1407 + ctx.fill(); 1408 + ctx.beginPath();ctx.arc(fx+side*puffR*0.7,puffY-puffR*0.45,puffR*0.75,0,Math.PI*2); 1409 + ctx.fillStyle=isLightMode?'hsla('+b.puffHue+',55%,95%,0.85)':'hsla('+b.puffHue+',35%,78%,0.45)'; 1410 + ctx.fill(); 1411 + ctx.beginPath();ctx.arc(fx-side*puffR*0.55,puffY-puffR*0.25,puffR*0.6,0,Math.PI*2); 1412 + ctx.fillStyle=isLightMode?'hsla('+b.puffHue+',55%,94%,0.75)':'hsla('+b.puffHue+',35%,75%,0.4)'; 1413 + ctx.fill(); 1414 + } 1415 + // Twinkle sparkle at the crown 1416 + var twA=0.45+Math.sin(t*3+b.twinkle)*0.45; 1417 + ctx.globalAlpha=Math.max(0,twA); 1418 + ctx.fillStyle=isLightMode?'#fff4b0':'#fffce0'; 1419 + ctx.beginPath();ctx.arc(0,-r+bandW*0.5,Math.max(1,1.5*S),0,Math.PI*2);ctx.fill(); 1420 + // Tiny cross-sparkle rays 1421 + ctx.strokeStyle=ctx.fillStyle;ctx.lineWidth=Math.max(0.5,0.6*S); 1422 + var rays=Math.max(2.5,2.2*S); 1423 + ctx.beginPath();ctx.moveTo(-rays,-r+bandW*0.5);ctx.lineTo(rays,-r+bandW*0.5); 1424 + ctx.moveTo(0,-r+bandW*0.5-rays);ctx.lineTo(0,-r+bandW*0.5+rays);ctx.stroke(); 1425 + ctx.globalAlpha=1; 1413 1426 ctx.restore();} 1414 1427 // 📊 Notebook scientific aesthetic boot animation state 1415 1428 var NB_DATA_POINTS=[];var NB_GRID_LINES=[];var NB_WAVEFORMS=[];var NB_LAST_SPAWN=0; ··· 1562 1575 var logFS=densityParam===1&&isDeviceMode?Math.max(14,Math.floor(H/60)):4*S*dS; 1563 1576 x.font=logFS+'px monospace';var logY=(densityParam===1&&isDeviceMode?Math.floor(H/20):16*S*dS)+embedPad;var logSpacing=densityParam===1&&isDeviceMode?Math.floor(logFS*1.5):7*S*dS;for(var li=0;li<lines.length&&li<10;li++){var ln=lines[li],ly=logY+li*logSpacing,la=Math.max(0.3,1-li*0.08),lc=klCols[li%klCols.length];var tw=x.measureText(ln.text).width;var logX=densityParam===1&&isDeviceMode?20:10*S*dS;var textX=densityParam===1&&isDeviceMode?30:(logX+3*S*dS);var pillH=densityParam===1&&isDeviceMode?Math.floor(logFS*1.2):6*S*dS;var pillR=densityParam===1&&isDeviceMode?6:3*S*dS;var pillW=tw+(textX-logX)*2;x.globalAlpha=la*0.15;x.fillStyle='rgb('+lc[0]+','+lc[1]+','+lc[2]+')';x.beginPath();x.roundRect(logX,ly-pillH*0.65,pillW,pillH,pillR);x.fill();x.globalAlpha=la;x.fillStyle='rgb('+lc[0]+','+lc[1]+','+lc[2]+')';x.fillText(ln.text,textX,ly);} 1564 1577 x.globalAlpha=1;requestAnimationFrame(anim);return;} 1565 - // 🐦 Spring mode — yellowish, light, with procedural turtle-graphics birds (default) 1578 + // 🌈 Spring mode — yellowish, light, with procedural turtle-graphics rainbows (default) 1566 1579 if(isSpring){ 1567 1580 if(!SPRING_INIT)springInit(S); 1568 1581 // Soft sunny gradient bg (cream/butter for light, deep amber for dark) ··· 1575 1588 x.globalAlpha=1; 1576 1589 // Sunbeam streaks from top-right 1577 1590 for(var br=0;br<5;br++){x.save();x.globalAlpha=0.06+Math.sin(t*0.5+br)*0.03;x.fillStyle=isLightMode?'#fff7c0':'#ffeeaa';x.translate(W*0.85,0);x.rotate(0.6+br*0.08);x.fillRect(-2*S,0,4*S,H*1.4);x.restore();} 1578 - // Draw all birds (sorted by y for fake depth) 1579 - var sortedBd=SPRING_BIRDS.slice().sort(function(a,b){return a.py-b.py;}); 1580 - for(var fi=0;fi<sortedBd.length;fi++){drawTurtleBird(x,W,H,sortedBd[fi],t,S,isLightMode);} 1591 + // Draw all rainbows (sorted by y for fake depth — farther ones higher up) 1592 + var sortedBd=SPRING_RAINBOWS.slice().sort(function(a,b){return a.py-b.py;}); 1593 + for(var fi=0;fi<sortedBd.length;fi++){drawRainbow(x,W,H,sortedBd[fi],t,S,isLightMode);} 1581 1594 // Logo top-left (small, soft) 1582 1595 var lS=21*S,lX=5*S,lY=5*S;var logoImg=imgFullLoaded?imgFull:img; 1583 1596 x.imageSmoothingEnabled=imgFullLoaded;x.globalAlpha=0.92;x.drawImage(logoImg,lX,lY,lS,lS);x.globalAlpha=1; ··· 1595 1608 var sec=(performance.now()-bootStart)/1000;var secT=sec.toFixed(2)+'s';x.font='bold '+(4*S)+'px monospace';x.globalAlpha=0.55;x.fillStyle=isLightMode?'#7a5a20':'#ffd870';x.fillText(secT,W-x.measureText(secT).width-5*S,lY+5*S);x.globalAlpha=1; 1596 1609 // Handle (if present) 1597 1610 if(uH){var hAge=(performance.now()-hST)/1000,hFade=Math.min(1,hAge*2);x.font='bold '+(5*S)+'px '+SPRING_FONTS_LIGHT[1];x.globalAlpha=hFade*0.85;x.fillStyle=isLightMode?'#5a3a10':'#ffe6a8';x.fillText(uH,5*S,tBaseY+tFS+6*S);x.globalAlpha=1;} 1598 - // 🐦 MOTD — chars float chaotically among the birds, each with own font/phase 1611 + // 🌈 MOTD — chars float chaotically among the rainbows, each with own font/phase 1599 1612 if(motd){var mAge=(performance.now()-motdStart)/1000;var mFade=Math.min(1,mAge*0.5);var maxW=W*0.85;var motdFS=Math.min(18*S,Math.max(8*S,maxW/Math.max(8,motd.length*0.6)));x.font='bold '+motdFS+'px monospace'; 1600 1613 // Wrap motd into lines using rough char width 1601 1614 var roughChars=Math.max(8,Math.floor(maxW/(motdFS*0.6)));var mLines=wrapMotdText(motd,roughChars);var lineH=motdFS*1.4;var startY=H/2-(mLines.length*lineH)/2;
+5
system/netlify/functions/keep-confirm.mjs
··· 8 8 import { connect } from "../../backend/database.mjs"; 9 9 import { respond } from "../../backend/http.mjs"; 10 10 import { getKeepsContractAddress, LEGACY_KEEPS_CONTRACT } from "../../backend/tezos-keeps-contract.mjs"; 11 + import { mirrorRecordMint } from "../../backend/kidlisp-dual-write.mjs"; 11 12 12 13 const VERSION_BY_PROFILE = { 13 14 v11: "11.0.0", ··· 350 351 console.warn(`❌ Failed to update piece ${cleanPiece}`); 351 352 await database.disconnect(); 352 353 return respond(500, { error: "Failed to record mint" }); 354 + } 355 + 356 + if (resolvedTokenId !== null) { 357 + await mirrorRecordMint(cleanPiece, setOps.kept, { source: "kept" }); 353 358 } 354 359 355 360 console.log(`✅ Recorded keep for $${cleanPiece} - Token #${resolvedTokenId ?? "pending"} on ${normalizedNetwork}`);
+43 -13
system/netlify/functions/keep-mint.mjs
··· 16 16 import { authorize, handleFor, hasAdmin } from "../../backend/authorization.mjs"; 17 17 import { connect } from "../../backend/database.mjs"; 18 18 import { analyzeKidLisp, ANALYZER_VERSION } from "../../backend/kidlisp-analyzer.mjs"; 19 + import { 20 + mirrorIpfsMedia, 21 + mirrorPendingRebake, 22 + mirrorRecordMint, 23 + mirrorTezosState, 24 + } from "../../backend/kidlisp-dual-write.mjs"; 19 25 import { stream } from "@netlify/functions"; 20 26 import { TezosToolkit } from "@taquito/taquito"; 21 27 import { InMemorySigner } from "@taquito/signer"; ··· 837 843 } 838 844 839 845 await collection.updateOne({ code: pieceName }, updateOps); 846 + await mirrorIpfsMedia(pieceName, updateOps.$set.ipfsMedia); 840 847 console.log(`🪙 KEEP: Cached IPFS media for $${pieceName}`); 841 - 848 + 842 849 // REBAKE MODE: Return early with new URIs (don't proceed to minting) 843 850 if (isRebake) { 844 851 // Store pending rebake info so it persists across page refreshes 852 + const rebakePayload = { 853 + artifactUri, 854 + thumbnailUri, 855 + createdAt: new Date(), 856 + sourceHash: pieceSourceHash, 857 + }; 845 858 await collection.updateOne( 846 859 { code: pieceName }, 847 - { 848 - $set: { 849 - pendingRebake: { 850 - artifactUri, 851 - thumbnailUri, 852 - createdAt: new Date(), 853 - sourceHash: pieceSourceHash, 854 - } 855 - } 856 - } 860 + { $set: { pendingRebake: rebakePayload } } 857 861 ); 862 + await mirrorPendingRebake(pieceName, rebakePayload); 858 863 console.log(`🪙 KEEP: Stored pending rebake for $${pieceName}`); 859 864 860 865 const rebakeCreatedAt = new Date().toISOString(); ··· 1104 1109 const piecesCollection = database.db.collection("kidlisp"); 1105 1110 await piecesCollection.updateOne( 1106 1111 { user: user.sub, code: pieceName }, 1107 - { 1108 - $set: { 1112 + { 1113 + $set: { 1109 1114 [`tezos.contracts.${CONTRACT_ADDRESS}`]: { 1110 1115 minted: true, 1111 1116 tokenId: tokenId, ··· 1121 1126 } 1122 1127 } 1123 1128 ); 1129 + 1130 + await mirrorRecordMint( 1131 + pieceName, 1132 + { 1133 + tokenId, 1134 + contractAddress: CONTRACT_ADDRESS, 1135 + network: NETWORK, 1136 + txHash: op.hash, 1137 + keptAt: new Date(), 1138 + keptBy: user.sub, 1139 + walletAddress: destinationAddress, 1140 + artifactUri, 1141 + thumbnailUri, 1142 + metadataUri, 1143 + }, 1144 + { source: "server_mint" }, 1145 + ); 1146 + await mirrorTezosState(pieceName, { 1147 + minted: true, 1148 + exists: true, 1149 + tokenId, 1150 + txHash: op.hash, 1151 + creatorAddress: creatorWalletAddress, 1152 + network: NETWORK, 1153 + }); 1124 1154 1125 1155 await send("complete", { 1126 1156 success: true,
+19 -16
system/netlify/functions/keep-prepare-background.mjs
··· 11 11 import { getKeepsContractAddress, LEGACY_KEEPS_CONTRACT } from "../../backend/tezos-keeps-contract.mjs"; 12 12 import { handleFor } from "../../backend/authorization.mjs"; 13 13 import { 14 + mirrorIpfsMedia, 15 + mirrorPendingRebake, 16 + } from "../../backend/kidlisp-dual-write.mjs"; 17 + import { 14 18 updateJobStage, 15 19 setJobResult, 16 20 markJobReady, ··· 673 677 }; 674 678 } 675 679 await col.updateOne({ code: pieceName }, updateOps); 680 + await mirrorIpfsMedia(pieceName, updateOps.$set.ipfsMedia); 676 681 } 677 682 678 683 // ── Rebake early exit ────────────────────────────────────────────── 679 684 if (isRebake) { 685 + const rebakePayload = { 686 + artifactUri, 687 + thumbnailUri, 688 + metadataUri: null, 689 + createdAt: new Date(), 690 + sourceHash: pieceSourceHash, 691 + network: NETWORK, 692 + contractAddress: CONTRACT_ADDRESS, 693 + contractProfile: contractProfile || null, 694 + contractVersion: contractVersion || null, 695 + packDate: packDate || null, 696 + }; 680 697 await col.updateOne( 681 698 { code: pieceName }, 682 - { 683 - $set: { 684 - pendingRebake: { 685 - artifactUri, 686 - thumbnailUri, 687 - metadataUri: null, 688 - createdAt: new Date(), 689 - sourceHash: pieceSourceHash, 690 - network: NETWORK, 691 - contractAddress: CONTRACT_ADDRESS, 692 - contractProfile: contractProfile || null, 693 - contractVersion: contractVersion || null, 694 - packDate: packDate || null, 695 - }, 696 - }, 697 - } 699 + { $set: { pendingRebake: rebakePayload } } 698 700 ); 701 + await mirrorPendingRebake(pieceName, rebakePayload); 699 702 const mintStatus = await checkMintStatus(pieceName, CONTRACT_ADDRESS); 700 703 await markJobReady(jobId, { 701 704 rebake: true, piece: pieceName, artifactUri, thumbnailUri,
+118 -99
system/netlify/functions/keep-update-confirm.mjs
··· 1 - // keep-update-confirm.mjs - Confirm a client-side metadata update and update MongoDB 2 - // 3 - // POST /api/keep-update-confirm - Record a successful client-side wallet metadata update 4 - // This is called after the user signs the edit_metadata transaction with their wallet 5 - // to update the kidlisp record with the new URIs. 6 - 1 + // keep-update-confirm.mjs - Confirm a client-side metadata update and update MongoDB 2 + // 3 + // POST /api/keep-update-confirm - Record a successful client-side wallet metadata update 4 + // This is called after the user signs the edit_metadata transaction with their wallet 5 + // to update the kidlisp record with the new URIs. 6 + 7 7 import { authorize, hasAdmin } from "../../backend/authorization.mjs"; 8 8 import { connect } from "../../backend/database.mjs"; 9 9 import { respond } from "../../backend/http.mjs"; 10 10 import { getKeepsContractAddress, LEGACY_KEEPS_CONTRACT } from "../../backend/tezos-keeps-contract.mjs"; 11 + import { mirrorRecordMint } from "../../backend/kidlisp-dual-write.mjs"; 11 12 12 13 // Configuration 13 14 const NETWORK = process.env.TEZOS_NETWORK || "mainnet"; ··· 116 117 117 118 return { contractProfile, contractVersion }; 118 119 } 119 - 120 - export async function handler(event, context) { 121 - if (event.httpMethod === "OPTIONS") { 122 - return respond(204, ""); 123 - } 124 - 125 - if (event.httpMethod !== "POST") { 126 - return respond(405, { error: "Method not allowed" }); 127 - } 128 - 129 - let database; 130 - try { 131 - database = await connect(); 132 - } catch (connectError) { 133 - console.error("❌ MongoDB connection failed:", connectError.message); 134 - return respond(503, { error: "Database temporarily unavailable" }); 135 - } 136 - 137 - try { 138 - // Verify user is authenticated 139 - const user = await authorize(event.headers); 140 - if (!user) { 141 - await database.disconnect(); 142 - return respond(401, { error: "Authentication required" }); 143 - } 144 - 145 - // Parse body 146 - const body = JSON.parse(event.body || "{}"); 147 - const { 148 - piece, 149 - tokenId, 150 - txHash, 120 + 121 + export async function handler(event, context) { 122 + if (event.httpMethod === "OPTIONS") { 123 + return respond(204, ""); 124 + } 125 + 126 + if (event.httpMethod !== "POST") { 127 + return respond(405, { error: "Method not allowed" }); 128 + } 129 + 130 + let database; 131 + try { 132 + database = await connect(); 133 + } catch (connectError) { 134 + console.error("❌ MongoDB connection failed:", connectError.message); 135 + return respond(503, { error: "Database temporarily unavailable" }); 136 + } 137 + 138 + try { 139 + // Verify user is authenticated 140 + const user = await authorize(event.headers); 141 + if (!user) { 142 + await database.disconnect(); 143 + return respond(401, { error: "Authentication required" }); 144 + } 145 + 146 + // Parse body 147 + const body = JSON.parse(event.body || "{}"); 148 + const { 149 + piece, 150 + tokenId, 151 + txHash, 151 152 artifactUri, 152 153 thumbnailUri, 153 154 metadataUri, ··· 155 156 contractProfile, 156 157 contractVersion, 157 158 } = body; 158 - 159 - if (!piece || !txHash) { 160 - await database.disconnect(); 161 - return respond(400, { error: "Missing piece or txHash" }); 162 - } 163 - 164 - // Clean piece name (remove $ prefix if present) 159 + 160 + if (!piece || !txHash) { 161 + await database.disconnect(); 162 + return respond(400, { error: "Missing piece or txHash" }); 163 + } 164 + 165 + // Clean piece name (remove $ prefix if present) 165 166 const cleanPiece = piece.replace(/^\$/, ""); 166 167 const defaultContract = await getKeepsContractAddress({ 167 168 db: database.db, ··· 177 178 requestedVersion: contractVersion, 178 179 }); 179 180 const normalizedTokenId = normalizeTokenId(tokenId); 180 - 181 - // Find the kidlisp record 182 - const collection = database.db.collection("kidlisp"); 183 - const record = await collection.findOne({ code: cleanPiece }); 184 - 185 - if (!record) { 186 - await database.disconnect(); 187 - return respond(404, { error: `Piece '$${cleanPiece}' not found` }); 188 - } 189 - 190 - // Verify ownership (user must own the piece or be admin) 191 - const isAdmin = await hasAdmin(user); 192 - if (!isAdmin && record.user && record.user !== user.sub) { 193 - console.warn(`❌ User ${user.sub} tried to confirm update for piece owned by ${record.user}`); 194 - await database.disconnect(); 195 - return respond(403, { error: "Not authorized to confirm this update" }); 196 - } 197 - 198 - // Update the record - move pending URIs to actual URIs and record the update 199 - const updateResult = await collection.updateOne( 200 - { code: cleanPiece }, 201 - { 202 - $set: { 203 - // Update contract-specific data 204 - [`tezos.contracts.${effectiveContract}.artifactUri`]: artifactUri, 205 - [`tezos.contracts.${effectiveContract}.thumbnailUri`]: thumbnailUri, 181 + 182 + // Find the kidlisp record 183 + const collection = database.db.collection("kidlisp"); 184 + const record = await collection.findOne({ code: cleanPiece }); 185 + 186 + if (!record) { 187 + await database.disconnect(); 188 + return respond(404, { error: `Piece '$${cleanPiece}' not found` }); 189 + } 190 + 191 + // Verify ownership (user must own the piece or be admin) 192 + const isAdmin = await hasAdmin(user); 193 + if (!isAdmin && record.user && record.user !== user.sub) { 194 + console.warn(`❌ User ${user.sub} tried to confirm update for piece owned by ${record.user}`); 195 + await database.disconnect(); 196 + return respond(403, { error: "Not authorized to confirm this update" }); 197 + } 198 + 199 + // Update the record - move pending URIs to actual URIs and record the update 200 + const updateResult = await collection.updateOne( 201 + { code: cleanPiece }, 202 + { 203 + $set: { 204 + // Update contract-specific data 205 + [`tezos.contracts.${effectiveContract}.artifactUri`]: artifactUri, 206 + [`tezos.contracts.${effectiveContract}.thumbnailUri`]: thumbnailUri, 206 207 [`tezos.contracts.${effectiveContract}.metadataUri`]: metadataUri, 207 208 [`tezos.contracts.${effectiveContract}.lastUpdatedAt`]: new Date(), 208 209 [`tezos.contracts.${effectiveContract}.lastUpdateTxHash`]: txHash, ··· 226 227 }, 227 228 $unset: { 228 229 // Clear pending state 229 - pendingRebake: "", 230 - [`tezos.contracts.${effectiveContract}.pendingMetadataUri`]: "", 231 - [`tezos.contracts.${effectiveContract}.pendingArtifactUri`]: "", 232 - [`tezos.contracts.${effectiveContract}.pendingThumbnailUri`]: "", 233 - } 234 - } 235 - ); 236 - 237 - if (updateResult.modifiedCount === 0 && updateResult.matchedCount === 0) { 238 - console.warn(`❌ Failed to update piece ${cleanPiece}`); 239 - await database.disconnect(); 240 - return respond(500, { error: "Failed to record update" }); 241 - } 242 - 243 - console.log(`✅ Confirmed metadata update for $${cleanPiece} (token #${tokenId || "?"}): ${txHash}`); 244 - 245 - await database.disconnect(); 246 - return respond(200, { 247 - success: true, 248 - piece: cleanPiece, 249 - tokenId, 230 + pendingRebake: "", 231 + [`tezos.contracts.${effectiveContract}.pendingMetadataUri`]: "", 232 + [`tezos.contracts.${effectiveContract}.pendingArtifactUri`]: "", 233 + [`tezos.contracts.${effectiveContract}.pendingThumbnailUri`]: "", 234 + } 235 + } 236 + ); 237 + 238 + if (updateResult.modifiedCount === 0 && updateResult.matchedCount === 0) { 239 + console.warn(`❌ Failed to update piece ${cleanPiece}`); 240 + await database.disconnect(); 241 + return respond(500, { error: "Failed to record update" }); 242 + } 243 + 244 + const resolvedTokenIdForMirror = normalizedTokenId ?? normalizeTokenId(record?.kept?.tokenId); 245 + if (resolvedTokenIdForMirror !== null) { 246 + await mirrorRecordMint(cleanPiece, { 247 + tokenId: resolvedTokenIdForMirror, 248 + contractAddress: effectiveContract, 249 + network: NETWORK, 250 + txHash: txHash || record?.kept?.txHash || null, 251 + contractProfile: contractIdentity.contractProfile || null, 252 + contractVersion: contractIdentity.contractVersion || null, 253 + artifactUri: artifactUri || null, 254 + thumbnailUri: thumbnailUri || null, 255 + metadataUri: metadataUri || null, 256 + keptAt: new Date(), 257 + keptBy: record?.kept?.keptBy || user.sub, 258 + walletAddress: record?.kept?.walletAddress || null, 259 + }, { source: "update" }); 260 + } 261 + 262 + console.log(`✅ Confirmed metadata update for $${cleanPiece} (token #${tokenId || "?"}): ${txHash}`); 263 + 264 + await database.disconnect(); 265 + return respond(200, { 266 + success: true, 267 + piece: cleanPiece, 268 + tokenId, 250 269 txHash, 251 270 artifactUri, 252 271 thumbnailUri, ··· 254 273 contractProfile: contractIdentity.contractProfile || null, 255 274 contractVersion: contractIdentity.contractVersion || null, 256 275 }); 257 - 258 - } catch (err) { 259 - console.error("❌ keep-update-confirm error:", err); 260 - if (database) await database.disconnect(); 261 - return respond(500, { error: err.message }); 262 - } 263 - } 276 + 277 + } catch (err) { 278 + console.error("❌ keep-update-confirm error:", err); 279 + if (database) await database.disconnect(); 280 + return respond(500, { error: err.message }); 281 + } 282 + }
+18 -2
system/netlify/functions/keep-update.mjs
··· 9 9 import { authorize, hasAdmin } from "../../backend/authorization.mjs"; 10 10 import { connect } from "../../backend/database.mjs"; 11 11 import { getKeepsContractAddress, LEGACY_KEEPS_CONTRACT } from "../../backend/tezos-keeps-contract.mjs"; 12 + import { mirrorRecordMint } from "../../backend/kidlisp-dual-write.mjs"; 12 13 import { stream } from "@netlify/functions"; 13 14 import { TezosToolkit, MichelsonMap } from "@taquito/taquito"; 14 15 import { InMemorySigner } from "@taquito/signer"; ··· 461 462 // Use contract-keyed storage: tezos.contracts[CONTRACT_ADDRESS] 462 463 await collection.updateOne( 463 464 { code: pieceName }, 464 - { 465 - $set: { 465 + { 466 + $set: { 466 467 [`tezos.contracts.${CONTRACT_ADDRESS}.artifactUri`]: artifactUri, 467 468 [`tezos.contracts.${CONTRACT_ADDRESS}.thumbnailUri`]: thumbnailUri, 468 469 [`tezos.contracts.${CONTRACT_ADDRESS}.metadataUri`]: newMetadataUri, ··· 471 472 }, 472 473 $unset: { pendingRebake: "" } 473 474 } 475 + ); 476 + 477 + await mirrorRecordMint( 478 + pieceName, 479 + { 480 + tokenId: parseInt(tokenId, 10), 481 + contractAddress: CONTRACT_ADDRESS, 482 + network: NETWORK, 483 + txHash: op.hash, 484 + artifactUri, 485 + thumbnailUri, 486 + metadataUri: newMetadataUri, 487 + keptAt: new Date(), 488 + }, 489 + { source: "update_server" }, 474 490 ); 475 491 476 492 await send("progress", { stage: "database", message: "✓ Database updated" });
+27 -20
system/netlify/functions/store-kidlisp-datomic.mjs
··· 42 42 43 43 function selectPrimaryKeep(keeps, preferredContract) { 44 44 if (!Array.isArray(keeps) || keeps.length === 0) return null; 45 - if (!preferredContract) return keeps[0]; 46 - const pref = keeps.find( 47 - (k) => 48 - (k.contractAddress || "").toLowerCase() === 49 - preferredContract.toLowerCase() 45 + const byRecency = [...keeps].sort((a, b) => { 46 + const at = a?.keptAt ? new Date(a.keptAt).getTime() : 0; 47 + const bt = b?.keptAt ? new Date(b.keptAt).getTime() : 0; 48 + return bt - at; 49 + }); 50 + if (!preferredContract) return byRecency[0]; 51 + const want = preferredContract.toLowerCase(); 52 + const pref = byRecency.find( 53 + (k) => (k.contractAddress || "").toLowerCase() === want 50 54 ); 51 - return pref || keeps[0]; 55 + return pref || byRecency[0]; 52 56 } 53 57 54 58 function filterKeeps(keeps, { contract, contractProfile, contractVersion }) { ··· 339 343 340 344 // ── recent ── 341 345 if (recent) { 342 - const limit = Math.min(parseInt(q.limit) || 50, 100000); 346 + const clientLimit = Math.min(parseInt(q.limit) || 50, 100000); 343 347 const sort = q.sort || "recent"; 344 348 const since = q.since; 345 349 const filterHandle = q.handle; 346 350 347 - const res = await sidecar.listCodes({ limit, sort, since }); 351 + // When the caller narrows by handle, pull the full corpus from 352 + // the sidecar before the JS-side filter. Otherwise a 2000-row 353 + // "recent" window drops all of the user's older pieces (e.g. 354 + // @jeffrey has 700+ pieces scattered through ~17K docs by date). 355 + const sidecarLimit = filterHandle ? 100000 : clientLimit; 356 + const res = await sidecar.listCodes({ limit: sidecarLimit, sort, since }); 348 357 const pieces = res.recent || []; 349 358 350 359 const handles = await handlesBySub(database, pieces.map((p) => p.user)); 351 360 352 361 const shaped = pieces 353 362 .map((p) => { 354 - const handle = p.user ? handles.get(p.user) : null; 363 + const handle = p.user ? handles.get(p.user) || null : null; 364 + const shape = toMongoShape(p, { 365 + handle, 366 + contract: requestedContract, 367 + contractProfile: requestedContractProfile, 368 + contractVersion: requestedContractVersion, 369 + }) || {}; 355 370 return { 371 + ...shape, 356 372 code: p.code, 357 - source: p.source, 358 373 preview: 359 374 p.source && p.source.length > 40 360 375 ? p.source.substring(0, 37) + "..." 361 376 : p.source, 362 - when: p.when, 363 - hits: p.hits, 364 - user: p.user || null, 365 - handle: handle || null, 366 - ...(toMongoShape(p, { 367 - contract: requestedContract, 368 - contractProfile: requestedContractProfile, 369 - contractVersion: requestedContractVersion, 370 - }) || {}), 371 377 }; 372 378 }) 373 379 .filter((p) => { ··· 378 384 return p.handle === want; 379 385 }); 380 386 387 + const trimmed = filterHandle ? shaped.slice(0, clientLimit) : shaped; 381 388 await database.disconnect(); 382 389 return respond( 383 390 200, 384 - { recent: shaped, count: shaped.length, limit }, 391 + { recent: trimmed, count: trimmed.length, limit: clientLimit }, 385 392 NO_CACHE_HEADERS 386 393 ); 387 394 }
+114
system/public/aesthetic.computer/bios.mjs
··· 4509 4509 const hasDawParam = new URLSearchParams(window.location.search).has("daw"); 4510 4510 if (hasDawParam) { 4511 4511 _dawConnectSend(send, updateMetronome); 4512 + 4513 + // 🎹 Low-latency keyboard bridge for M4L devices. 4514 + // jweb~ captures keyboard focus, which means Max's own [key]/[keyup] 4515 + // objects never see keystrokes. Capture them here on the main thread and 4516 + // forward directly via window.max.outlet — sub-ms, no worker round-trip, 4517 + // no iframe focus fight. 4518 + // 4519 + // BIOS owns the notepat key→pitch layout and octave state so we can emit 4520 + // a finished MIDI pitch and keep the Max patcher trivial. The piece UI 4521 + // also listens to the same keystrokes to render matching visual feedback. 4522 + const _dawKeyOffsets = { 4523 + z: -2, x: -1, 4524 + c: 0, v: 1, d: 2, s: 3, e: 4, f: 5, w: 6, 4525 + g: 7, r: 8, a: 9, q: 10, b: 11, 4526 + h: 12, t: 13, i: 14, y: 15, j: 16, k: 17, u: 18, 4527 + l: 19, o: 20, m: 21, p: 22, n: 23, 4528 + ";": 24, "'": 25, "]": 26, 4529 + }; 4530 + let _dawBaseOctave = 4; 4531 + const _dawHeldPitch = {}; // keyLower → emitted pitch (for correct note-off across octave shifts) 4532 + 4533 + // Round-trip latency probe. BIOS emits a "ping" with a monotonic 4534 + // timestamp alongside each notedown; the Max patcher echoes it back 4535 + // via `script window.acMaxPong(...)` and we log the delta. This captures 4536 + // the full iframe → Max → iframe hop — the real pipeline cost. 4537 + const _dawRttSamples = []; // rolling buffer of recent RTTs (ms) 4538 + window.acMaxPong = function (t0) { 4539 + if (typeof t0 !== "number" || !Number.isFinite(t0)) return; 4540 + const rtt = performance.now() - t0; 4541 + _dawRttSamples.push(rtt); 4542 + if (_dawRttSamples.length > 20) _dawRttSamples.shift(); 4543 + const avg = 4544 + _dawRttSamples.reduce((s, v) => s + v, 0) / _dawRttSamples.length; 4545 + console.log( 4546 + `🎹 rtt ${rtt.toFixed(2)}ms (avg of ${_dawRttSamples.length}: ${avg.toFixed(2)}ms)`, 4547 + ); 4548 + }; 4549 + 4550 + function _dawEmitMax(sym, value) { 4551 + if ( 4552 + typeof window !== "undefined" && 4553 + window.max && 4554 + typeof window.max.outlet === "function" 4555 + ) { 4556 + try { window.max.outlet(sym, value); } catch (_err) {} 4557 + } 4558 + } 4559 + function _dawComputePitch(k) { 4560 + const off = _dawKeyOffsets[k]; 4561 + if (off === undefined) return null; 4562 + return (_dawBaseOctave + 1) * 12 + off; // baseOctave 4 → C = 60 4563 + } 4564 + 4565 + window.addEventListener("keydown", (e) => { 4566 + if (e.repeat) return; 4567 + const k = typeof e.key === "string" ? e.key : ""; 4568 + if (k.length !== 1) return; 4569 + // Octave hot-switch 1-9 4570 + if (k >= "1" && k <= "9") { 4571 + _dawBaseOctave = parseInt(k, 10); 4572 + _dawEmitMax("octave", _dawBaseOctave); 4573 + return; 4574 + } 4575 + const low = k.toLowerCase(); 4576 + const pitch = _dawComputePitch(low); 4577 + if (pitch === null) return; 4578 + _dawHeldPitch[low] = pitch; 4579 + _dawEmitMax("notedown", pitch); 4580 + // RTT ping: use Math.round so the int fits %ld in Max's [sprintf] 4581 + // round-trip path. Delta is logged in window.acMaxPong above. 4582 + _dawEmitMax("ping", Math.round(performance.now())); 4583 + }, true); 4584 + 4585 + window.addEventListener("keyup", (e) => { 4586 + const k = typeof e.key === "string" ? e.key : ""; 4587 + if (k.length !== 1) return; 4588 + const low = k.toLowerCase(); 4589 + const pitch = _dawHeldPitch[low]; 4590 + if (pitch === undefined) return; 4591 + delete _dawHeldPitch[low]; 4592 + _dawEmitMax("noteup", pitch); 4593 + }, true); 4594 + 4595 + // Focus/blur indicator. On blur, release all held pitches so we never 4596 + // leave a note hanging when the iframe loses focus (common during an 4597 + // Ableton window switch). The piece UI uses the same events to show a 4598 + // "tap me!" attract state. 4599 + window.addEventListener("focus", () => { 4600 + _dawEmitMax("focus", 1); 4601 + }, true); 4602 + window.addEventListener("blur", () => { 4603 + for (const k of Object.keys(_dawHeldPitch)) { 4604 + _dawEmitMax("noteup", _dawHeldPitch[k]); 4605 + } 4606 + for (const k of Object.keys(_dawHeldPitch)) delete _dawHeldPitch[k]; 4607 + _dawEmitMax("focus", 0); 4608 + }, true); 4512 4609 } 4513 4610 4514 4611 function requestBeat(time) { ··· 5155 5252 audioContext.resume().then(() => { 5156 5253 console.log("🎹 AudioContext resumed for DAW mode! State:", audioContext.state); 5157 5254 }).catch(e => console.warn("🎹 AudioContext resume failed:", e)); 5255 + } 5256 + return; 5257 + } 5258 + 5259 + // 🎹 MIDI note emit from a worker piece → Max (jweb~ main-thread bridge). 5260 + // Pieces running inside an M4L device's jweb~ send 5261 + // send({ type: "daw:midi", content: { pitch, velocity, channel? } }) 5262 + // and BIOS forwards to `window.max.outlet` which the patcher routes via 5263 + // [route note channel] → [noteout]. 5264 + if (type === "daw:midi" && content) { 5265 + if (typeof window !== "undefined" && window.max && typeof window.max.outlet === "function") { 5266 + const pitch = Number(content.pitch); 5267 + const velocity = Number(content.velocity); 5268 + const channel = Number.isFinite(Number(content.channel)) ? Number(content.channel) : 0; 5269 + if (!Number.isFinite(pitch) || !Number.isFinite(velocity)) return; 5270 + try { window.max.outlet("channel", channel); } catch (_e) {} 5271 + try { window.max.outlet("note", pitch, velocity); } catch (_e) {} 5158 5272 } 5159 5273 return; 5160 5274 }
+28 -13
system/public/aesthetic.computer/disks/arena.mjs
··· 187 187 } 188 188 const seen = new Set(); 189 189 for (const p of blobs) { 190 - if (p.h === myHandle) { 190 + const isMe = p.h === myHandle; 191 + if (isMe) { 191 192 myServerState = p; 192 193 myServerStateMs = snap.serverMs; 193 194 myServerAckCmdMs = typeof snap.ackCmdMs === "number" ? snap.ackCmdMs : myServerAckCmdMs; 194 - continue; 195 + // While spectating, our "me" entry is being driven by another tab — 196 + // render it as just another remote stick figure so we can watch. 197 + // Otherwise skip (cam-doll is already drawing us locally). 198 + if (!netSpectator) continue; 195 199 } 196 200 seen.add(p.h); 197 201 let o = others[p.h]; ··· 210 214 }); 211 215 while (o.buffer.length > 32) o.buffer.shift(); 212 216 } 217 + // If we just left spectator mode, drop the self entry we had been tracking. 218 + if (!netSpectator && others[myHandle]) delete others[myHandle]; 213 219 // Prune others not in this snap for >2s (graceful drop). 214 220 for (const h of Object.keys(others)) { 215 221 if (seen.has(h)) continue; ··· 1805 1811 if (!phys.onGround) activeShadow = shadowAir; 1806 1812 else if (phys.crouch > 0.5) activeShadow = shadowCrouch; 1807 1813 } 1808 - // Only draw ground-anchored shadow/plumb while on solid ground. 1814 + // Only draw ground-anchored shadow/plumb while on solid ground AND we're 1815 + // playing (spectators are flying around without a body — no shadow). 1809 1816 const onSolidGround = phys?.onGround; 1810 - if (activeShadow && onSolidGround) ink(255, 255, 255).form(activeShadow); 1811 - if (plumbLine && onSolidGround && plumbLine.scale[1] > 0.05) { 1812 - ink(255, 255, 255).form(plumbLine); 1813 - } 1814 - // Feet + arms render regardless of ground state (they fall with you). 1815 - // Dropped entirely in LOW perf mode — wireframes are nice-to-have. 1816 - if (!perfLowMode) { 1817 - if (bodyFeet) ink(255).form(bodyFeet); 1818 - if (bodyArms) ink(255).form(bodyArms); 1817 + if (!netSpectator) { 1818 + if (activeShadow && onSolidGround) ink(255, 255, 255).form(activeShadow); 1819 + if (plumbLine && onSolidGround && plumbLine.scale[1] > 0.05) { 1820 + ink(255, 255, 255).form(plumbLine); 1821 + } 1822 + // Feet + arms render regardless of ground state (they fall with you). 1823 + // Dropped entirely in LOW perf mode — wireframes are nice-to-have. 1824 + if (!perfLowMode) { 1825 + if (bodyFeet) ink(255).form(bodyFeet); 1826 + if (bodyArms) ink(255).form(bodyArms); 1827 + } 1819 1828 } 1820 1829 1821 1830 // 🏟️ Remote players (interpolated from server snapshots, rendered ~100ms behind). ··· 2342 2351 }; 2343 2352 } 2344 2353 2354 + // 🏟️ Lifecycle: tell the server we're leaving so our player record is 2355 + // deleted immediately instead of waiting for the 30s stale sweep. 2356 + function leave() { 2357 + try { netServer?.send("arena:bye", { handle: myHandle }); } catch {} 2358 + } 2359 + 2345 2360 export const system = "fps"; 2346 - export { boot, sim, paint, act }; 2361 + export { boot, sim, paint, act, leave };
+270 -466
system/public/aesthetic.computer/disks/commits.mjs
··· 1 1 // commits, 2025.1.14 2 - // Live GitHub commit feed for aesthetic-computer 3 - // ╔═══════════════════════════════════════════════════════════╗ 4 - // ║ A typographically ornate commit history visualization ║ 5 - // ╚═══════════════════════════════════════════════════════════╝ 2 + // Live Tangled commit feed for aesthetic.computer/core. 3 + // Source: https://tangled.org/aesthetic.computer/core (via /api/commits) 6 4 7 - const { max, min, floor, ceil, abs, sin, cos, PI } = Math; 5 + const { max, min, floor, sin } = Math; 8 6 9 - const REPO = "whistlegraph/aesthetic-computer"; 7 + const TANGLED_REPO_URL = "https://tangled.org/aesthetic.computer/core"; 10 8 const POLL_INTERVAL = 30000; // 30 seconds 11 - const COMMITS_PER_PAGE = 30; // Fewer per page since we fetch details 9 + const COMMITS_PER_PAGE = 30; 12 10 13 11 let commits = []; 14 12 let scroll = 0; ··· 19 17 let error = null; 20 18 let lastFetch = 0; 21 19 let pollTimer = null; 22 - let rowHeight = 10; // MatrixChunky8 is 8px + 2px spacing for elegance 23 - let topMargin = 18; // Below HUD label 24 - let bottomMargin = 20; // Footer area 20 + let rowHeight = 10; 21 + let topMargin = 18; 22 + let bottomMargin = 22; 25 23 let hue = 0; 26 24 let pulsePhase = 0; 27 25 let needsLayout = true; 28 - let autoScroll = false; // Start paused 29 - let autoScrollDelay = 2000; // 2 second delay before auto-scroll 30 - let loadTime = 0; // When commits first loaded 26 + let autoScroll = false; 27 + let autoScrollDelay = 2000; 28 + let loadTime = 0; 31 29 let autoScrollSpeed = 0.25; 32 30 let currentPage = 1; 33 31 let hasMoreCommits = true; 34 - let showDetailedView = true; // Toggle detailed stats 32 + let showDetailedView = true; 35 33 let frameCount = 0; 36 - let hoveredCommit = null; 37 - let selectedCommit = null; 38 34 39 - // Stats cache for detailed commit info 40 - const statsCache = new Map(); 41 - const statsFetching = new Set(); 35 + // UI buttons (created in boot, painted in paint, handled in act). 36 + let tangledBtn = null; 37 + let detailBtn = null; 38 + let playBtn = null; 39 + let refreshBtn = null; 42 40 43 - // GitHub link box for click detection 44 - let githubLinkBox = null; 45 - 46 - // Visual theming 47 41 const FONT = "MatrixChunky8"; 48 42 const COLORS = { 49 43 bg: [10, 12, 18], 50 - bgAccent: [16, 18, 26], 51 44 line: [35, 40, 55], 52 - lineAccent: [50, 55, 75], 53 45 sha: [255, 180, 100], 54 46 shaNew: [150, 255, 150], 55 47 author: [180, 150, 255], ··· 64 56 day: [120, 140, 160], 65 57 }; 66 58 67 - // Parse relative time 59 + // Parse relative time. 68 60 function timeAgo(dateStr) { 69 61 const now = new Date(); 70 62 const past = new Date(dateStr); 71 63 const seconds = Math.floor((now - past) / 1000); 72 - 73 64 const units = [ 74 65 { name: "y", seconds: 31536000 }, 75 66 { name: "mo", seconds: 2592000 }, ··· 79 70 { name: "m", seconds: 60 }, 80 71 { name: "s", seconds: 1 }, 81 72 ]; 82 - 83 73 for (const unit of units) { 84 74 const count = Math.floor(seconds / unit.seconds); 85 75 if (count >= 1) return `${count}${unit.name}`; ··· 87 77 return "now"; 88 78 } 89 79 90 - // Format numbers with commas 91 80 function formatNum(n) { 92 81 if (n >= 1000) return (n / 1000).toFixed(1) + "k"; 93 82 return String(n); 94 83 } 95 84 96 - // Generate sparkline bar for additions/deletions 85 + // Extract a handle-like label from git author names. Tangled commits are 86 + // authored as e.g. "prompt.ac/@jeffrey" — we want the `@jeffrey` part. 87 + function formatAuthor(name) { 88 + if (!name) return "@?"; 89 + const atMatch = name.match(/@([A-Za-z0-9._-]+)/); 90 + if (atMatch) return "@" + atMatch[1]; 91 + const first = name.split(/\s+/)[0].toLowerCase(); 92 + return "@" + first; 93 + } 94 + 97 95 function statsBar(add, del, maxWidth = 30) { 98 96 const total = add + del; 99 97 if (total === 0) return { addW: 0, delW: 0 }; 100 - const scale = min(1, total / 200); // Scale to max 200 changes 98 + const scale = min(1, total / 200); 101 99 const w = floor(maxWidth * scale); 102 100 const addW = total > 0 ? max(1, floor((add / total) * w)) : 0; 103 101 const delW = total > 0 ? max(1, floor((del / total) * w)) : 0; 104 102 return { addW, delW }; 105 103 } 106 104 107 - // Bound scroll like chat.mjs does 108 105 function boundScroll() { 109 106 if (scroll < 0) scroll = 0; 110 107 if (scroll > totalScrollHeight - chatHeight + 5) { ··· 112 109 } 113 110 } 114 111 115 - // Fetch detailed stats for a single commit 116 - async function fetchCommitStats(sha) { 117 - if (statsCache.has(sha) || statsFetching.has(sha)) return; 118 - statsFetching.add(sha); 119 - 120 - try { 121 - const response = await fetch( 122 - `https://api.github.com/repos/${REPO}/commits/${sha}` 123 - ); 124 - if (response.ok) { 125 - const data = await response.json(); 126 - statsCache.set(sha, { 127 - additions: data.stats?.additions || 0, 128 - deletions: data.stats?.deletions || 0, 129 - files: data.files?.length || 0, 130 - }); 131 - } 132 - } catch (e) { 133 - console.warn("Failed to fetch commit stats:", sha, e); 134 - } finally { 135 - statsFetching.delete(sha); 136 - } 137 - } 138 - 139 - // Get timeline marker info for a date 140 112 function getTimelineMarker(dateStr, prevDateStr) { 141 113 const d = new Date(dateStr); 142 114 const prev = prevDateStr ? new Date(prevDateStr) : null; 143 - 144 115 const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; 145 116 const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; 146 - 147 - // Check for year change (biggest) 117 + 148 118 if (!prev || d.getFullYear() !== prev.getFullYear()) { 149 119 return { type: "year", label: `◆ ${d.getFullYear()} ◆`, color: COLORS.year }; 150 120 } 151 - 152 - // Check for month change 153 121 if (d.getMonth() !== prev.getMonth()) { 154 122 return { type: "month", label: `── ${months[d.getMonth()]} ──`, color: COLORS.month }; 155 123 } 156 - 157 - // Check for week change (Sunday boundary) 158 124 const weekOfYear = (date) => { 159 125 const start = new Date(date.getFullYear(), 0, 1); 160 126 return Math.ceil(((date - start) / 86400000 + start.getDay() + 1) / 7); ··· 162 128 if (weekOfYear(d) !== weekOfYear(prev)) { 163 129 return { type: "week", label: `· Week ${weekOfYear(d)} ·`, color: COLORS.week }; 164 130 } 165 - 166 - // Check for day change 167 131 if (d.getDate() !== prev.getDate()) { 168 132 return { type: "day", label: `${days[d.getDay()]} ${d.getDate()}`, color: COLORS.day }; 169 133 } 170 - 171 134 return null; 172 135 } 173 136 174 - // Fetch commits from GitHub API 175 137 async function fetchCommits(page = 1, append = false) { 176 138 try { 177 139 if (page === 1) loading = commits.length === 0; 178 140 else loadingMore = true; 179 - 180 - const response = await fetch( 181 - `https://api.github.com/repos/${REPO}/commits?per_page=${COMMITS_PER_PAGE}&page=${page}` 182 - ); 183 - 184 - if (!response.ok) { 185 - throw new Error(`GitHub API error: ${response.status}`); 186 - } 187 - 141 + 142 + const response = await fetch(`/api/commits?page=${page}&per_page=${COMMITS_PER_PAGE}`); 143 + if (!response.ok) throw new Error(`commits API error: ${response.status}`); 188 144 const data = await response.json(); 189 - 190 - // Check if we got fewer commits than requested (end of history) 191 - if (data.length < COMMITS_PER_PAGE) { 192 - hasMoreCommits = false; 193 - } 194 - 195 - const newCommits = data.map(c => ({ 196 - sha: c.sha.slice(0, 7), 145 + if (data.error) throw new Error(data.error); 146 + 147 + hasMoreCommits = !!data.hasMore; 148 + 149 + const newCommits = (data.commits || []).map((c) => ({ 150 + sha: c.shortSha, 197 151 fullSha: c.sha, 198 - message: c.commit.message.split("\n")[0], // First line only 199 - fullMessage: c.commit.message, // Keep full message for expanded view 200 - author: c.commit.author.name, 201 - email: c.commit.author.email, 202 - date: c.commit.author.date, 203 - avatar: c.author?.avatar_url, 204 - parents: c.parents?.length || 0, // For merge detection 152 + message: (c.message || "").split("\n")[0], 153 + fullMessage: c.message || "", 154 + author: c.author, 155 + email: c.email, 156 + date: c.date, 157 + parents: c.parents || 0, 158 + additions: c.additions || 0, 159 + deletions: c.deletions || 0, 160 + files: c.files || 0, 205 161 })); 206 - 162 + 207 163 if (append) { 208 - // Filter out duplicates 209 - const existingShas = new Set(commits.map(c => c.fullSha)); 210 - const unique = newCommits.filter(c => !existingShas.has(c.fullSha)); 211 - commits = [...commits, ...unique]; 164 + const existing = new Set(commits.map((c) => c.fullSha)); 165 + commits = [...commits, ...newCommits.filter((c) => !existing.has(c.fullSha))]; 212 166 } else { 213 - // Check for new commits at top 214 167 const hadCommits = commits.length > 0; 215 168 const oldFirstSha = commits[0]?.fullSha; 216 169 commits = newCommits; 217 - 218 - // Flash if new commits arrived 219 170 if (hadCommits && commits[0]?.fullSha !== oldFirstSha) { 220 - hue = 120; // Flash green for new commit 171 + hue = 120; 221 172 } 222 173 } 223 - 224 - // Fetch stats for visible commits 225 - newCommits.slice(0, 10).forEach(c => fetchCommitStats(c.fullSha)); 226 - 174 + 227 175 lastFetch = Date.now(); 228 176 loading = false; 229 177 loadingMore = false; 230 178 error = null; 231 179 needsLayout = true; 232 180 currentPage = page; 233 - 234 - // Track when commits first loaded for auto-scroll delay 235 - if (page === 1 && !append && loadTime === 0) { 236 - loadTime = Date.now(); 237 - } 181 + 182 + if (page === 1 && !append && loadTime === 0) loadTime = Date.now(); 238 183 } catch (err) { 239 184 error = err.message; 240 185 loading = false; ··· 243 188 } 244 189 } 245 190 246 - // Load more commits when scrolling near bottom 247 191 async function loadMoreIfNeeded() { 248 192 if (loadingMore || !hasMoreCommits) return; 249 - 250 - const scrollNearBottom = scroll > totalScrollHeight - chatHeight - 200; 251 - if (scrollNearBottom) { 193 + if (scroll > totalScrollHeight - chatHeight - 200) { 252 194 await fetchCommits(currentPage + 1, true); 253 195 } 254 196 } 255 197 256 - function boot({ screen, store }) { 257 - // Initial fetch 198 + function buildButtons({ ui, screen }) { 199 + tangledBtn = new ui.TextButton("tangled", { right: 4, top: 4, screen }); 200 + detailBtn = new ui.TextButton(showDetailedView ? "compact" : "detail", { 201 + left: 4, 202 + bottom: 4, 203 + screen, 204 + }); 205 + playBtn = new ui.TextButton(autoScroll ? "pause" : "play", { 206 + left: 4 + detailBtn.width + 4, 207 + bottom: 4, 208 + screen, 209 + }); 210 + refreshBtn = new ui.TextButton("refresh", { right: 4, bottom: 4, screen }); 211 + } 212 + 213 + function syncButtons({ screen }) { 214 + if (!tangledBtn) return; 215 + // Labels track state. 216 + detailBtn.replaceLabel(showDetailedView ? "compact" : "detail"); 217 + playBtn.replaceLabel(autoScroll ? "pause" : "play"); 218 + // Reposition (widths change with label). 219 + tangledBtn.reposition({ right: 4, top: 4, screen }); 220 + refreshBtn.reposition({ right: 4, bottom: 4, screen }); 221 + detailBtn.reposition({ left: 4, bottom: 4, screen }); 222 + playBtn.reposition({ left: 4 + detailBtn.width + 4, bottom: 4, screen }); 223 + } 224 + 225 + // Color schemes: [fill, outline, text] / [hover fill, hover outline, hover text] 226 + const BTN_SCHEME = [[20, 24, 34], [80, 90, 120], [180, 190, 220]]; 227 + const BTN_HOVER = [[30, 35, 50], [140, 160, 210], [220, 230, 255]]; 228 + const LINK_SCHEME = [[0, 0, 0, 0], [40, 60, 110], [130, 170, 255]]; 229 + const LINK_HOVER = [[20, 30, 60], [140, 180, 255], [220, 235, 255]]; 230 + const PLAY_SCHEME = [[14, 28, 20], [60, 160, 100], [140, 230, 170]]; 231 + const PLAY_HOVER = [[20, 40, 30], [110, 220, 150], [200, 255, 220]]; 232 + 233 + function boot({ ui, screen }) { 258 234 fetchCommits(); 259 - 260 - // Start polling for new commits 261 235 pollTimer = setInterval(() => fetchCommits(1, false), POLL_INTERVAL); 262 - 263 - // Always start at top with fresh state 264 236 scroll = 0; 265 237 autoScroll = false; 266 238 loadTime = 0; 267 239 frameCount = 0; 268 - } 269 - 270 - // Draw decorative elements 271 - function drawDecor(ink, line, box, w, h, phase) { 272 - // Subtle corner decorations 273 - const cornerSize = 6; 274 - const c = [40, 45, 60, 150 + sin(phase) * 30]; 275 - 276 - // Top-left corner 277 - ink(...c).line(0, cornerSize, 0, 0); 278 - ink(...c).line(0, 0, cornerSize, 0); 279 - 280 - // Top-right corner 281 - ink(...c).line(w - cornerSize, 0, w - 1, 0); 282 - ink(...c).line(w - 1, 0, w - 1, cornerSize); 283 - 284 - // Bottom-left corner 285 - ink(...c).line(0, h - cornerSize, 0, h - 1); 286 - ink(...c).line(0, h - 1, cornerSize, h - 1); 287 - 288 - // Bottom-right corner 289 - ink(...c).line(w - cornerSize, h - 1, w - 1, h - 1); 290 - ink(...c).line(w - 1, h - cornerSize, w - 1, h - 1); 291 - } 292 - 293 - // Render stats bars with smooth animation 294 - function drawStats(ink, box, x, y, stats, w) { 295 - if (!stats) return y; 296 - 297 - const { addW, delW } = statsBar(stats.additions, stats.deletions, min(w - 60, 40)); 298 - const barY = y; 299 - const barH = 4; 300 - 301 - // Stats bar background 302 - ink(25, 28, 35).box(x, barY, addW + delW + 2, barH); 303 - 304 - // Additions bar (green) 305 - if (addW > 0) { 306 - ink(...COLORS.additions).box(x, barY, addW, barH); 307 - } 308 - 309 - // Deletions bar (red) 310 - if (delW > 0) { 311 - ink(...COLORS.deletions).box(x + addW, barY, delW, barH); 312 - } 313 - 314 - return barY + barH + 2; 240 + buildButtons({ ui, screen }); 241 + topMargin = 4 + (tangledBtn?.height || 18) + 4; 242 + bottomMargin = 4 + (detailBtn?.height || 18) + 4; 315 243 } 316 244 317 - function paint({ wipe, ink, screen, line, text, box, typeface, num, needsPaint, mask, unmask }) { 245 + function paint($) { 246 + const { wipe, ink, screen, line, text, box, needsPaint, mask, unmask, ui } = $; 318 247 const { width: w, height: h } = screen; 319 248 frameCount++; 320 249 pulsePhase += 0.05; 321 - 322 - // Rich dark background with subtle gradient simulation 250 + 323 251 const bgPulse = sin(pulsePhase * 0.3) * 2; 324 252 wipe(COLORS.bg[0] + bgPulse, COLORS.bg[1] + bgPulse, COLORS.bg[2] + bgPulse); 325 - 326 - // Draw decorative corner elements 327 - drawDecor(ink, line, box, w, h, pulsePhase); 328 253 329 - // Header: GitHub link right only (HUD handles piece name) 330 - const headerY = 8; 331 - const ghText = "GitHub →"; 332 - const ghTextW = text.width(ghText, FONT); 333 - const ghX = w - ghTextW - 4; 334 - const ghGlow = 150 + sin(pulsePhase * 1.5) * 40; 335 - ink(100, 140, 255, ghGlow).write(ghText, { x: ghX, y: headerY }, false, undefined, false, FONT); 336 - githubLinkBox = { x: ghX - 2, y: headerY - 2, w: ghTextW + 4, h: 12 }; 254 + // Ensure buttons exist (in case of hot-reload) and keep them in sync. 255 + if (!tangledBtn) buildButtons({ ui, screen }); 256 + syncButtons({ screen }); 257 + topMargin = 4 + tangledBtn.height + 4; 258 + bottomMargin = 4 + detailBtn.height + 4; 337 259 338 260 // Top divider line with gradient effect 339 261 const topLineY = topMargin - 1; ··· 341 263 const alpha = 40 + sin(i * 0.02 + pulsePhase) * 15; 342 264 ink(50, 55, 75, alpha).box(i, topLineY, 1, 1); 343 265 } 344 - 266 + 345 267 // Bottom divider line 346 268 const botLineY = h - bottomMargin; 347 269 for (let i = 0; i < w; i++) { 348 270 const alpha = 40 + sin(i * 0.02 - pulsePhase) * 15; 349 271 ink(50, 55, 75, alpha).box(i, botLineY, 1, 1); 350 272 } 351 - 273 + 352 274 if (loading && commits.length === 0) { 353 - // Animated loading indicator 354 275 const dots = ".".repeat((floor(frameCount / 10) % 4)); 355 - const loadText = `Loading commits${dots}`; 356 - ink(150, 150, 180).write(loadText, { center: "xy", x: w / 2, y: h / 2 }, false, undefined, false, FONT); 276 + ink(150, 150, 180).write( 277 + `Loading commits${dots}`, 278 + { center: "xy", x: w / 2, y: h / 2 }, 279 + false, 280 + undefined, 281 + false, 282 + FONT, 283 + ); 284 + paintButtons($); 357 285 needsPaint(); 358 286 return; 359 287 } 360 - 288 + 361 289 if (error && commits.length === 0) { 362 - ink(255, 100, 100).write("Error: " + error.slice(0, 40), { center: "xy", x: w / 2, y: h / 2 }, false, undefined, false, FONT); 290 + ink(255, 100, 100).write( 291 + "Error: " + error.slice(0, 40), 292 + { center: "xy", x: w / 2, y: h / 2 }, 293 + false, 294 + undefined, 295 + false, 296 + FONT, 297 + ); 298 + paintButtons($); 363 299 return; 364 300 } 365 - 366 - // Calculate heights - expanded view with stats 301 + 367 302 chatHeight = h - topMargin - bottomMargin; 368 - const baseCommitHeight = rowHeight + 2; // Single row per commit 369 - const expandedCommitHeight = rowHeight * 4 + 4; // Space for stats (no file list) 303 + const baseCommitHeight = rowHeight + 2; 304 + const expandedCommitHeight = rowHeight * 4 + 4; 370 305 const commitHeight = showDetailedView ? expandedCommitHeight : baseCommitHeight; 371 - const yearMarkerHeight = 18; // More prominent year markers 306 + const yearMarkerHeight = 18; 372 307 const monthMarkerHeight = 14; 373 308 const smallMarkerHeight = rowHeight + 4; 374 - 375 - // Calculate total height including timeline markers 309 + 376 310 if (needsLayout) { 377 311 let height = 0; 378 312 for (let i = 0; i < commits.length; i++) { ··· 388 322 totalScrollHeight = height; 389 323 needsLayout = false; 390 324 } 391 - 392 - // Mask off the scrollable area 393 - mask({ 394 - x: 0, 395 - y: topMargin, 396 - width: w, 397 - height: chatHeight, 398 - }); 399 - 400 - // Fetch stats for visible commits 401 - let visibleStart = floor(scroll / commitHeight); 402 - let visibleEnd = ceil((scroll + chatHeight) / commitHeight); 403 - for (let i = max(0, visibleStart - 2); i < min(commits.length, visibleEnd + 2); i++) { 404 - const c = commits[i]; 405 - if (c && !statsCache.has(c.fullSha) && !statsFetching.has(c.fullSha)) { 406 - fetchCommitStats(c.fullSha); 407 - } 408 - } 409 - 410 - // Draw commits with timeline markers 325 + 326 + mask({ x: 0, y: topMargin, width: w, height: chatHeight }); 327 + 411 328 let y = topMargin - scroll; 412 - 329 + 413 330 for (let i = 0; i < commits.length; i++) { 414 331 const commit = commits[i]; 415 332 const prevDate = i > 0 ? commits[i - 1].date : null; 416 333 const marker = getTimelineMarker(commit.date, prevDate); 417 - const stats = statsCache.get(commit.fullSha); 418 - 419 - // Timeline marker 334 + 420 335 if (marker) { 421 336 let markerH = smallMarkerHeight; 422 - 423 - if (marker.type === "year") { 424 - markerH = yearMarkerHeight; 425 - } else if (marker.type === "month") { 426 - markerH = monthMarkerHeight; 427 - } 428 - 337 + if (marker.type === "year") markerH = yearMarkerHeight; 338 + else if (marker.type === "month") markerH = monthMarkerHeight; 339 + 429 340 if (y + markerH >= topMargin - markerH && y < h - bottomMargin + markerH) { 430 - // Background for marker with gradient 431 - const bgAlpha = marker.type === "year" ? 220 : (marker.type === "month" ? 180 : 100); 341 + const bgAlpha = marker.type === "year" ? 220 : marker.type === "month" ? 180 : 100; 432 342 ink(30, 28, 45, bgAlpha).box(0, y, w, markerH); 433 - 434 - // Marker text 435 343 const textY = y + Math.floor((markerH - 8) / 2); 436 - 344 + 437 345 if (marker.type === "year") { 438 - // Year: centered, ornate 439 346 const yearGlow = 180 + sin(pulsePhase * 2) * 40; 440 347 ink(marker.color[0], marker.color[1], marker.color[2], yearGlow).write( 441 - marker.label, { center: "x", x: w / 2, y: textY }, false, undefined, false, FONT 348 + marker.label, 349 + { center: "x", x: w / 2, y: textY }, 350 + false, 351 + undefined, 352 + false, 353 + FONT, 442 354 ); 443 355 } else if (marker.type === "month") { 444 - // Month: left aligned with accent line 445 356 ink(...marker.color).write(marker.label, { x: 4, y: textY }, false, undefined, false, FONT); 446 - // Accent line 447 357 const labelW = text.width(marker.label, FONT) + 8; 448 - ink(marker.color[0], marker.color[1], marker.color[2], 60).line(labelW, y + markerH / 2, w - 4, y + markerH / 2); 358 + ink(marker.color[0], marker.color[1], marker.color[2], 60).line( 359 + labelW, 360 + y + markerH / 2, 361 + w - 4, 362 + y + markerH / 2, 363 + ); 449 364 } else { 450 - // Week/Day: subtle with dot 451 365 ink(marker.color[0], marker.color[1], marker.color[2], 180).write( 452 - marker.label, { x: 4, y: textY }, false, undefined, false, FONT 366 + marker.label, 367 + { x: 4, y: textY }, 368 + false, 369 + undefined, 370 + false, 371 + FONT, 453 372 ); 454 373 } 455 374 } 456 375 y += markerH; 457 376 } 458 - 459 - // Skip if outside visible area (with buffer) 377 + 460 378 if (y + commitHeight < topMargin - 20) { 461 379 y += commitHeight; 462 380 continue; ··· 465 383 y += commitHeight; 466 384 continue; 467 385 } 468 - 469 - // Commit row background (alternating subtle stripes with inner padding) 386 + 470 387 if (showDetailedView) { 471 388 const rowPadding = 3; 472 389 if (i % 2 === 0) { 473 390 ink(18, 20, 28, 100).box(0, y + rowPadding, w, commitHeight - rowPadding * 2); 474 391 } 475 - // Subtle separator line between commits 476 392 ink(40, 45, 60, 40).line(4, y + commitHeight - 1, w - 4, y + commitHeight - 1); 477 - } else { 478 - if (i % 2 === 0) { 479 - ink(18, 20, 28, 60).box(0, y, w, commitHeight); 480 - } 393 + } else if (i % 2 === 0) { 394 + ink(18, 20, 28, 60).box(0, y, w, commitHeight); 481 395 } 482 - 483 - // Row 1: SHA, time, author (with vertical offset for padding) 484 - const contentY = y + (showDetailedView ? 4 : 1); // Top padding within commit block 396 + 397 + const contentY = y + (showDetailedView ? 4 : 1); 485 398 const isNew = i === 0 && hue > 60; 486 399 const shaPulse = isNew ? 200 + sin(pulsePhase * 4) * 55 : 0; 487 400 const shaColor = isNew ? [150 + shaPulse * 0.4, 255, 150 + shaPulse * 0.2] : COLORS.sha; 488 - 489 - // SHA with subtle box 401 + 490 402 ink(30, 32, 42).box(2, contentY, 34, rowHeight); 491 403 ink(...shaColor).write(commit.sha, { x: 4, y: contentY + 1 }, false, undefined, false, FONT); 492 - 493 - // Merge indicator 404 + 494 405 let xOffset = 38; 495 406 if (commit.parents > 1) { 496 407 ink(180, 140, 200).write("⊕", { x: xOffset, y: contentY + 1 }, false, undefined, false, FONT); 497 408 xOffset += 10; 498 409 } 499 - 500 - // Time ago 410 + 501 411 const ago = timeAgo(commit.date); 502 412 ink(...COLORS.time).write(ago, { x: xOffset, y: contentY + 1 }, false, undefined, false, FONT); 503 - xOffset += text.width(ago + " ", FONT); // Extra space 504 - 505 - // Author (truncate to fit) 506 - const author = "@" + commit.author.split(" ")[0].toLowerCase().slice(0, 12); 413 + xOffset += text.width(ago + " ", FONT); 414 + 415 + const author = formatAuthor(commit.author); 507 416 ink(...COLORS.author).write(author, { x: xOffset, y: contentY + 1 }, false, undefined, false, FONT); 508 417 const authorEndX = xOffset + text.width(author, FONT); 509 - 510 418 const charWidth = 4; 511 419 512 420 if (!showDetailedView) { 513 - // Single-row mode: ticker message after author 514 421 const msgX = authorEndX + 6; 515 422 const availableChars = Math.floor((w - msgX - 8) / charWidth); 516 423 const msg = commit.message; 517 - 518 424 if (msg.length <= availableChars) { 519 425 ink(...COLORS.message).write(msg, { x: msgX, y: contentY + 1 }, false, undefined, false, FONT); 520 426 } else { 521 - // Ticker: scroll the message with seamless wrap 522 427 const separator = " · "; 523 428 const fullTicker = msg + separator; 524 429 const tickerLen = fullTicker.length; ··· 531 436 } 532 437 533 438 if (showDetailedView) { 534 - // Row 2: Message (with extra line spacing) 535 - const msgY = contentY + rowHeight + 2; 536 - const maxChars = Math.floor((w - 8) / charWidth); 537 - const msg = commit.message.slice(0, maxChars); 538 - ink(...COLORS.message).write(msg, { x: 4, y: msgY }, false, undefined, false, FONT); 439 + const msgY = contentY + rowHeight + 2; 440 + const maxChars = Math.floor((w - 8) / charWidth); 441 + const msg = commit.message.slice(0, maxChars); 442 + ink(...COLORS.message).write(msg, { x: 4, y: msgY }, false, undefined, false, FONT); 539 443 540 - // Row 3-4: Stats (if detailed view and stats loaded) 541 - { 542 - const statsY = contentY + rowHeight * 2 + 4; // Extra spacing before stats 543 - 544 - if (stats) { 545 - // Stats line: +additions -deletions files 546 - let sx = 4; 547 - 548 - // Additions 549 - const addText = `+${formatNum(stats.additions)}`; 550 - ink(...COLORS.additions).write(addText, { x: sx, y: statsY }, false, undefined, false, FONT); 551 - sx += text.width(addText + " ", FONT); 552 - 553 - // Deletions 554 - const delText = `-${formatNum(stats.deletions)}`; 555 - ink(...COLORS.deletions).write(delText, { x: sx, y: statsY }, false, undefined, false, FONT); 556 - sx += text.width(delText + " ", FONT); 557 - 558 - // Files changed 559 - const filesText = `${stats.files}f`; 560 - ink(...COLORS.files).write(filesText, { x: sx, y: statsY }, false, undefined, false, FONT); 561 - sx += text.width(filesText + " ", FONT); 562 - 563 - // Mini bar chart 564 - const { addW, delW } = statsBar(stats.additions, stats.deletions, 35); 565 - if (addW > 0) { 566 - ink(...COLORS.additions, 180).box(sx, statsY + 2, addW, 4); 567 - } 568 - if (delW > 0) { 569 - ink(...COLORS.deletions, 180).box(sx + addW, statsY + 2, delW, 4); 570 - } 571 - 572 - } else if (statsFetching.has(commit.fullSha)) { 573 - // Loading indicator for stats 574 - const loadDots = ".".repeat((floor(frameCount / 8) % 4)); 575 - ink(80, 80, 100).write(`loading${loadDots}`, { x: 4, y: statsY }, false, undefined, false, FONT); 576 - } else { 577 - // Placeholder 578 - ink(50, 50, 60).write("···", { x: 4, y: statsY }, false, undefined, false, FONT); 579 - } 580 - } 581 - } // end showDetailedView 444 + const statsY = contentY + rowHeight * 2 + 4; 445 + let sx = 4; 446 + 447 + const addText = `+${formatNum(commit.additions)}`; 448 + ink(...COLORS.additions).write(addText, { x: sx, y: statsY }, false, undefined, false, FONT); 449 + sx += text.width(addText + " ", FONT); 450 + 451 + const delText = `-${formatNum(commit.deletions)}`; 452 + ink(...COLORS.deletions).write(delText, { x: sx, y: statsY }, false, undefined, false, FONT); 453 + sx += text.width(delText + " ", FONT); 454 + 455 + const filesText = `${commit.files}f`; 456 + ink(...COLORS.files).write(filesText, { x: sx, y: statsY }, false, undefined, false, FONT); 457 + sx += text.width(filesText + " ", FONT); 458 + 459 + const { addW, delW } = statsBar(commit.additions, commit.deletions, 35); 460 + if (addW > 0) ink(...COLORS.additions, 180).box(sx, statsY + 2, addW, 4); 461 + if (delW > 0) ink(...COLORS.deletions, 180).box(sx + addW, statsY + 2, delW, 4); 582 462 583 - // Subtle separator with gradient (detail view only) 584 - if (showDetailedView) { 585 463 const sepY = y + commitHeight - 1; 586 - for (let sx = 4; sx < w - 4; sx++) { 587 - const alpha = 20 + sin(sx * 0.1) * 10; 588 - ink(35, 38, 50, alpha).box(sx, sepY, 1, 1); 464 + for (let sxp = 4; sxp < w - 4; sxp++) { 465 + const alpha = 20 + sin(sxp * 0.1) * 10; 466 + ink(35, 38, 50, alpha).box(sxp, sepY, 1, 1); 589 467 } 590 468 } 591 - 469 + 592 470 y += commitHeight; 593 471 } 594 - 595 - // Loading more indicator at bottom 472 + 596 473 if (loadingMore) { 597 474 const loadDots = ".".repeat((floor(frameCount / 10) % 4)); 598 - ink(150, 150, 180).write(`Loading more${loadDots}`, { x: 4, y: h - bottomMargin - rowHeight - 4 }, false, undefined, false, FONT); 475 + ink(150, 150, 180).write( 476 + `Loading more${loadDots}`, 477 + { x: 4, y: h - bottomMargin - rowHeight - 4 }, 478 + false, 479 + undefined, 480 + false, 481 + FONT, 482 + ); 599 483 } 600 - 601 - unmask(); // End masking 602 - 603 - // 📜 Scroll bar (ornate version) 484 + 485 + unmask(); 486 + 487 + // 📜 Scroll bar 604 488 if (totalScrollHeight > chatHeight) { 605 - // Track 606 489 ink(25, 28, 35).box(w - 4, topMargin, 3, chatHeight); 607 - 608 - // Decorative track edges 609 490 ink(40, 45, 55).line(w - 5, topMargin, w - 5, topMargin + chatHeight); 610 491 ink(40, 45, 55).line(w - 1, topMargin, w - 1, topMargin + chatHeight); 611 - 612 492 const segHeight = max(8, floor((chatHeight / totalScrollHeight) * chatHeight)); 613 493 const scrollRatio = scroll / max(1, totalScrollHeight - chatHeight); 614 494 const boxY = topMargin + floor(scrollRatio * (chatHeight - segHeight)); 615 - 616 - // Thumb with glow 617 495 const thumbColor = autoScroll ? COLORS.additions : [200, 150, 255]; 618 496 const thumbGlow = 50 + sin(pulsePhase * 2) * 30; 619 497 ink(thumbColor[0], thumbColor[1], thumbColor[2], thumbGlow).box(w - 6, boxY - 1, 7, segHeight + 2); 620 498 ink(...thumbColor).box(w - 4, boxY, 3, segHeight); 621 499 } 622 - 623 - // ═══════════════════════════════════════════════════════════ 624 - // Footer area (ornate status bar) 625 - // ═══════════════════════════════════════════════════════════ 626 - const footerY = h - bottomMargin + 3; 500 + 501 + paintButtons($); 502 + 503 + // Status between buttons (commit count + next-poll countdown). 627 504 const sinceLastFetch = Date.now() - lastFetch; 628 505 const nextPoll = Math.max(0, Math.ceil((POLL_INTERVAL - sinceLastFetch) / 1000)); 629 - 630 - // Auto-scroll delay progress bar 506 + const countText = hasMoreCommits ? `${commits.length}+ · ${nextPoll}s` : `${commits.length} · ${nextPoll}s`; 507 + const countX = Math.floor(w / 2 - text.width(countText, FONT) / 2); 508 + ink(100, 105, 130).write( 509 + countText, 510 + { x: countX, y: h - bottomMargin + 7 }, 511 + false, 512 + undefined, 513 + false, 514 + FONT, 515 + ); 516 + 631 517 const sinceLoad = Date.now() - loadTime; 632 - const delayProgress = loadTime > 0 ? Math.min(1, sinceLoad / autoScrollDelay) : 0; 633 - const waitingToScroll = loadTime > 0 && !autoScroll && delayProgress < 1; 634 - 635 - // Left section: playback state 636 - if (waitingToScroll) { 637 - // Progress bar during delay 638 - const barWidth = 24; 639 - const barX = 4; 640 - ink(30, 32, 40).box(barX, footerY, barWidth, 7); 641 - ink(100, 200, 150).box(barX, footerY, Math.floor(barWidth * delayProgress), 7); 642 - ink(60, 65, 80).box(barX, footerY, barWidth, 7, "outline"); 643 - } else { 644 - // Auto-scroll indicator with icon 645 - const playIcon = autoScroll ? "►" : "║║"; 646 - const playColor = autoScroll ? COLORS.additions : [100, 100, 120]; 647 - ink(...playColor).write(playIcon, { x: 4, y: footerY }, false, undefined, false, FONT); 648 - } 649 - 650 - // Poll countdown with subtle animation 651 - const pollAlpha = 150 + sin(pulsePhase + nextPoll * 0.2) * 50; 652 - ink(80, 85, 105, pollAlpha).write(`${nextPoll}s`, { x: waitingToScroll ? 32 : 20, y: footerY }, false, undefined, false, FONT); 653 - 654 - // Center: view toggle hint 655 - const toggleHint = showDetailedView ? "[d]etail" : "[d]etail"; 656 - const hintX = floor(w / 2) - floor(text.width(toggleHint, FONT) / 2); 657 - ink(60, 65, 80).write(toggleHint, { x: hintX, y: footerY }, false, undefined, false, FONT); 658 - 659 - // Right section: commit count 660 - const countText = hasMoreCommits ? `${commits.length}+` : `${commits.length}`; 661 - const countX = w - text.width(countText, FONT) - 4; 662 - ink(100, 105, 130).write(countText, { x: countX, y: footerY }, false, undefined, false, FONT); 663 - 664 - // Stats cache indicator 665 - const cacheText = `${statsCache.size}★`; 666 - ink(60, 80, 60).write(cacheText, { x: countX - text.width(cacheText + " ", FONT), y: footerY }, false, undefined, false, FONT); 667 - 668 - // Keep painting for animations 669 - if (autoScroll || waitingToScroll || statsFetching.size > 0 || !showDetailedView) needsPaint(); 518 + const waitingToScroll = 519 + loadTime > 0 && !autoScroll && sinceLoad < autoScrollDelay; 520 + 521 + if (autoScroll || waitingToScroll) needsPaint(); 522 + } 523 + 524 + function paintButtons($) { 525 + if (!tangledBtn) return; 526 + tangledBtn.paint($, LINK_SCHEME, LINK_HOVER); 527 + detailBtn.paint($, BTN_SCHEME, BTN_HOVER); 528 + playBtn.paint($, PLAY_SCHEME, PLAY_HOVER); 529 + refreshBtn.paint($, BTN_SCHEME, BTN_HOVER); 670 530 } 671 531 672 - function act({ event: e, screen, store, jump }) { 673 - const { height: h } = screen; 674 - 675 - // 📜 Scrolling - any manual scroll disables auto-scroll 532 + function act({ event: e, screen, jump, ui, net }) { 533 + if (!tangledBtn) buildButtons({ ui, screen }); 534 + 535 + // Buttons. 536 + tangledBtn.act(e, () => { 537 + jump(`out:${TANGLED_REPO_URL}`); 538 + }); 539 + 540 + detailBtn.act(e, () => { 541 + showDetailedView = !showDetailedView; 542 + needsLayout = true; 543 + }); 544 + 545 + playBtn.act(e, () => { 546 + autoScroll = !autoScroll; 547 + if (autoScroll) scroll = 0; 548 + }); 549 + 550 + refreshBtn.act(e, () => { 551 + fetchCommits(1, false); 552 + }); 553 + 554 + // Scroll via drag — disables auto-scroll. 676 555 if (e.is("draw")) { 677 556 autoScroll = false; 678 - scroll -= e.delta.y; // Invert for natural scroll direction 679 - boundScroll(); 680 - loadMoreIfNeeded(); 681 - } 682 - 683 - // Keyboard controls 684 - if (e.is("keyboard:down:arrowup") || e.is("keyboard:down:k")) { 685 - autoScroll = false; 686 - scroll -= rowHeight * 4; 687 - boundScroll(); 688 - } 689 - 690 - if (e.is("keyboard:down:arrowdown") || e.is("keyboard:down:j")) { 691 - autoScroll = false; 692 - scroll += rowHeight * 4; 693 - boundScroll(); 694 - loadMoreIfNeeded(); 695 - } 696 - 697 - if (e.is("keyboard:down:home")) { 698 - autoScroll = false; 699 - scroll = 0; 700 - } 701 - 702 - if (e.is("keyboard:down:end")) { 703 - autoScroll = false; 704 - scroll = max(0, totalScrollHeight - chatHeight + 5); 705 - loadMoreIfNeeded(); 706 - } 707 - 708 - // Page up/down 709 - if (e.is("keyboard:down:pageup")) { 710 - autoScroll = false; 711 - scroll -= chatHeight * 0.8; 712 - boundScroll(); 713 - } 714 - 715 - if (e.is("keyboard:down:pagedown")) { 716 - autoScroll = false; 717 - scroll += chatHeight * 0.8; 557 + scroll -= e.delta.y; 718 558 boundScroll(); 719 559 loadMoreIfNeeded(); 720 560 } 721 - 722 - // Toggle auto-scroll with Space 723 - if (e.is("keyboard:down: ")) { 724 - autoScroll = !autoScroll; 725 - if (autoScroll) scroll = 0; // Reset to top when enabling 726 - } 727 - 728 - // Toggle detailed view with D 729 - if (e.is("keyboard:down:d")) { 730 - showDetailedView = !showDetailedView; 731 - needsLayout = true; 732 - } 733 - 734 - // Refresh on R 735 - if (e.is("keyboard:down:r")) { 736 - statsCache.clear(); 737 - fetchCommits(1, false); 738 - } 739 - 740 - // GitHub link click 741 - if (e.is("touch") && githubLinkBox) { 742 - const { x, y } = e; 743 - if (x >= githubLinkBox.x && x <= githubLinkBox.x + githubLinkBox.w && 744 - y >= githubLinkBox.y && y <= githubLinkBox.y + githubLinkBox.h) { 745 - jump(`out:https://github.com/${REPO}`); 746 - } 561 + 562 + if (e.is("reframed")) { 563 + syncButtons({ screen }); 747 564 } 748 565 749 - // Back to prompt 750 - if (e.is("keyboard:down:escape")) { 751 - jump("prompt"); 752 - } 566 + // Back to prompt (standard AC convention). 567 + if (e.is("keyboard:down:escape") || e.is("keyboard:down:`")) jump("prompt"); 568 + if (e.is("keyboard:down:backspace")) jump("prompt"); 753 569 } 754 570 755 - function sim({ store }) { 756 - // Decay the new-commit flash 571 + function sim() { 757 572 if (hue > 0) hue = Math.max(0, hue - 0.5); 758 - 759 - // Auto-start scrolling after delay 573 + 760 574 const sinceLoad = Date.now() - loadTime; 761 575 if (loadTime > 0 && !autoScroll && sinceLoad >= autoScrollDelay && scroll === 0) { 762 576 autoScroll = true; 763 577 } 764 - 765 - // Auto-scroll 578 + 766 579 if (autoScroll && totalScrollHeight > chatHeight) { 767 580 scroll += autoScrollSpeed; 768 581 boundScroll(); 769 - 770 - // Loop back to top when reaching end 771 - if (scroll >= totalScrollHeight - chatHeight) { 772 - scroll = 0; 773 - } 774 - 582 + if (scroll >= totalScrollHeight - chatHeight) scroll = 0; 775 583 loadMoreIfNeeded(); 776 584 } 777 585 } 778 586 779 587 function leave() { 780 - // Clean up polling 781 588 if (pollTimer) { 782 589 clearInterval(pollTimer); 783 590 pollTimer = null; 784 591 } 785 - // Clear caches 786 - statsCache.clear(); 787 - statsFetching.clear(); 788 592 } 789 593 790 594 function meta() { 791 595 return { 792 596 title: "Commits", 793 - desc: "╔═══════════════════════════════════════╗\n║ Live GitHub commit feed with stats ║\n║ +additions -deletions • files changed ║\n╚═══════════════════════════════════════╝", 597 + desc: "Live Tangled commit feed for aesthetic.computer/core.", 794 598 }; 795 599 } 796 600
+359 -154
system/public/aesthetic.computer/disks/notepat-remote.mjs
··· 1 1 // notepat-remote, 26.4.20 2 - // AC 🎹 Notepat Remote — receives notepat:midi events from session-server 3 - // and bridges them to Max for Live via window.max.outlet. 4 - // 5 - // Meant to load inside jweb~ in AC-NotepatRemote.amxd. When opened standalone 6 - // in a browser it still renders the connection status UI (MIDI out is a no-op). 7 - // 8 - // Wire path: 9 - // ThinkPad ac-native notepat.mjs 10 - // → UDP :10010 → session-server.aesthetic.computer 11 - // → WS fanout → this piece 12 - // → window.max.outlet(["note", pitch, vel]) → Max [route note] → [noteout] 2 + // AC 🎹 Notepat Remote — Max for Live device UI. 3 + // • Local keyboard input is owned by BIOS (bios.mjs dawKeyEmit) — it 4 + // captures keydown/keyup in the jweb iframe and calls window.max.outlet 5 + // with a finished MIDI pitch, routed straight to [noteout] in the 6 + // patcher for sub-ms latency. This piece only mirrors that state for 7 + // visual feedback. 8 + // • Session-server relay path: this piece opens a WebSocket to 9 + // wss://session-server.aesthetic.computer/ and forwards notepat:midi 10 + // events via send({type:"daw:midi",...}) which BIOS turns into 11 + // window.max.outlet("note"/"channel", ...). Same bridge, async path. 12 + // • Button grid under the status header lets you tap notes on touch 13 + // devices — each button fires the same note your keyboard would. 14 + // • When the iframe loses focus, Max's key listener stops receiving, so 15 + // the UI goes red + "TAP ME!" attract mode to prompt a click. 13 16 14 - const { floor, min, max } = Math; 17 + const { floor, min, max, abs, sin, PI } = Math; 15 18 16 19 const WS_URL = "wss://session-server.aesthetic.computer/"; 17 - const RECONNECT_FRAMES = 120; // ~2s @ 60fps 20 + const RECONNECT_FRAMES = 120; 21 + 22 + // Key offsets relative to base-octave C. Mirrors bios.mjs _dawKeyOffsets. 23 + const KEY_OFFSETS = { 24 + z: -2, x: -1, 25 + c: 0, v: 1, d: 2, s: 3, e: 4, f: 5, w: 6, 26 + g: 7, r: 8, a: 9, q: 10, b: 11, 27 + h: 12, t: 13, i: 14, y: 15, j: 16, k: 17, u: 18, 28 + l: 19, o: 20, m: 21, p: 22, n: 23, 29 + ";": 24, "'": 25, "]": 26, 30 + }; 31 + 32 + // Chromatic octave blocks — 4 cols × 3 rows = 12 notes each. 33 + // Matches notepat.mjs pad layout. Row 0 = low (C..D#), row 2 = high (G#..B). 34 + // Two blocks render side-by-side (base octave left, +1 right) when the 35 + // device is wide enough (always 360px in M4L). 36 + const OCTAVE_GRIDS = [ 37 + // Base octave (offsets 0-11 → C..B of baseOctave) 38 + [ 39 + ["c", "v", "d", "s"], 40 + ["e", "f", "w", "g"], 41 + ["r", "a", "q", "b"], 42 + ], 43 + // +1 octave (offsets 12-23 → C..B of baseOctave+1) 44 + [ 45 + ["h", "t", "i", "y"], 46 + ["j", "k", "u", "l"], 47 + ["o", "m", "p", "n"], 48 + ], 49 + ]; 50 + 51 + const PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; 52 + function pitchName(p) { 53 + return PITCH_NAMES[((p % 12) + 12) % 12] + (floor(p / 12) - 1); 54 + } 55 + function isBlackKey(p) { 56 + return [1, 3, 6, 8, 10].includes(((p % 12) + 12) % 12); 57 + } 18 58 19 59 let ws = null; 20 - let wsState = "idle"; // idle | connecting | open | closed | error 60 + let wsState = "idle"; 21 61 let wsError = ""; 22 62 let reconnectAt = 0; 23 63 24 - let sources = []; // [{ handle, machineId, piece, lastSeen }] 25 - let pktCount = 0; 26 - let noteOnCount = 0; 27 - let noteOffCount = 0; 28 - let lastNote = null; // { pitch, vel, chan, event, handle, ts, latencyMs } 64 + let sources = []; 65 + let relayCount = 0; 66 + 67 + let lastNote = null; 29 68 let lastNoteFrame = -9999; 30 69 31 - let frame = 0; 32 - let lastSubscribedCh = -1; 70 + // Local state mirrored from keyboard events (visual only — BIOS emits MIDI). 71 + let baseOctave = 4; 72 + const heldKeys = new Set(); 73 + let focused = true; // assume focused until we learn otherwise 74 + let focusedChangedFrame = 0; 75 + let lastInteractionFrame = -999; 33 76 34 - // Max for Live jweb~ bridge (exposes window.max.outlet) 35 - const maxBridge = 36 - typeof window !== "undefined" && 37 - window.max && 38 - typeof window.max.outlet === "function" 39 - ? window.max 40 - : null; 77 + let _send = null; 78 + let frame = 0; 41 79 42 - function emitMaxNote(pitch, velocity, channel) { 43 - if (!maxBridge) return; 44 - try { 45 - if (channel !== lastSubscribedCh) { 46 - maxBridge.outlet(["channel", channel]); 47 - lastSubscribedCh = channel; 48 - } 49 - maxBridge.outlet(["note", pitch, velocity]); 50 - } catch (_err) {} 51 - } 80 + // Button grid layout (recomputed on each paint in case screen size changes). 81 + let buttons = []; 52 82 53 83 function connectWs() { 54 84 if (typeof WebSocket === "undefined") return; ··· 60 90 ws.onopen = () => { 61 91 wsState = "open"; 62 92 try { 63 - ws.send( 64 - JSON.stringify({ 65 - type: "notepat:midi:subscribe", 66 - content: { all: true }, 67 - }), 68 - ); 93 + ws.send(JSON.stringify({ 94 + type: "notepat:midi:subscribe", 95 + content: { all: true }, 96 + })); 69 97 } catch (_e) {} 70 98 }; 71 99 ws.onmessage = (ev) => { 72 100 let msg; 73 - try { 74 - msg = JSON.parse(ev.data); 75 - } catch { 76 - return; 77 - } 101 + try { msg = JSON.parse(ev.data); } catch { return; } 78 102 if (!msg || !msg.type) return; 79 103 if (msg.type === "notepat:midi") { 80 - handleMidiEvent(msg.content || {}); 104 + handleRelay(msg.content || {}); 81 105 } else if (msg.type === "notepat:midi:sources") { 82 106 const list = (msg.content && msg.content.sources) || []; 83 107 sources = list.map((s) => ({ 84 108 handle: s.handle || "", 85 109 machineId: s.machineId || "", 86 - piece: s.piece || "notepat", 87 - lastSeen: s.lastSeen || 0, 88 110 })); 89 111 } 90 112 }; 91 - ws.onerror = () => { 92 - wsState = "error"; 93 - wsError = "ws error"; 94 - }; 95 - ws.onclose = () => { 96 - wsState = "closed"; 97 - reconnectAt = frame + RECONNECT_FRAMES; 98 - }; 113 + ws.onerror = () => { wsState = "error"; wsError = "ws err"; }; 114 + ws.onclose = () => { wsState = "closed"; reconnectAt = frame + RECONNECT_FRAMES; }; 99 115 } catch (err) { 100 116 wsState = "error"; 101 - wsError = err?.message || "connect failed"; 117 + wsError = err?.message || "connect fail"; 102 118 reconnectAt = frame + RECONNECT_FRAMES; 103 119 } 104 120 } 105 121 106 - function handleMidiEvent(ev) { 122 + function handleRelay(ev) { 123 + if (!_send) return; 107 124 const pitch = Number(ev.note); 108 125 const vel = Number(ev.velocity); 109 126 const chan = Number(ev.channel) || 0; 110 127 if (!Number.isFinite(pitch) || !Number.isFinite(vel)) return; 111 - pktCount += 1; 112 - const now = Date.now(); 113 - const tsNum = Number(ev.ts); 114 - const latency = Number.isFinite(tsNum) ? max(0, now - tsNum) : 0; 128 + relayCount += 1; 115 129 const isOff = ev.event === "note_off" || (ev.event === "note_on" && vel === 0); 116 - if (isOff) { 117 - noteOffCount += 1; 118 - emitMaxNote(pitch, 0, chan); 119 - } else { 120 - noteOnCount += 1; 121 - emitMaxNote(pitch, max(1, min(127, vel)), chan); 122 - } 130 + _send({ 131 + type: "daw:midi", 132 + content: { 133 + pitch, 134 + velocity: isOff ? 0 : max(1, min(127, vel)), 135 + channel: chan, 136 + }, 137 + }); 123 138 lastNote = { 124 139 pitch, 125 140 vel, 126 - chan, 127 - event: ev.event || (isOff ? "note_off" : "note_on"), 141 + source: "relay", 128 142 handle: ev.handle || "", 129 - ts: now, 130 - latencyMs: latency, 143 + ts: Date.now(), 131 144 }; 132 145 lastNoteFrame = frame; 133 146 } 134 147 135 - const PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; 136 - function pitchName(p) { 137 - const n = PITCH_NAMES[((p % 12) + 12) % 12]; 138 - const o = floor(p / 12) - 1; 139 - return n + o; 148 + // Emit a note via the daw:midi pipe (touch/click path — keyboard notes go 149 + // through BIOS directly for speed, not through here). 150 + function tapNote(pitch, on) { 151 + if (!_send) return; 152 + _send({ 153 + type: "daw:midi", 154 + content: { pitch, velocity: on ? 100 : 0, channel: 0 }, 155 + }); 156 + lastNote = { pitch, vel: on ? 100 : 0, source: "tap", handle: "", ts: Date.now() }; 157 + lastNoteFrame = frame; 158 + lastInteractionFrame = frame; 140 159 } 141 160 142 - function boot({ wipe, cursor }) { 143 - wipe(8, 10, 18); 161 + function pitchForKey(key) { 162 + const off = KEY_OFFSETS[key]; 163 + if (off === undefined) return null; 164 + return (baseOctave + 1) * 12 + off; 165 + } 166 + 167 + function boot({ wipe, cursor, hud, send }) { 168 + wipe(10, 12, 22); 144 169 cursor?.("native"); 170 + hud?.label?.(""); 171 + _send = send; 145 172 connectWs(); 146 173 } 147 174 ··· 150 177 if (wsState === "closed" && frame >= reconnectAt) connectWs(); 151 178 } 152 179 153 - function paint({ wipe, ink, box, line, screen }) { 154 - const bg = [8, 10, 18]; 155 - const fg = [220, 225, 255]; 156 - const fgDim = [130, 140, 170]; 157 - const accent = [255, 170, 80]; 158 - const good = [140, 255, 180]; 159 - const bad = [255, 120, 120]; 160 - const warn = [255, 220, 100]; 161 - wipe(...bg); 180 + let tappedButton = null; // button object currently pressed via touch 181 + 182 + function act({ event: e }) { 183 + if (!e?.is) return; 184 + 185 + // Focus/blur signals from AC (if AC forwards them; harmless if it doesn't). 186 + if (e.is("focus")) { 187 + focused = true; 188 + focusedChangedFrame = frame; 189 + return; 190 + } 191 + if (e.is("blur")) { 192 + focused = false; 193 + focusedChangedFrame = frame; 194 + heldKeys.clear(); 195 + return; 196 + } 197 + 198 + // Button grid taps — tapNote for touch, release on lift. 199 + if (e.is("touch")) { 200 + lastInteractionFrame = frame; 201 + const hit = hitButton(e.x, e.y); 202 + if (hit) { 203 + tappedButton = hit; 204 + tapNote(hit.pitch, true); 205 + } 206 + return; 207 + } 208 + if (e.is("lift")) { 209 + if (tappedButton) { 210 + tapNote(tappedButton.pitch, false); 211 + tappedButton = null; 212 + } 213 + return; 214 + } 215 + if (e.is("draw")) { 216 + // Allow drag across buttons (piano-roll tap). Release previous, press new. 217 + const hit = hitButton(e.x, e.y); 218 + if (hit && hit !== tappedButton) { 219 + if (tappedButton) tapNote(tappedButton.pitch, false); 220 + tappedButton = hit; 221 + tapNote(hit.pitch, true); 222 + } 223 + return; 224 + } 225 + 226 + // Octave hot-switch 1-9 (BIOS also tracks this, we mirror for UI). 227 + for (let n = 1; n <= 9; n += 1) { 228 + if (e.is(`keyboard:down:${n}`)) { 229 + baseOctave = n; 230 + focused = true; 231 + lastInteractionFrame = frame; 232 + return; 233 + } 234 + } 235 + 236 + // Track keyboard state for visual feedback only. 237 + for (const key of Object.keys(KEY_OFFSETS)) { 238 + if (e.is(`keyboard:down:${key}`)) { 239 + if (!heldKeys.has(key)) { 240 + heldKeys.add(key); 241 + const p = pitchForKey(key); 242 + if (p !== null) { 243 + lastNote = { pitch: p, vel: 100, source: "kbd", handle: "", ts: Date.now() }; 244 + lastNoteFrame = frame; 245 + } 246 + } 247 + focused = true; 248 + lastInteractionFrame = frame; 249 + return; 250 + } 251 + if (e.is(`keyboard:up:${key}`)) { 252 + heldKeys.delete(key); 253 + return; 254 + } 255 + } 256 + } 257 + 258 + function hitButton(x, y) { 259 + if (typeof x !== "number" || typeof y !== "number") return null; 260 + for (const b of buttons) { 261 + if (x >= b.x && x < b.x + b.w && y >= b.y && y < b.y + b.h) return b; 262 + } 263 + return null; 264 + } 162 265 266 + function paint({ wipe, ink, box, line, screen }) { 163 267 const W = screen.width; 164 268 const H = screen.height; 165 - let y = 4; 166 269 167 - // Header + bridge badge 168 - ink(...accent).write("NOTEPAT-REMOTE", { x: 4, y, size: 1 }); 169 - ink(...(maxBridge ? good : warn)).write( 170 - maxBridge ? "[M4L]" : "[solo]", 171 - { x: 112, y }, 172 - ); 173 - y += 10; 270 + // ── Palette ────────────────────────────────────────────────────────── 271 + // Ableton-tasteful: graphite background, amber/orange accent (Live's 272 + // MIDI color), muted grays. Unfocused = deeply dimmed + angry red blink 273 + // so you can't miss it. 274 + const blinkPhase = (sin(frame * 0.14) + 1) / 2; // 0..1 sinusoidal 275 + const blinkOn = blinkPhase > 0.5; 174 276 175 - // WS status line 176 - const stateColor = 177 - wsState === "open" 178 - ? good 179 - : wsState === "connecting" 180 - ? warn 181 - : wsState === "error" || wsState === "closed" 182 - ? bad 183 - : fgDim; 184 - ink(...fgDim).write("ws", { x: 4, y }); 185 - ink(...stateColor).write(wsState.toUpperCase(), { x: 18, y }); 186 - if (wsError) ink(...bad).write(wsError.slice(0, 24), { x: 60, y }); 187 - y += 8; 277 + // Focused theme (always-on) 278 + const focusedBg = [16, 18, 22]; 279 + const focusedAccent = [255, 156, 60]; // Live orange 280 + const focusedAccentBright = [255, 196, 110]; 281 + const focusedFg = [212, 216, 224]; 282 + const focusedDim = [110, 116, 130]; 283 + const focusedKeyWhite = [38, 42, 50]; 284 + const focusedKeyBlack = [22, 24, 30]; 285 + const focusedOutline = [70, 76, 88]; 188 286 189 - // Sources 190 - ink(...fgDim).write("src", { x: 4, y }); 191 - if (sources.length === 0) { 192 - ink(...fgDim).write("(none — start relay on)", { x: 18, y }); 193 - } else { 194 - const label = sources 195 - .slice(0, 4) 196 - .map((s) => (s.handle ? "@" + s.handle : s.machineId.slice(0, 6))) 197 - .join(" "); 198 - ink(...fg).write(label.slice(0, 36), { x: 18, y }); 287 + // Unfocused theme — dark + red; flashes for attention. 288 + const unfocusedBg = blinkOn ? [48, 12, 12] : [22, 6, 6]; 289 + const unfocusedAccent = blinkOn ? [255, 70, 70] : [180, 45, 45]; 290 + const unfocusedFg = [180, 150, 150]; 291 + const unfocusedDim = [100, 70, 70]; 292 + const unfocusedKeyWhite = [40, 18, 18]; 293 + const unfocusedKeyBlack = [24, 10, 10]; 294 + const unfocusedOutline = [80, 30, 30]; 295 + 296 + const bgBase = focused ? focusedBg : unfocusedBg; 297 + const accent = focused ? focusedAccent : unfocusedAccent; 298 + const accentBright = focused ? focusedAccentBright : unfocusedAccent; 299 + const fg = focused ? focusedFg : unfocusedFg; 300 + const dim = focused ? focusedDim : unfocusedDim; 301 + const keyWhite = focused ? focusedKeyWhite : unfocusedKeyWhite; 302 + const keyBlack = focused ? focusedKeyBlack : unfocusedKeyBlack; 303 + const outline = focused ? focusedOutline : unfocusedOutline; 304 + 305 + wipe(...bgBase); 306 + 307 + const sinceNote = frame - lastNoteFrame; 308 + if (lastNote && sinceNote < 10 && focused) { 309 + const f = 1 - sinceNote / 10; 310 + ink(...accentBright, floor(40 * f)).box(0, 0, W, H, "fill"); 199 311 } 200 - y += 8; 201 312 202 - // Counters 203 - ink(...fgDim).write( 204 - `pkt ${pktCount} on ${noteOnCount} off ${noteOffCount}`, 205 - { x: 4, y }, 206 - ); 313 + // ── Header row: piece name + ws state ───────────────────────────────── 314 + let y = 2; 315 + ink(...accent).write("notepat-remote", { x: 4, y }); 316 + const wsColor = 317 + !focused ? dim : 318 + wsState === "open" ? [120, 220, 140] : 319 + wsState === "connecting" ? [255, 200, 90] : 320 + wsState === "error" || wsState === "closed" ? [255, 100, 100] : dim; 321 + ink(...wsColor).write(wsState, { x: W - wsState.length * 6 - 4, y }); 207 322 y += 10; 208 323 209 - // Last note — flashes accent, fades to fg over ~30 frames 324 + // ── Status row: ACTIVE + octave + last note ────────────────────────── 325 + if (focused) { 326 + ink(...accent).box(4, y + 1, 6, 6, "fill"); 327 + ink(...fg).write("ACTIVE", { x: 14, y }); 328 + } 329 + ink(...dim).write(`oct ${baseOctave}`, { x: 70, y }); 210 330 if (lastNote) { 211 - const age = frame - lastNoteFrame; 212 - const flashing = age < 30; 213 - const color = flashing ? accent : fg; 214 - const arrow = lastNote.vel === 0 || lastNote.event === "note_off" ? "v" : "^"; 215 - const label = `${arrow} ${pitchName(lastNote.pitch)} (${lastNote.pitch}) vel ${lastNote.vel} ch ${lastNote.chan}`; 216 - ink(...color).write(label, { x: 4, y }); 217 - y += 8; 218 - const who = lastNote.handle ? "@" + lastNote.handle : "?"; 219 - ink(...fgDim).write(`${who} ${lastNote.latencyMs}ms`, { x: 4, y }); 220 - y += 10; 221 - } else { 222 - ink(...fgDim).write("(waiting for notes…)", { x: 4, y }); 223 - y += 10; 331 + const noteFresh = sinceNote < 30; 332 + const pn = pitchName(lastNote.pitch); 333 + const noteColor = noteFresh ? accent : dim; 334 + const srcTag = 335 + lastNote.source === "relay" ? "@" + (lastNote.handle || "?") : 336 + lastNote.source === "tap" ? "tap" : "kbd"; 337 + const label = `${lastNote.vel === 0 ? "v" : "^"} ${pn} ${srcTag}`; 338 + ink(...noteColor).write(label, { x: W - label.length * 6 - 4, y }); 224 339 } 340 + y += 10; 225 341 226 - // Indicator strip at bottom — one bar per source, bars flash on note 227 - if (H - y > 14) { 228 - const barY = H - 10; 229 - ink(...fgDim).line(2, barY - 1, W - 2, barY - 1); 230 - ink(...fg).write("hint: enable on ThinkPad: 'midi relay on'", { x: 4, y: H - 8 }); 342 + // ── Button grid area: two 4×3 octave blocks side-by-side ───────────── 343 + const gridTop = y + 2; 344 + const gridBottom = H - 4; 345 + const gap = 6; 346 + const blockW = floor((W - 8 - gap) / 2); 347 + const blockH = gridBottom - gridTop; 348 + const cellW = floor(blockW / 4); 349 + const cellH = floor(blockH / 3); 350 + 351 + buttons = []; 352 + for (let octIdx = 0; octIdx < OCTAVE_GRIDS.length; octIdx += 1) { 353 + const grid = OCTAVE_GRIDS[octIdx]; 354 + const blockX = 4 + octIdx * (blockW + gap); 355 + const octNum = baseOctave + octIdx; 356 + ink(...dim).write(`o${octNum}`, { x: blockX + 1, y: gridTop - 1 }); 357 + 358 + for (let rowIdx = 0; rowIdx < grid.length; rowIdx += 1) { 359 + const row = grid[rowIdx]; 360 + for (let colIdx = 0; colIdx < row.length; colIdx += 1) { 361 + const key = row[colIdx]; 362 + const offset = rowIdx * 4 + colIdx; 363 + const pitch = (baseOctave + 1 + octIdx) * 12 + offset; 364 + const b = { 365 + x: blockX + colIdx * cellW, 366 + y: gridTop + rowIdx * cellH, 367 + w: cellW - 1, 368 + h: cellH - 1, 369 + key, 370 + pitch, 371 + }; 372 + buttons.push(b); 373 + 374 + const held = 375 + heldKeys.has(key) || (tappedButton && tappedButton.key === key); 376 + const recentFlash = 377 + lastNote && lastNote.pitch === pitch && sinceNote < 18; 378 + const black = isBlackKey(pitch); 379 + 380 + let fill; 381 + if (held && focused) { 382 + fill = accent; 383 + } else if (recentFlash && focused) { 384 + const f = 1 - sinceNote / 18; 385 + fill = [ 386 + floor(bgBase[0] + (accent[0] - bgBase[0]) * f * 0.5), 387 + floor(bgBase[1] + (accent[1] - bgBase[1]) * f * 0.5), 388 + floor(bgBase[2] + (accent[2] - bgBase[2]) * f * 0.5), 389 + ]; 390 + } else { 391 + fill = black ? keyBlack : keyWhite; 392 + } 393 + ink(...fill).box(b.x, b.y, b.w, b.h, "fill"); 394 + ink(...(held && focused ? accent : outline)) 395 + .box(b.x, b.y, b.w, b.h, "outline"); 396 + 397 + const labelX = b.x + floor(b.w / 2) - 2; 398 + const labelY = b.y + floor(b.h / 2) - 4; 399 + const labelColor = 400 + held && focused ? [10, 16, 10] : 401 + black ? [200, 210, 220] : fg; 402 + ink(...labelColor).write(key.toUpperCase(), { x: labelX, y: labelY }); 403 + } 404 + } 405 + } 406 + 407 + // ── Unfocused overlay: big blinking "TAP ME!" over everything ──────── 408 + if (!focused) { 409 + // Semi-opaque scrim so the grid visibly darkens 410 + ink(4, 0, 0, 160).box(0, 0, W, H, "fill"); 411 + 412 + // Thick pulsing red border that can't be missed 413 + const borderAlpha = floor(140 + blinkPhase * 115); 414 + for (let i = 0; i < 3; i += 1) { 415 + ink(255, 40, 40, borderAlpha).box(i, i, W - i * 2, H - i * 2, "outline"); 416 + } 417 + 418 + // Huge "TAP ME!" centered in the device 419 + const msg = "TAP ME!"; 420 + const msgSize = 2; // AC text scaling 421 + const charW = 6 * msgSize; 422 + const charH = 10 * msgSize; 423 + const msgW = msg.length * charW; 424 + const msgX = floor((W - msgW) / 2); 425 + const msgY = floor((H - charH) / 2) - 4; 426 + // Drop shadow 427 + ink(0, 0, 0, 180).write(msg, { x: msgX + 2, y: msgY + 2, size: msgSize }); 428 + ink(255, blinkOn ? 80 : 40, blinkOn ? 80 : 40) 429 + .write(msg, { x: msgX, y: msgY, size: msgSize }); 430 + 431 + // Subtitle — smaller, italic-y hint 432 + const sub = "click me to play"; 433 + const subX = floor((W - sub.length * 6) / 2); 434 + const subY = msgY + charH + 4; 435 + ink(blinkOn ? 220 : 140, 120, 120) 436 + .write(sub, { x: subX, y: subY }); 231 437 } 232 438 } 233 439 234 440 function leave() { 235 - try { 236 - ws?.close(); 237 - } catch {} 441 + try { ws?.close(); } catch {} 238 442 ws = null; 443 + heldKeys.clear(); 239 444 } 240 445 241 446 function meta() { 242 447 return { 243 448 title: "Notepat Remote", 244 - desc: "Max for Live bridge: session-server notepat:midi → MIDI track", 449 + desc: "M4L: native-latency keyboard + session-server relay → MIDI track", 245 450 }; 246 451 } 247 452 248 - export { boot, sim, paint, leave, meta }; 453 + export { boot, sim, paint, act, leave, meta };
+1 -25
system/public/aesthetic.computer/disks/notepat.mjs
··· 4627 4627 ink("yellow"); 4628 4628 write("tap", { right: 6, top: 6 }); 4629 4629 } else if (!paintPictureOverlay) { 4630 - const downloadRailLeft = Math.min( 4631 - osBtn?.box?.x ?? Infinity, 4632 - abletonBtn?.box?.x ?? Infinity, 4633 - ); 4634 - const downloadRailRight = Math.max( 4635 - (osBtn?.box?.x ?? 0) + (osBtn?.box?.w ?? 0), 4636 - (abletonBtn?.box?.x ?? 0) + (abletonBtn?.box?.w ?? 0), 4637 - ); 4638 - if (Number.isFinite(downloadRailLeft) && downloadRailRight > downloadRailLeft) { 4639 - ink(10, 28, 34, 210).box( 4640 - downloadRailLeft - 3, 4641 - 0, 4642 - downloadRailRight - downloadRailLeft + 6, 4643 - TOP_BAR_BOTTOM - 1, 4644 - ); 4645 - ink(55, 120, 135, 170).box( 4646 - downloadRailLeft - 3, 4647 - 0, 4648 - downloadRailRight - downloadRailLeft + 6, 4649 - TOP_BAR_BOTTOM - 1, 4650 - "outline", 4651 - ); 4652 - } 4653 - 4654 4630 abletonBtn?.paint((btn) => { 4655 4631 ink(btn.down ? [52, 48, 90] : [30, 28, 62]).box(btn.box); 4656 4632 if (btn.over && !btn.down) { ··· 7148 7124 down: () => api.beep(400), 7149 7125 push: () => { 7150 7126 api.beep(); 7151 - jump("ableton"); 7127 + jump("out:https://aesthetic.computer/m4l/notepat-remote.amxd"); 7152 7128 }, 7153 7129 }); 7154 7130
+45 -6
system/public/aesthetic.computer/disks/prompt.mjs
··· 5879 5879 commitBtn.reposition({ center: "x", y: buttonY, screen }, commitText); 5880 5880 commitBtn.btn.disabled = false; 5881 5881 } 5882 + // TextButtonSmall sizes w as text.length * 4; MatrixChunky8 is proportional 5883 + // so spaces/parens overshoot. Tighten to the real rendered width and recenter. 5884 + const commitTextWidth = $.text.box( 5885 + commitText, undefined, undefined, undefined, undefined, "MatrixChunky8", 5886 + ).box.width; 5887 + commitBtn.btn.box.w = commitTextWidth + 4; // padL + padR 5888 + commitBtn.btn.box.x = Math.floor((screen.width - commitBtn.btn.box.w) / 2); 5882 5889 const cBox = commitBtn.btn.box; 5883 5890 if (cBox) { 5884 5891 const isHover = commitBtn.btn.over && !commitBtn.btn.down; ··· 5926 5933 ]; 5927 5934 commitBtn.paint($, colors); 5928 5935 5929 - // 🎹 Notepat shortcut button — sits to the right of the commit button. 5936 + // 🎹 Notepat shortcut button — sits to the right of the commit button, 5937 + // but stacks above it on narrow screens (phones) when the pair would 5938 + // collide with the TextInput's Enter button. 5930 5939 // Leading spaces reserve room for the piano icon we draw afterward. 5931 - // MatrixChunky8 = 4px/char + 2px pad ⇒ text starts at box.x + 2 + 4*spaces. 5932 - // Icon frame (13×9) ends at box.x + 15, so 4 spaces (x+18) leaves a 3px gap. 5933 - const notepatLabel = " notepat"; 5940 + // MatrixChunky8 space advance = 2px (proportional), padL = 2px ⇒ text 5941 + // starts at box.x + 2 + 2*spaces. Icon frame (13×9) ends at box.x + 14, 5942 + // so 8 spaces (x+18) leaves a 3px gap after the frame. 5943 + const notepatLabel = " notepat"; 5944 + <<<<<<< Updated upstream 5945 + // TextButtonSmall sizes the box as label.length * 4, but MatrixChunky8 5946 + // is proportional (spaces advance only 2px), so the button overshoots. 5947 + // Measure the actual rendered width and tighten the box below. 5948 + const notepatTextWidth = $.text.box( 5949 + notepatLabel, undefined, undefined, undefined, undefined, "MatrixChunky8", 5950 + ).box.width; 5951 + const notepatWidth = notepatTextWidth + 4; // padL + padR 5952 + const notepatHeight = 7 + 2 * 2; // ch=7, padY*2 5953 + const pairGap = 4; 5954 + const stackedGap = 10; // breathing room between stacked notepat and commit 5955 + const enterBoxForStack = $.system.prompt.input?.enter?.btn?.box; 5956 + const pasteBoxForStack = $.system.prompt.input?.paste?.btn?.box; 5957 + // Commit is centered; pair extends rightward by pairGap + notepatWidth. 5958 + const pairRightEdge = (screen.width + cBox.w) / 2 + pairGap + notepatWidth; 5959 + const pairLeftEdge = (screen.width - cBox.w) / 2; 5960 + const rightLimit = enterBoxForStack ? enterBoxForStack.x - 4 : screen.width - 4; 5961 + const leftLimit = pasteBoxForStack ? pasteBoxForStack.x + pasteBoxForStack.w + 4 : 4; 5962 + const stackVertically = pairRightEdge > rightLimit || pairLeftEdge < leftLimit; 5963 + const notepatPos = stackVertically 5964 + ? { center: "x", y: buttonY - notepatHeight - stackedGap, screen } 5965 + : { x: cBox.x + cBox.w + pairGap, y: buttonY }; 5966 + ======= 5934 5967 const notepatX = cBox.x + cBox.w + 4; 5968 + >>>>>>> Stashed changes 5935 5969 if (!notepatBtn) { 5936 - notepatBtn = new $.ui.TextButtonSmall(notepatLabel, { x: notepatX, y: buttonY }); 5970 + notepatBtn = new $.ui.TextButtonSmall(notepatLabel, notepatPos); 5937 5971 } else { 5938 - notepatBtn.reposition({ x: notepatX, y: buttonY }, notepatLabel); 5972 + notepatBtn.reposition(notepatPos, notepatLabel); 5939 5973 notepatBtn.btn.disabled = false; 5974 + } 5975 + // Shrink the box to the real text width and re-center when stacked. 5976 + notepatBtn.btn.box.w = notepatWidth; 5977 + if (stackVertically) { 5978 + notepatBtn.btn.box.x = Math.floor((screen.width - notepatWidth) / 2); 5940 5979 } 5941 5980 const nBox = notepatBtn.btn.box; 5942 5981 const nHover = notepatBtn.btn.over && !notepatBtn.btn.down;
+481
system/public/are.na-annual/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width,initial-scale=1"> 6 + <title>whistlegraph and the self-teaching score · are.na annual vol. 8 pitch</title> 7 + <meta name="description" content="Jeffrey Alan Scudder's pitch for Are.na Annual Vol. 8 (theme: Score) — whistlegraph as the first viral graphic score."> 8 + <link rel="icon" href="https://aesthetic.computer/icon/128x128/prompt.png" type="image/png"> 9 + <link rel="stylesheet" href="https://aesthetic.computer/type/webfonts/berkeley-mono-variable.css"> 10 + <style> 11 + @font-face { 12 + font-family: 'YWFT Processing'; 13 + src: url('https://aesthetic.computer/type/webfonts/ywft-processing-regular.woff2') format('woff2'); 14 + font-weight: normal; 15 + font-display: swap; 16 + } 17 + @font-face { 18 + font-family: 'YWFT Processing'; 19 + src: url('https://aesthetic.computer/type/webfonts/ywft-processing-bold.woff2') format('woff2'); 20 + font-weight: bold; 21 + font-display: swap; 22 + } 23 + 24 + :root { 25 + --bg: #1a1a2e; 26 + --text: #e8e8e8; 27 + --dim: #888; 28 + --pink: #cd5c9b; 29 + --cyan: #4ecdc4; 30 + --purple: #7850b4; 31 + --gold: #d4a017; 32 + --green: #4ecb71; 33 + --red: #e06666; 34 + --box-bg: rgba(255,255,255,0.03); 35 + --box-border: rgba(255,255,255,0.10); 36 + } 37 + @media (prefers-color-scheme: light) { 38 + :root:not(.dark-mode) { 39 + --bg: #f5f5f5; 40 + --text: #1a1a2e; 41 + --dim: #666; 42 + --pink: #b4489a; 43 + --cyan: #0891b2; 44 + --purple: #7850b4; 45 + --gold: #a07800; 46 + --green: #0a8a3e; 47 + --red: #b33a3a; 48 + --box-bg: rgba(0,0,0,0.03); 49 + --box-border: rgba(0,0,0,0.12); 50 + } 51 + } 52 + 53 + * { margin: 0; padding: 0; box-sizing: border-box; } 54 + ::-webkit-scrollbar { display: none; } 55 + 56 + html, body { background: var(--bg); color: var(--text); } 57 + body { 58 + font-family: 'Berkeley Mono Variable', 'Menlo', monospace; 59 + font-size: 13px; 60 + line-height: 1.55; 61 + -webkit-text-size-adjust: none; 62 + padding: 1.4em 1.6em 4em; 63 + min-height: 100vh; 64 + } 65 + @media (min-width: 900px) { body { padding: 1.8em 2.2em 5em; } } 66 + 67 + a { color: var(--cyan); text-decoration: none; } 68 + a:hover { color: var(--pink); } 69 + 70 + .wrap { max-width: 900px; margin: 0 auto; } 71 + 72 + /* ── MASTHEAD ─────────────────────────────── */ 73 + .mast { margin-bottom: 2em; } 74 + .eyebrow { 75 + color: var(--dim); 76 + font-size: 0.75em; 77 + letter-spacing: 0.18em; 78 + text-transform: uppercase; 79 + } 80 + .eyebrow .tag { color: var(--pink); } 81 + 82 + h1 { 83 + font-family: 'YWFT Processing', 'Berkeley Mono Variable', monospace; 84 + font-size: clamp(32px, 5.6vw, 56px); 85 + font-weight: normal; 86 + letter-spacing: -0.01em; 87 + line-height: 1.02; 88 + margin: 0.2em 0 0.2em; 89 + } 90 + h1 .dot { color: var(--pink); } 91 + h1 em { 92 + font-style: normal; 93 + color: var(--cyan); 94 + } 95 + 96 + .sub { 97 + color: var(--dim); 98 + font-size: 0.95em; 99 + margin-bottom: 1.1em; 100 + } 101 + .sub strong { color: var(--text); font-weight: normal; } 102 + 103 + .meta { 104 + display: grid; 105 + grid-template-columns: max-content 1fr; 106 + gap: 0.15em 1.2em; 107 + padding: 0.8em 1em; 108 + background: var(--box-bg); 109 + border: 1px solid var(--box-border); 110 + border-radius: 4px; 111 + font-size: 0.88em; 112 + } 113 + .meta dt { color: var(--dim); text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.85em; padding-top: 0.18em; } 114 + .meta dd { margin: 0; } 115 + .meta .due { color: var(--gold); } 116 + 117 + /* ── SECTIONS ─────────────────────────────── */ 118 + section { margin: 2.4em 0; } 119 + h2 { 120 + font-family: 'YWFT Processing', 'Berkeley Mono Variable', monospace; 121 + font-size: 1.4em; 122 + font-weight: normal; 123 + color: var(--text); 124 + letter-spacing: 0; 125 + padding-bottom: 0.3em; 126 + border-bottom: 1px solid var(--box-border); 127 + margin-bottom: 1em; 128 + } 129 + h2 .ord { color: var(--pink); margin-right: 0.4em; } 130 + 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; } 131 + 132 + /* ── PITCH PROSE ─────────────────────────── */ 133 + .pitch p { margin: 0 0 0.9em; font-size: 0.98em; } 134 + .pitch p:last-child { margin-bottom: 0; } 135 + .pitch .drop { 136 + font-family: 'YWFT Processing', monospace; 137 + font-size: 1.25em; 138 + color: var(--cyan); 139 + display: block; 140 + margin: 1.4em 0 0.6em; 141 + } 142 + .pitch q { 143 + quotes: "“" "”"; 144 + color: var(--gold); 145 + font-style: italic; 146 + } 147 + .pitch em { color: var(--pink); font-style: normal; } 148 + .pitch .name { color: var(--cyan); } 149 + 150 + /* ── ANCHORS ──────────────────────────────── */ 151 + .anchors { 152 + display: grid; 153 + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); 154 + gap: 0.6em; 155 + margin: 1em 0; 156 + } 157 + .anchor { 158 + padding: 0.8em 1em; 159 + background: var(--box-bg); 160 + border: 1px solid var(--box-border); 161 + border-left: 2px solid var(--pink); 162 + border-radius: 3px; 163 + } 164 + .anchor b { 165 + display: block; 166 + color: var(--pink); 167 + font-family: 'YWFT Processing', monospace; 168 + font-size: 1.05em; 169 + font-weight: normal; 170 + margin-bottom: 0.2em; 171 + } 172 + .anchor p { margin: 0; color: var(--text); font-size: 0.88em; line-height: 1.5; } 173 + 174 + /* ── LINEAGE LIST ─────────────────────────── */ 175 + .lineage { display: grid; gap: 0.5em; } 176 + .lineage .row { 177 + display: grid; 178 + grid-template-columns: 120px 1fr; 179 + gap: 1em; 180 + padding: 0.5em 0; 181 + border-bottom: 1px dotted var(--box-border); 182 + font-size: 0.92em; 183 + } 184 + .lineage .row:last-child { border-bottom: none; } 185 + .lineage .who { color: var(--cyan); } 186 + .lineage .claim { color: var(--text); } 187 + .lineage .claim em { color: var(--pink); font-style: normal; } 188 + 189 + /* ── CHANNEL (live) ───────────────────────── */ 190 + .channel { 191 + margin-top: 1.2em; 192 + } 193 + .channel-status { 194 + font-size: 0.8em; 195 + color: var(--dim); 196 + margin-bottom: 1em; 197 + padding: 0.4em 0.6em; 198 + border-left: 2px solid var(--gold); 199 + background: var(--box-bg); 200 + } 201 + .channel-status .ok { color: var(--green); } 202 + .channel-status .err { color: var(--red); } 203 + 204 + .section-break { 205 + margin: 1.6em 0 0.6em; 206 + padding: 0.4em 0; 207 + border-top: 1px solid var(--box-border); 208 + font-family: 'Berkeley Mono Variable', monospace; 209 + text-transform: uppercase; 210 + letter-spacing: 0.14em; 211 + font-size: 0.75em; 212 + color: var(--dim); 213 + } 214 + .section-break b { color: var(--pink); font-weight: normal; margin-right: 0.5em; } 215 + .section-break .note { color: var(--dim); text-transform: none; letter-spacing: 0; margin-left: 0.8em; font-style: italic; } 216 + 217 + .blocks-grid { 218 + display: grid; 219 + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); 220 + gap: 0.8em; 221 + } 222 + .block { 223 + display: flex; 224 + flex-direction: column; 225 + background: var(--box-bg); 226 + border: 1px solid var(--box-border); 227 + border-radius: 3px; 228 + overflow: hidden; 229 + transition: border-color 0.1s; 230 + } 231 + .block:hover { border-color: var(--cyan); } 232 + .block.text { grid-column: 1 / -1; padding: 0.9em 1.1em; background: transparent; border: none; border-left: 2px solid var(--gold); border-radius: 0; } 233 + .block.text .txt { 234 + color: var(--gold); 235 + font-size: 1.05em; 236 + line-height: 1.4; 237 + font-family: 'YWFT Processing', monospace; 238 + } 239 + .block .thumb { 240 + aspect-ratio: 1 / 1; 241 + background: #0a0a18 center/cover no-repeat; 242 + display: block; 243 + border-bottom: 1px solid var(--box-border); 244 + } 245 + .block .body { padding: 0.6em 0.8em; display: flex; flex-direction: column; gap: 0.25em; flex: 1; } 246 + .block .title { color: var(--cyan); font-size: 0.92em; line-height: 1.3; word-break: break-word; } 247 + .block .title:hover { color: var(--pink); } 248 + .block .why { color: var(--dim); font-size: 0.82em; line-height: 1.4; margin-top: 0.2em; } 249 + .block .host { color: var(--purple); font-size: 0.72em; letter-spacing: 0.05em; margin-top: auto; padding-top: 0.4em; } 250 + 251 + /* ── CHECKLIST ────────────────────────────── */ 252 + .check { list-style: none; padding: 0; font-size: 0.9em; } 253 + .check li { padding: 0.35em 0; display: flex; gap: 0.6em; } 254 + .check li::before { font-family: 'Berkeley Mono Variable', monospace; width: 1em; text-align: center; } 255 + .check .done { color: var(--dim); } 256 + .check .done::before { content: "✓"; color: var(--green); } 257 + .check .todo::before { content: "▸"; color: var(--gold); } 258 + .check .todo { color: var(--text); } 259 + .check .todo em { color: var(--gold); font-style: normal; } 260 + 261 + footer { 262 + margin-top: 4em; 263 + padding-top: 1em; 264 + border-top: 1px solid var(--box-border); 265 + color: var(--dim); 266 + font-size: 0.75em; 267 + letter-spacing: 0.08em; 268 + } 269 + footer a { color: var(--dim); } 270 + footer a:hover { color: var(--cyan); } 271 + 272 + @media (max-width: 560px) { 273 + body { padding: 1.2em 1em 3em; font-size: 13px; } 274 + .lineage .row { grid-template-columns: 1fr; gap: 0.1em; } 275 + .meta { grid-template-columns: 1fr; gap: 0.05em; } 276 + .meta dt { padding-top: 0.6em; } 277 + } 278 + </style> 279 + </head> 280 + <body> 281 + <main class="wrap"> 282 + 283 + <header class="mast"> 284 + <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> 285 + <h1>Whistlegraph<br>and the <em>Self-Teaching<br>Score</em><span class="dot">.</span></h1> 286 + <p class="sub">Pitch by <strong>@jeffrey</strong> (Jeffrey Alan Scudder) · <a href="https://aesthetic.computer">aesthetic.computer</a></p> 287 + <dl class="meta"> 288 + <dt>Channel</dt><dd><a href="https://www.are.na/aesthetic-computer/self-teaching-scores">are.na/aesthetic-computer/self-teaching-scores</a></dd> 289 + <dt>Form</dt><dd><a href="https://aredotna.notion.site/3178a0f816d9815abdf3cb1624bb9e88">aredotna.notion.site — Vol. 8 submission</a></dd> 290 + <dt>Due</dt><dd class="due">Mon, 20 Apr 2026 · 11:59 PM EST</dd> 291 + <dt>Honorarium</dt><dd>$200 · book released Dec 2026</dd> 292 + </dl> 293 + </header> 294 + 295 + <!-- ── PITCH ───────────────────────────────── --> 296 + <section class="pitch"> 297 + <h2><span class="ord">§</span>Pitch</h2> 298 + 299 + <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> 300 + 301 + <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> 302 + 303 + <p>I want to write about <em>why</em> it worked there.</p> 304 + 305 + <span class="drop">The graphic-score tradition failed at one thing.</span> 306 + 307 + <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> 308 + 309 + <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> 310 + 311 + <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> 312 + 313 + <span class="drop">Three registers, one object.</span> 314 + 315 + <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> 316 + 317 + <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> 318 + 319 + <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> 320 + </section> 321 + 322 + <!-- ── ANCHORS ─────────────────────────────── --> 323 + <section> 324 + <h2><span class="ord">§</span>Three Registers</h2> 325 + <div class="anchors"> 326 + <div class="anchor"> 327 + <b>Art</b> 328 + <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> 329 + </div> 330 + <div class="anchor"> 331 + <b>Content</b> 332 + <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> 333 + </div> 334 + <div class="anchor"> 335 + <b>Interface</b> 336 + <p>The founding principle of aesthetic.computer: every piece a self-documenting score, every URL a memorizable performance.</p> 337 + </div> 338 + </div> 339 + </section> 340 + 341 + <!-- ── LINEAGE ─────────────────────────────── --> 342 + <section> 343 + <h2><span class="ord">§</span>The Lineage The Essay Argues<span class="count">specific, not canonical</span></h2> 344 + <div class="lineage"> 345 + <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> 346 + <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> 347 + <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> 348 + <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> 349 + <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> 350 + <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> 351 + <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> 352 + <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> 353 + <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> 354 + </div> 355 + </section> 356 + 357 + <!-- ── CHANNEL (live) ──────────────────────── --> 358 + <section> 359 + <h2><span class="ord">§</span>The Channel<span class="count">live from are.na · 68 blocks</span></h2> 360 + <div id="channel-status" class="channel-status"> 361 + Fetching <span class="ok">api.are.na</span> … 362 + </div> 363 + <div id="channel" class="channel"></div> 364 + </section> 365 + 366 + <!-- ── CONDENSED ──────────────────────────── --> 367 + <section> 368 + <h2><span class="ord">§</span>Submission-Form Version<span class="count">~200 words</span></h2> 369 + <div class="pitch" style="border-left: 2px solid var(--pink); padding-left: 1em"> 370 + <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> 371 + <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> 372 + <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> 373 + <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> 374 + </div> 375 + </section> 376 + 377 + <!-- ── CHECKLIST ──────────────────────────── --> 378 + <section> 379 + <h2><span class="ord">§</span>Status</h2> 380 + <ul class="check"> 381 + <li class="done">Channel live and public, 68 blocks, all annotated.</li> 382 + <li class="done">Channel description set (via web UI — the v2 PUT does not persist it).</li> 383 + <li class="done">Other personal channels set to private; profile reads as focused.</li> 384 + <li class="done"><code>ARENA_TOKEN</code> stashed in <code>aesthetic-computer-vault/.env</code>.</li> 385 + <li class="todo">Fill the Notion form — paste the 200-word version and channel URL. <em>Due tonight, 11:59 PM EST.</em></li> 386 + </ul> 387 + </section> 388 + 389 + <footer> 390 + <a href="https://aesthetic.computer/are.na-annual">aesthetic.computer/are.na-annual</a> · 2026-04-20 · live channel fetch from api.are.na 391 + </footer> 392 + 393 + </main> 394 + 395 + <script> 396 + (async () => { 397 + const SLUG = "self-teaching-scores"; 398 + const API = `https://api.are.na/v2/channels/${SLUG}/contents?per=100&direction=desc`; 399 + const statusEl = document.getElementById("channel-status"); 400 + const root = document.getElementById("channel"); 401 + 402 + // Reading-order sections (top → bottom of channel page) 403 + // positions are 1..68 in the channel, 68 = top 404 + const SECTIONS = [ 405 + { name: "§10 — Whistlegraph", note: "The subject. Everything lands here.", min: 64 }, 406 + { name: "§9 — Framing Text", note: "The thesis spoken plainly.", min: 61 }, 407 + { name: "§8 — Computational / Card-Sized Kin", note: "Environments where the program is its own score.", min: 54 }, 408 + { name: "§7 — 20th-Century Graphic Scores", note: "The canon we diverge from.", min: 39 }, 409 + { name: "§6 — Fluxus &amp; Event Scores", note: "Single-instruction, card-sized scores — closest kin.", min: 33 }, 410 + { name: "§5 — Vernacular / Folk Notation", note: "Notations that teach by use.", min: 25 }, 411 + { name: "§4 — Sport as Line", note: "Spatial scores on real terrain.", min: 20 }, 412 + { name: "§3 — Body / Movement Notation", note: "The West's attempts to notate bodies.", min: 15 }, 413 + { name: "§2 — Instructional / Craft", note: "The \"score teaches itself\" claim made mundane.", min: 7 }, 414 + { name: "§1 — Viral / Social Kin", note: "The contemporary record: formats that spread by being reproducible.", min: 1 }, 415 + ]; 416 + 417 + function hostname(u) { 418 + try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return ""; } 419 + } 420 + 421 + function sectionFor(pos) { 422 + for (const s of SECTIONS) if (pos >= s.min) return s; 423 + return SECTIONS[SECTIONS.length - 1]; 424 + } 425 + 426 + function renderBlocks(blocks) { 427 + // Sort top-of-channel first 428 + const sorted = [...blocks].sort((a, b) => b.position - a.position); 429 + // Group by section 430 + const groups = new Map(); 431 + for (const b of sorted) { 432 + const s = sectionFor(b.position); 433 + if (!groups.has(s.name)) groups.set(s.name, { s, blocks: [] }); 434 + groups.get(s.name).blocks.push(b); 435 + } 436 + 437 + const out = []; 438 + for (const [name, { s, blocks: bs }] of groups) { 439 + out.push(`<div class="section-break"><b>${s.name}</b><span class="note">${s.note}</span></div>`); 440 + out.push(`<div class="blocks-grid">`); 441 + for (const b of bs) { 442 + if (b.class === "Text") { 443 + const txt = (b.content || "").replace(/\*([^*]+)\*/g, "<em>$1</em>"); 444 + out.push(`<div class="block text"><div class="txt">${txt}</div></div>`); 445 + continue; 446 + } 447 + const url = b.source?.url || "#"; 448 + const title = b.title || b.generated_title || url; 449 + const img = b.image?.display?.url || b.image?.thumb?.url || ""; 450 + const desc = b.description || ""; 451 + const host = hostname(url); 452 + out.push( 453 + `<a class="block" href="${url}" target="_blank" rel="noreferrer">` + 454 + (img ? `<span class="thumb" style="background-image:url('${img}')"></span>` : "") + 455 + `<div class="body">` + 456 + `<div class="title">${title}</div>` + 457 + (desc ? `<div class="why">${desc}</div>` : "") + 458 + (host ? `<div class="host">${host}</div>` : "") + 459 + `</div>` + 460 + `</a>` 461 + ); 462 + } 463 + out.push(`</div>`); 464 + } 465 + root.innerHTML = out.join(""); 466 + } 467 + 468 + try { 469 + const r = await fetch(API); 470 + if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); 471 + const data = await r.json(); 472 + const blocks = data.contents || []; 473 + 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>`; 474 + renderBlocks(blocks); 475 + } catch (err) { 476 + 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>`; 477 + } 478 + })(); 479 + </script> 480 + </body> 481 + </html>
+4 -3
system/public/kidlisp.com/keeps.html
··· 3690 3690 3691 3691 async function fetchAllCodes(sort = 'recent') { 3692 3692 try { 3693 - // 2000 is well above any plausible per-user piece count; the server 3694 - // already filtered to acHandle so this is just a safety ceiling. 3695 - const url = buildStoreUrl(`&limit=2000&sort=${sort}`); 3693 + // 20000 is a ceiling — @jeffrey already has ~4K pieces and the 3694 + // server-side handle filter needs headroom, otherwise older 3695 + // kept pieces get cut off before they reach the client. 3696 + const url = buildStoreUrl(`&limit=20000&sort=${sort}`); 3696 3697 const res = await fetch(url, { cache: 'no-store' }); 3697 3698 if (!res.ok) throw new Error(`HTTP ${res.status}`); 3698 3699 const data = await res.json();
system/public/m4l/notepat-remote.amxd

This is a binary file and will not be displayed.