Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

notepat/gm: wire General MIDI as default sound + digit-buffered patch picker

GM bank served from assets.aesthetic.computer/gm/<NNN>/<note>.mp3, baked
on lith from a FOSS SoundFont (GeneralUser GS). The disk-side wiring
falls back to a sine oscillator while GM is loading or unavailable, so
nothing breaks before the packs land on Spaces.

- lith/scripts/gm-bake.mjs: SoundFont→per-instrument MP3 bake (128
melodic patches + GM standard drum kit, manifest.json + LICENSE.txt)
- system/public/aesthetic.computer/lib/gm.mjs: lazy AudioBuffer player —
loadManifest / loadPatch / loadDrumKit / prefetchPatch
- system/public/aesthetic.computer/lib/midi.mjs: forward 0xC0
program-change through acSEND so notepat can react to it
- notepat.mjs: "gm" prepended to wavetypes (default); top-row 0-9
picks GM programs live like menuband, with 700ms auto-clear; wave
button shows GM:078 while typing; legacy oscillators stay reachable
via Tab; deferred GM init waits for window.audioContext after first
user gesture
- plans/notepat-gm-integration.md: full integration plan + decision log

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

+1445 -12
+17
lith/README.md
··· 22 22 2. Fill in the real production values 23 23 3. Re-run `fish vault-tool.fish status` to confirm `lith/.env` is tracked 24 24 4. Deploy with `fish /workspaces/aesthetic-computer/lith/deploy.fish` 25 + 26 + ## GM SoundFont bake 27 + 28 + `lith/scripts/gm-bake.mjs` is a one-shot baker that renders a FOSS SoundFont 29 + (default: [GeneralUser GS v1.471](https://schristiancollins.com/generaluser.php)) 30 + into per-instrument MP3 packs covering the full GM Level 1 bank — analogous to 31 + how BDF fonts get hosted on `assets.aesthetic.computer`. Requires `fluidsynth` 32 + and `ffmpeg` on PATH (`brew install fluid-synth ffmpeg` on macOS, 33 + `apt install fluidsynth ffmpeg` on Linux). Run it with 34 + `node lith/scripts/gm-bake.mjs` — the SF2 is downloaded into `lith/cache/` and 35 + output lands at `lith/scripts/out/gm/<NNN>/<NoteName>.mp3` plus 36 + `drum-000/<midi>.mp3` for the Standard Kit, with a `manifest.json` and 37 + `LICENSE.txt` alongside. The script is re-runnable (skips existing MP3s), 38 + defaults to every-third-semitone over A0..C8 (override with `--note-step=N`, 39 + `--only=0,1,24`, `--skip-drums`, `--out=...`, or `--dry-run`). When the bake 40 + finishes, publish with `npm run assets:sync:up` to push the tree to 41 + `assets.aesthetic.computer/gm/`.
+528
lith/scripts/gm-bake.mjs
··· 1 + #!/usr/bin/env node 2 + // gm-bake.mjs — One-shot bake of a FOSS SoundFont into per-instrument MP3 packs 3 + // for the General MIDI bank. Output is structured for upload to 4 + // assets.aesthetic.computer/gm/* (run `npm run assets:sync:up` after). 5 + // 6 + // Default SoundFont: GeneralUser GS v1.471 by S. Christian Collins 7 + // https://schristiancollins.com/generaluser.php 8 + // Override with $GM_SF2=/path/to/file.sf2 if the upstream URL drifts. 9 + // 10 + // Required tools (both expected on lith; check + install hints printed below): 11 + // fluidsynth brew install fluid-synth | apt install fluidsynth 12 + // ffmpeg brew install ffmpeg | apt install ffmpeg 13 + // 14 + // Usage: 15 + // node lith/scripts/gm-bake.mjs # melodic + drums, step=3 16 + // node lith/scripts/gm-bake.mjs --note-step=1 # render every semitone 17 + // node lith/scripts/gm-bake.mjs --note-step=6 # coarse, smaller bundle 18 + // node lith/scripts/gm-bake.mjs --only=0,1,24 # subset of program IDs 19 + // node lith/scripts/gm-bake.mjs --skip-drums 20 + // node lith/scripts/gm-bake.mjs --out=/tmp/gm # alt output dir 21 + // node lith/scripts/gm-bake.mjs --dry-run # plan only, no render 22 + // 23 + // Re-runnable: skips MP3s that already exist on disk. 24 + // Output tree (default): 25 + // lith/cache/ (downloaded SF2) 26 + // lith/scripts/out/gm/manifest.json 27 + // lith/scripts/out/gm/LICENSE.txt 28 + // lith/scripts/out/gm/000/A4.mp3 (program 0 = Acoustic Grand, A4) 29 + // lith/scripts/out/gm/000/Cs4.mp3 (C#4 — sharps spelled with 's') 30 + // ... 31 + // lith/scripts/out/gm/drum-000/35.mp3 (Standard Kit, MIDI note 35) 32 + // ... 33 + 34 + import { spawnSync, execFileSync } from "node:child_process"; 35 + import { 36 + existsSync, 37 + mkdirSync, 38 + writeFileSync, 39 + rmSync, 40 + statSync, 41 + } from "node:fs"; 42 + import { dirname, join, resolve } from "node:path"; 43 + import { fileURLToPath } from "node:url"; 44 + import { tmpdir } from "node:os"; 45 + 46 + const __filename = fileURLToPath(import.meta.url); 47 + const __dirname = dirname(__filename); 48 + const LITH_DIR = resolve(__dirname, ".."); 49 + const CACHE_DIR = join(LITH_DIR, "cache"); 50 + 51 + // ─── ANSI ─────────────────────────────────────────────────────────────────── 52 + const C = { 53 + red: "\x1b[0;31m", 54 + green: "\x1b[0;32m", 55 + yellow: "\x1b[1;33m", 56 + dim: "\x1b[2m", 57 + reset: "\x1b[0m", 58 + }; 59 + const log = (msg) => console.log(`${C.green}->${C.reset} ${msg}`); 60 + const warn = (msg) => console.warn(`${C.yellow}!${C.reset} ${msg}`); 61 + const die = (msg) => { 62 + console.error(`${C.red}x${C.reset} ${msg}`); 63 + process.exit(1); 64 + }; 65 + 66 + // ─── CLI args ─────────────────────────────────────────────────────────────── 67 + const argv = process.argv.slice(2); 68 + const flag = (name, fallback = undefined) => { 69 + for (const a of argv) { 70 + if (a === `--${name}`) return true; 71 + if (a.startsWith(`--${name}=`)) return a.slice(name.length + 3); 72 + } 73 + return fallback; 74 + }; 75 + const NOTE_STEP = parseInt(flag("note-step", "3"), 10); 76 + const ONLY = flag("only"); 77 + const SKIP_DRUMS = flag("skip-drums", false) === true; 78 + const SKIP_MELODIC = flag("skip-melodic", false) === true; 79 + const DRY_RUN = flag("dry-run", false) === true; 80 + const OUT_DIR = resolve(flag("out", join(__dirname, "out", "gm"))); 81 + 82 + if (!Number.isInteger(NOTE_STEP) || NOTE_STEP < 1 || NOTE_STEP > 12) { 83 + die(`--note-step must be an integer 1..12 (got ${NOTE_STEP})`); 84 + } 85 + 86 + // ─── GM patch names (program 0..127) ──────────────────────────────────────── 87 + // Standard General MIDI Level 1 melodic bank. 88 + const GM_PATCHES = [ 89 + "Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", 90 + "Honky-tonk Piano", "Electric Piano 1", "Electric Piano 2", "Harpsichord", 91 + "Clavi", 92 + "Celesta", "Glockenspiel", "Music Box", "Vibraphone", "Marimba", "Xylophone", 93 + "Tubular Bells", "Dulcimer", 94 + "Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ", 95 + "Reed Organ", "Accordion", "Harmonica", "Tango Accordion", 96 + "Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", 97 + "Electric Guitar (jazz)", "Electric Guitar (clean)", 98 + "Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", 99 + "Guitar harmonics", 100 + "Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", 101 + "Fretless Bass", "Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2", 102 + "Violin", "Viola", "Cello", "Contrabass", "Tremolo Strings", 103 + "Pizzicato Strings", "Orchestral Harp", "Timpani", 104 + "String Ensemble 1", "String Ensemble 2", "SynthStrings 1", "SynthStrings 2", 105 + "Choir Aahs", "Voice Oohs", "Synth Voice", "Orchestra Hit", 106 + "Trumpet", "Trombone", "Tuba", "Muted Trumpet", "French Horn", 107 + "Brass Section", "SynthBrass 1", "SynthBrass 2", 108 + "Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax", 109 + "Oboe", "English Horn", "Bassoon", "Clarinet", 110 + "Piccolo", "Flute", "Recorder", "Pan Flute", 111 + "Blown Bottle", "Shakuhachi", "Whistle", "Ocarina", 112 + "Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", 113 + "Lead 4 (chiff)", "Lead 5 (charang)", "Lead 6 (voice)", 114 + "Lead 7 (fifths)", "Lead 8 (bass + lead)", 115 + "Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)", 116 + "Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)", 117 + "FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)", 118 + "FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)", 119 + "Sitar", "Banjo", "Shamisen", "Koto", "Kalimba", "Bag pipe", "Fiddle", 120 + "Shanai", 121 + "Tinkle Bell", "Agogo", "Steel Drums", "Woodblock", "Taiko Drum", 122 + "Melodic Tom", "Synth Drum", "Reverse Cymbal", 123 + "Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet", 124 + "Telephone Ring", "Helicopter", "Applause", "Gunshot", 125 + ]; 126 + if (GM_PATCHES.length !== 128) { 127 + die(`internal error: GM_PATCHES length = ${GM_PATCHES.length}, expected 128`); 128 + } 129 + 130 + // GM Level 1 standard kit note names (MIDI notes 35..81). 131 + const GM_DRUM_KIT_NOTE_NAMES = { 132 + 35: "Acoustic Bass Drum", 36: "Bass Drum 1", 37: "Side Stick", 133 + 38: "Acoustic Snare", 39: "Hand Clap", 40: "Electric Snare", 134 + 41: "Low Floor Tom", 42: "Closed Hi Hat", 43: "High Floor Tom", 135 + 44: "Pedal Hi-Hat", 45: "Low Tom", 46: "Open Hi-Hat", 47: "Low-Mid Tom", 136 + 48: "Hi-Mid Tom", 49: "Crash Cymbal 1", 50: "High Tom", 51: "Ride Cymbal 1", 137 + 52: "Chinese Cymbal", 53: "Ride Bell", 54: "Tambourine", 55: "Splash Cymbal", 138 + 56: "Cowbell", 57: "Crash Cymbal 2", 58: "Vibraslap", 59: "Ride Cymbal 2", 139 + 60: "Hi Bongo", 61: "Low Bongo", 62: "Mute Hi Conga", 63: "Open Hi Conga", 140 + 64: "Low Conga", 65: "High Timbale", 66: "Low Timbale", 67: "High Agogo", 141 + 68: "Low Agogo", 69: "Cabasa", 70: "Maracas", 71: "Short Whistle", 142 + 72: "Long Whistle", 73: "Short Guiro", 74: "Long Guiro", 75: "Claves", 143 + 76: "Hi Wood Block", 77: "Low Wood Block", 78: "Mute Cuica", 79: "Open Cuica", 144 + 80: "Mute Triangle", 81: "Open Triangle", 145 + }; 146 + 147 + // ─── note helpers ─────────────────────────────────────────────────────────── 148 + const NOTE_NAMES = [ 149 + "C", "Cs", "D", "Ds", "E", "F", "Fs", "G", "Gs", "A", "As", "B", 150 + ]; 151 + function midiToName(m) { 152 + const pc = NOTE_NAMES[m % 12]; 153 + const oct = Math.floor(m / 12) - 1; // MIDI 0 = C-1, 60 = C4 154 + return `${pc}${oct}`; 155 + } 156 + 157 + // ─── tool checks ──────────────────────────────────────────────────────────── 158 + function which(bin) { 159 + const r = spawnSync("command", ["-v", bin], { shell: true }); 160 + return r.status === 0; 161 + } 162 + function requireTool(bin, hint) { 163 + if (!which(bin)) { 164 + console.error(`${C.red}x${C.reset} missing required tool: ${bin}`); 165 + console.error(` install: ${hint}`); 166 + process.exit(1); 167 + } 168 + } 169 + if (!DRY_RUN) { 170 + requireTool( 171 + "fluidsynth", 172 + "macOS: brew install fluid-synth\n Linux: apt install fluidsynth", 173 + ); 174 + requireTool( 175 + "ffmpeg", 176 + "macOS: brew install ffmpeg\n Linux: apt install ffmpeg", 177 + ); 178 + } 179 + 180 + // ─── SoundFont resolution ─────────────────────────────────────────────────── 181 + const GENERALUSER_URL = 182 + "https://schristiancollins.com/soundfonts/GeneralUser_GS_1.471.zip"; 183 + const GENERALUSER_NAME = "GeneralUser GS v1.471"; 184 + const GENERALUSER_LICENSE = `${GENERALUSER_NAME} 185 + by S. Christian Collins (https://schristiancollins.com/generaluser.php) 186 + 187 + GeneralUser GS is freely usable for any purpose, commercial or otherwise, 188 + and may be redistributed under the same conditions, with the following 189 + notes from the author: 190 + 191 + "GeneralUser GS is a GM and GS compatible SoundFont bank for composing, 192 + playing MIDI files, and use in any sound module that supports the 193 + SoundFont 2.01 standard. It is free to use and freely distributable, as 194 + long as you do not charge specifically for it or modify the included 195 + documentation. You are free to use samples from this SoundFont in your 196 + own work, including commercial work, without any further permission." 197 + 198 + See the upstream page for the full README and the most current license 199 + text: 200 + https://schristiancollins.com/generaluser.php 201 + `; 202 + 203 + function resolveSoundFont() { 204 + const env = process.env.GM_SF2; 205 + if (env) { 206 + if (!existsSync(env)) die(`GM_SF2 set but file missing: ${env}`); 207 + log(`using $GM_SF2 = ${env}`); 208 + return { path: env, name: GENERALUSER_NAME }; 209 + } 210 + mkdirSync(CACHE_DIR, { recursive: true }); 211 + const cachedSf2 = join(CACHE_DIR, "GeneralUser_GS.sf2"); 212 + if (existsSync(cachedSf2)) { 213 + log(`using cached SF2: ${cachedSf2}`); 214 + return { path: cachedSf2, name: GENERALUSER_NAME }; 215 + } 216 + if (DRY_RUN) { 217 + warn(`SF2 not present; would download from ${GENERALUSER_URL}`); 218 + return { path: cachedSf2, name: GENERALUSER_NAME }; 219 + } 220 + log(`downloading ${GENERALUSER_NAME} → ${cachedSf2}`); 221 + const zip = join(CACHE_DIR, "GeneralUser_GS.zip"); 222 + const curl = spawnSync( 223 + "curl", 224 + ["-fsSL", "-o", zip, GENERALUSER_URL], 225 + { stdio: "inherit" }, 226 + ); 227 + if (curl.status !== 0) { 228 + die( 229 + `download failed. Set $GM_SF2=/path/to/file.sf2 to bypass.\n` + 230 + ` Upstream page: https://schristiancollins.com/generaluser.php`, 231 + ); 232 + } 233 + // Try both unzip and bsdtar; locate the .sf2 inside. 234 + const extractDir = join(CACHE_DIR, "extract"); 235 + rmSync(extractDir, { recursive: true, force: true }); 236 + mkdirSync(extractDir, { recursive: true }); 237 + const unzip = spawnSync("unzip", ["-q", "-o", zip, "-d", extractDir], { 238 + stdio: "inherit", 239 + }); 240 + if (unzip.status !== 0) die("unzip failed; install `unzip` or extract manually"); 241 + // Find the SF2 file recursively. 242 + const found = execFileSync("find", [extractDir, "-name", "*.sf2"]) 243 + .toString() 244 + .trim() 245 + .split("\n") 246 + .filter(Boolean); 247 + if (found.length === 0) die("no .sf2 found inside the archive"); 248 + execFileSync("cp", [found[0], cachedSf2]); 249 + log(`extracted: ${cachedSf2}`); 250 + return { path: cachedSf2, name: GENERALUSER_NAME }; 251 + } 252 + 253 + // ─── render core ──────────────────────────────────────────────────────────── 254 + const HOLD_SEC = 3; 255 + const RELEASE_SEC = 1; 256 + const TOTAL_SEC = HOLD_SEC + RELEASE_SEC; 257 + const BITRATE = "96k"; 258 + const SAMPLE_RATE = 44100; 259 + const VELOCITY = 100; 260 + 261 + // Build a tiny MIDI file in memory: program change + note on + note off. 262 + // Format 0, single track, 480 PPQ, tempo 120 → 1 quarter = 0.5s. 263 + // Hold = HOLD_SEC. Track tail extends RELEASE_SEC for the release sample. 264 + function buildMidi({ program, note, isDrum }) { 265 + const PPQ = 480; 266 + const TEMPO_US_PER_QN = 500000; // 120 BPM 267 + const secondsToTicks = (s) => Math.round((s * 1_000_000 * PPQ) / TEMPO_US_PER_QN); 268 + 269 + const writeVarLen = (n) => { 270 + const bytes = []; 271 + bytes.push(n & 0x7f); 272 + n >>= 7; 273 + while (n > 0) { 274 + bytes.unshift((n & 0x7f) | 0x80); 275 + n >>= 7; 276 + } 277 + return bytes; 278 + }; 279 + 280 + const channel = isDrum ? 9 : 0; // GM channel 10 = index 9 281 + const events = []; 282 + 283 + // Tempo meta event @ tick 0. 284 + events.push(0); // delta 285 + events.push(0xff, 0x51, 0x03, 286 + (TEMPO_US_PER_QN >> 16) & 0xff, 287 + (TEMPO_US_PER_QN >> 8) & 0xff, 288 + TEMPO_US_PER_QN & 0xff); 289 + 290 + // Program change (skip for drum channel — kit selection is via bank, 291 + // but for GM standard kit on chan 10 we still emit a PC=0). 292 + events.push(...writeVarLen(0)); 293 + events.push(0xc0 | channel, program & 0x7f); 294 + 295 + // Note on @ delta=0 296 + events.push(...writeVarLen(0)); 297 + events.push(0x90 | channel, note & 0x7f, VELOCITY); 298 + 299 + // Note off @ delta=HOLD 300 + events.push(...writeVarLen(secondsToTicks(HOLD_SEC))); 301 + events.push(0x80 | channel, note & 0x7f, 0x40); 302 + 303 + // End of track @ delta=RELEASE (lets the SF release tail render fully) 304 + events.push(...writeVarLen(secondsToTicks(RELEASE_SEC))); 305 + events.push(0xff, 0x2f, 0x00); 306 + 307 + const trackBody = Buffer.from(events); 308 + const header = Buffer.alloc(14); 309 + header.write("MThd", 0, "ascii"); 310 + header.writeUInt32BE(6, 4); 311 + header.writeUInt16BE(0, 8); // format 0 312 + header.writeUInt16BE(1, 10); // 1 track 313 + header.writeUInt16BE(PPQ, 12); 314 + const trkHdr = Buffer.alloc(8); 315 + trkHdr.write("MTrk", 0, "ascii"); 316 + trkHdr.writeUInt32BE(trackBody.length, 4); 317 + return Buffer.concat([header, trkHdr, trackBody]); 318 + } 319 + 320 + function renderOne({ sf2, midiFile, wavFile, mp3File }) { 321 + // fluidsynth headless render → wav 322 + const fs = spawnSync( 323 + "fluidsynth", 324 + [ 325 + "-ni", 326 + "-g", "0.7", 327 + "-r", String(SAMPLE_RATE), 328 + "-F", wavFile, 329 + "--fast-render", wavFile, 330 + sf2, 331 + midiFile, 332 + ], 333 + { stdio: ["ignore", "ignore", "pipe"] }, 334 + ); 335 + // Note: some fluidsynth builds use `--fast-render=path` form — try fallback. 336 + if (fs.status !== 0 || !existsSync(wavFile)) { 337 + const fs2 = spawnSync( 338 + "fluidsynth", 339 + [ 340 + "-ni", 341 + "-g", "0.7", 342 + "-r", String(SAMPLE_RATE), 343 + `--fast-render=${wavFile}`, 344 + sf2, 345 + midiFile, 346 + ], 347 + { stdio: ["ignore", "ignore", "pipe"] }, 348 + ); 349 + if (fs2.status !== 0 || !existsSync(wavFile)) { 350 + const stderr = (fs.stderr?.toString() || "") + (fs2.stderr?.toString() || ""); 351 + throw new Error(`fluidsynth failed:\n${stderr}`); 352 + } 353 + } 354 + // ffmpeg → mp3 mono 96k 355 + const ff = spawnSync( 356 + "ffmpeg", 357 + [ 358 + "-y", "-loglevel", "error", 359 + "-i", wavFile, 360 + "-t", String(TOTAL_SEC), 361 + "-codec:a", "libmp3lame", 362 + "-b:a", BITRATE, 363 + "-ac", "1", 364 + "-ar", String(SAMPLE_RATE), 365 + mp3File, 366 + ], 367 + { stdio: ["ignore", "ignore", "pipe"] }, 368 + ); 369 + if (ff.status !== 0 || !existsSync(mp3File)) { 370 + throw new Error(`ffmpeg failed:\n${ff.stderr?.toString() || ""}`); 371 + } 372 + } 373 + 374 + // ─── plan & execute ───────────────────────────────────────────────────────── 375 + function planMelodicNotes(step) { 376 + const notes = []; 377 + for (let m = 21; m <= 108; m += step) notes.push(m); 378 + // Always include the boundary if step skipped it. 379 + if (notes[notes.length - 1] !== 108) notes.push(108); 380 + return notes; 381 + } 382 + function planDrumNotes() { 383 + return Object.keys(GM_DRUM_KIT_NOTE_NAMES) 384 + .map(Number) 385 + .sort((a, b) => a - b); 386 + } 387 + 388 + function selectedPrograms() { 389 + if (!ONLY) return [...Array(128).keys()]; 390 + return ONLY.split(",") 391 + .map((s) => parseInt(s.trim(), 10)) 392 + .filter((n) => Number.isInteger(n) && n >= 0 && n <= 127); 393 + } 394 + 395 + async function main() { 396 + log(`output: ${OUT_DIR}`); 397 + log(`note step: ${NOTE_STEP} (range A0..C8 = 21..108)`); 398 + if (DRY_RUN) warn("dry-run: planning only, no audio rendered"); 399 + 400 + const { path: sf2Path, name: sf2Name } = resolveSoundFont(); 401 + mkdirSync(OUT_DIR, { recursive: true }); 402 + 403 + const programs = selectedPrograms(); 404 + const melodicNotes = planMelodicNotes(NOTE_STEP); 405 + const drumNotes = planDrumNotes(); 406 + 407 + const manifest = { 408 + soundfont: sf2Name, 409 + license: "https://schristiancollins.com/generaluser.php", 410 + noteStep: NOTE_STEP, 411 + format: "mp3", 412 + sampleRate: SAMPLE_RATE, 413 + bitrate: BITRATE, 414 + channels: 1, 415 + durationSec: TOTAL_SEC, 416 + holdSec: HOLD_SEC, 417 + releaseSec: RELEASE_SEC, 418 + patches: [], 419 + drumKits: [], 420 + }; 421 + 422 + const tmp = tmpdir(); 423 + let rendered = 0; 424 + let skipped = 0; 425 + const failures = []; 426 + 427 + // Melodic programs. 428 + if (!SKIP_MELODIC) { 429 + for (const program of programs) { 430 + const dirName = String(program).padStart(3, "0"); 431 + const progDir = join(OUT_DIR, dirName); 432 + mkdirSync(progDir, { recursive: true }); 433 + manifest.patches.push({ 434 + id: program, 435 + name: GM_PATCHES[program], 436 + notes: [...melodicNotes], 437 + }); 438 + for (const note of melodicNotes) { 439 + const noteName = midiToName(note); 440 + const mp3File = join(progDir, `${noteName}.mp3`); 441 + if (existsSync(mp3File) && statSync(mp3File).size > 0) { 442 + skipped++; 443 + continue; 444 + } 445 + if (DRY_RUN) { 446 + rendered++; 447 + continue; 448 + } 449 + const midiFile = join(tmp, `gm-${program}-${note}.mid`); 450 + const wavFile = join(tmp, `gm-${program}-${note}.wav`); 451 + try { 452 + writeFileSync(midiFile, buildMidi({ program, note, isDrum: false })); 453 + renderOne({ sf2: sf2Path, midiFile, wavFile, mp3File }); 454 + rendered++; 455 + if (rendered % 25 === 0) { 456 + process.stdout.write( 457 + `${C.dim} rendered ${rendered} (skipped ${skipped})${C.reset}\n`, 458 + ); 459 + } 460 + } catch (err) { 461 + failures.push({ program, note, error: err.message }); 462 + warn(`program ${program} note ${note}: ${err.message.split("\n")[0]}`); 463 + } finally { 464 + rmSync(midiFile, { force: true }); 465 + rmSync(wavFile, { force: true }); 466 + } 467 + } 468 + } 469 + } 470 + 471 + // Drum kit (GM Standard Kit on channel 10, program 0). 472 + if (!SKIP_DRUMS) { 473 + const drumDir = join(OUT_DIR, "drum-000"); 474 + mkdirSync(drumDir, { recursive: true }); 475 + manifest.drumKits.push({ 476 + id: 0, 477 + name: "Standard Kit", 478 + notes: drumNotes, 479 + noteNames: GM_DRUM_KIT_NOTE_NAMES, 480 + }); 481 + for (const note of drumNotes) { 482 + const mp3File = join(drumDir, `${note}.mp3`); 483 + if (existsSync(mp3File) && statSync(mp3File).size > 0) { 484 + skipped++; 485 + continue; 486 + } 487 + if (DRY_RUN) { 488 + rendered++; 489 + continue; 490 + } 491 + const midiFile = join(tmp, `gm-drum-${note}.mid`); 492 + const wavFile = join(tmp, `gm-drum-${note}.wav`); 493 + try { 494 + writeFileSync(midiFile, buildMidi({ program: 0, note, isDrum: true })); 495 + renderOne({ sf2: sf2Path, midiFile, wavFile, mp3File }); 496 + rendered++; 497 + } catch (err) { 498 + failures.push({ kit: 0, note, error: err.message }); 499 + warn(`drum note ${note}: ${err.message.split("\n")[0]}`); 500 + } finally { 501 + rmSync(midiFile, { force: true }); 502 + rmSync(wavFile, { force: true }); 503 + } 504 + } 505 + } 506 + 507 + // Manifest + license. 508 + writeFileSync( 509 + join(OUT_DIR, "manifest.json"), 510 + JSON.stringify(manifest, null, 2) + "\n", 511 + ); 512 + writeFileSync(join(OUT_DIR, "LICENSE.txt"), GENERALUSER_LICENSE); 513 + 514 + log(`done. rendered=${rendered} skipped=${skipped} failures=${failures.length}`); 515 + if (failures.length > 0) { 516 + warn(`${failures.length} render failure(s); see warnings above`); 517 + } 518 + console.log(""); 519 + console.log(` manifest: ${join(OUT_DIR, "manifest.json")}`); 520 + console.log(` license: ${join(OUT_DIR, "LICENSE.txt")}`); 521 + console.log(""); 522 + console.log(` publish: npm run assets:sync:up # uploads to assets.aesthetic.computer/gm/`); 523 + } 524 + 525 + main().catch((err) => { 526 + console.error(err); 527 + process.exit(1); 528 + });
+225
plans/notepat-gm-integration.md
··· 1 + # Notepat GM Integration Plan 2 + 3 + ## 1. Conflict audit — top-row digits 0–9 in notepat.mjs 4 + 5 + I searched all keyboard handlers for digit bindings. 6 + 7 + **Result: there are NO existing keyboard handlers for digit keys 0–9 in notepat.mjs.** Confirmed via: 8 + - `grep -nE 'keyboard:down:[0-9]'` — zero hits. 9 + - `grep -nE '"[0-9]"'` — only finds glyph-resolution lookups (typeface lookups for the character "0"), the `buttonOctaves` array (line 1058 — listing octave *numbers* as strings used as octBtn labels, not key bindings), DAW query parsing (line 1468), and HID scancode→key tables (lines 30–31, used by NuPhy WebHID, not the keyboard event bus). 10 + 11 + **Adjacent bindings that are nearby on the keyboard but not on digit row:** 12 + - `,` (comma) and `.` (period) — `upperOctaveShift -=/+= 1` (lines 6535, 6539). Keep — not on digit row. 13 + - `-` and `=` — paint overlay toggles (lines 6626–6627). Keep. 14 + - `tab` — cycles `waveIndex` (line 6613–6620). **Repurpose / keep:** still cycles wave but now wave list begins with `"gm"`. 15 + - `space` — metronome toggle (line 7281). Keep. 16 + - `arrowup/down/left/right` — drum pads (lines 7312–7334). Keep. 17 + - `alt` — crash/ride drums (lines 7300–7310). Keep. 18 + - `shift`, `enter`, `backspace`, `escape`, `/`, `\`, `` ` `` — various toggles. Keep. 19 + 20 + **Conclusion: digit row is fully free.** No conflicts. All 10 digits (0–9) can be claimed for the GM digit-buffered picker without breaking anything. 21 + 22 + The NuPhy HID table at lines 30–31 maps HID scancodes to digit *strings* — that is hardware-side translation, not an `act` handler, and the resulting `keyboard:down:0` … `keyboard:down:9` events flow through the same dispatcher that currently ignores digits. Once we add a digit handler, NuPhy digit presses will pick GM patches just like an OS keyboard. (Worth a one-line comment in the code.) 23 + 24 + ## 2. State changes 25 + 26 + New module-level state to add near line 393 (next to `wave`): 27 + 28 + ```js 29 + // GM (General MIDI) sound backend — see lib/gm.mjs 30 + let gmReady = false; // True once gm.loadManifest resolves. 31 + let gmManifest = null; // Cached manifest from gm.mjs. 32 + let gmProgram = 0; // Current GM program 0..127 (0 = Acoustic Grand Piano). 33 + let gmPatch = null; // Currently loaded patch handle from gm.loadPatch. 34 + let gmPatchLoading = false; // In-flight guard. 35 + let gmDrumKit = null; // Optional GM drum kit handle. 36 + let gmDigitBuffer = ""; // "0".."999" while user types. 37 + let gmDigitBufferDeadline = 0; // performance.now() when buffer auto-commits. 38 + const GM_DIGIT_TIMEOUT_MS = 700; // Auto-commit window (matches user spec). 39 + const GM_PROGRAM_NAMES = [/* 128 GM names */]; // For the HUD label. 40 + // Map of active GM voices: voiceId -> noteHandle returned by patch.play(midi). 41 + const gmVoices = new Map(); 42 + ``` 43 + 44 + Existing variables changed: 45 + 46 + - `wavetypes` (line 380) becomes `["gm", ...legacyWavetypes]`. Keep the legacy names exactly so saved settings still work. 47 + - `STARTING_WAVE` (line 392) → `"gm"`. 48 + - `waveIndex` (line 391) → `0` (still index zero of new array). 49 + - `shortWaveNames` (line 8504) — add `gm: "GM"`. 50 + - `displayWave` formatting (line 8514) — when `wave !== "gm"`, optionally prefix with `"L:"` to make it visually clear the user is in legacy mode (decision needed — see §8). 51 + 52 + ## 3. Sound dispatch changes 53 + 54 + The unified branch point lives in `makeNoteSound(tone, velocity, pan)` at **line 5967**. The current `if/else if/else` ladder distinguishes `stample`/`sample` → `play()`, `composite` → multi-synth, default → single `synth({ type: synthType })`. 55 + 56 + Plan: add `wave === "gm"` as the **first** branch. This branch must: 57 + 58 + 1. Convert notepat's `tone` (a string like `"4C"`, `"5G#"`) to a MIDI note number. Use `soundContext.freq(tone)` to get Hz, then `12 * log2(hz/440) + 69`. Or expose a helper from `lib/note-colors.mjs` if one already does this. (Decision: add a tiny inline `toneToMidi(tone)` helper at module scope; see §8.) 59 + 2. Bail to oscillator path (`return synth({ type: "sine", … })`) if `!gmReady || !gmPatch` — graceful fallback while GM is still loading or assets missing. 60 + 3. Call `const noteHandle = gmPatch.play(midi, { velocity, pan });` 61 + 4. Wrap in a uniform return object with `kill(fade)` → `noteHandle.release()` and a no-op `update()` (or a real `update()` if the gm.mjs API lands one — currently it does not, per the agent's prompt). 62 + 5. Track in `gmVoices` keyed by the `voiceId` so panic / clearHeldVoices can reach them. 63 + 64 + Drum case: at **line 6146** the existing `wave === "drum"` branch calls `playPercussion(...)`. Add a parallel branch *above* it: `if (wave === "gm" && /* user mapped drum kit */) { gmDrumKit?.play(midiDrumNote); return true; }`. **Decision needed (§8):** simplest cut is to keep `wave === "drum"` doing the existing percussion lib, and let GM stay melodic-only for v1. Drums via GM kit can be a follow-up — the bake agent is producing per-instrument packs anyway and a GM drum kit is patch 128 conceptually. 65 + 66 + Pitch bend in `applyPitchBendToNotes` (line 6082) needs a GM branch too — for v1 we can skip live pitch bend on GM voices (just return early when `wave === "gm"`) since the gm.mjs API doesn't expose a per-voice frequency setter. Document this limitation. 67 + 68 + ## 4. Tab / legacy UX 69 + 70 + **`wavetypes` ordering (line 380):** 71 + 72 + ```js 73 + const wavetypes = [ 74 + "gm", // 0 — General MIDI (default) 75 + "sine", // 1 76 + "triangle", // 2 77 + "sawtooth", // 3 78 + "square", 79 + "harp", 80 + "whistle", 81 + "composite", 82 + "stample", 83 + "drum", 84 + ]; 85 + ``` 86 + 87 + Tab key (line 6613) and waveBtn `push` (line 7383) already cycle `waveIndex = (waveIndex + 1) % wavetypes.length`. No change to that mechanism — they automatically include "gm" as position 0. 88 + 89 + **Display label (`buildWaveButton` ~line 8500):** when `wave === "gm"` the button label is `GM:078` (current program shown so the user always knows what's loaded). When in any legacy wave, the label keeps the wave name (`sine`, `tri`, etc.). Optional polish: dim the legacy labels or add a `[L]` prefix to signal they're not the default. 90 + 91 + The drum special case at line 1133 comment stays — `wave === "drum"` still routes through `lib/percussion.mjs`. 92 + 93 + ## 5. Digit-buffered picker — pseudocode 94 + 95 + Insertion point: in `act` near the existing tab handler (line 6613), well above the percussion arrow handlers. 96 + 97 + ```js 98 + // GM patch picker — type a 1-3 digit decimal program number. 99 + // Mirrors menubands behavior with one addition: a 700ms timeout 100 + // auto-commits and clears the buffer if the user pauses, matching 101 + // the spec. Buffer also clears on any non-digit key press, on 102 + // reaching 3 digits, or on a note key being struck. 103 + { 104 + const digitMatch = e.is("keyboard:down") && /^[0-9]$/.test(e.key) && !e.repeat; 105 + const now = performance.now(); 106 + 107 + // Auto-commit on timeout before processing this event. 108 + if (gmDigitBuffer && now > gmDigitBufferDeadline) { 109 + gmDigitBuffer = ""; 110 + } 111 + 112 + if (digitMatch) { 113 + if (gmDigitBuffer.length >= 3) gmDigitBuffer = ""; 114 + gmDigitBuffer += e.key; 115 + gmDigitBufferDeadline = now + GM_DIGIT_TIMEOUT_MS; 116 + 117 + const v = parseInt(gmDigitBuffer, 10); 118 + // Decision: notepat does not have menuband's MIDI-passthrough 119 + // mode, so "0" / "00" / "000" maps to GM program 0 (Acoustic 120 + // Grand Piano) instead of toggling a passthrough. Document this 121 + // divergence from menuband. See §8. 122 + const program = v === 0 ? 0 : Math.max(0, Math.min(127, v - 1)); 123 + 124 + // Switch to GM if the user is currently on a legacy wave, so 125 + // typing a digit always produces a GM voice. (Optional — could 126 + // also just queue the program for next time wave=="gm". Pick 127 + // the auto-switch behavior; it's friendlier.) 128 + if (wave !== "gm") { 129 + waveIndex = wavetypes.indexOf("gm"); 130 + wave = "gm"; 131 + buildWaveButton(api); 132 + } 133 + 134 + setGmProgram(program); // async: loads patch, swaps gmPatch on resolve. 135 + api.beep(); // Subtle audio confirmation. 136 + return; 137 + } 138 + 139 + // Non-digit key: clear the buffer (don't auto-commit anything new 140 + // — the program was already applied live on each digit). 141 + if (e.is("keyboard:down") && gmDigitBuffer) { 142 + gmDigitBuffer = ""; 143 + } 144 + } 145 + ``` 146 + 147 + Helper: 148 + 149 + ```js 150 + async function setGmProgram(program) { 151 + gmProgram = program; 152 + if (gmPatchLoading) return; // Latest call wins via re-check after await. 153 + gmPatchLoading = true; 154 + try { 155 + const next = await gm.loadPatch(program, window.audioContext); 156 + if (program === gmProgram) { 157 + gmPatch = next; // Apply only if user hasn't typed past us. 158 + } 159 + buildWaveButton(api); // Refresh "GM:078" label. 160 + } catch (err) { 161 + console.warn("🎼 GM load failed", program, err); 162 + } finally { 163 + gmPatchLoading = false; 164 + } 165 + } 166 + ``` 167 + 168 + Debug HUD: `gmDigitBuffer` is rendered as `GM:078` while typing in the existing top-bar status row (next to the wave button) so the user sees the partial buffer live. After commit, the label shows the full program name (e.g., `78 Whistle`). 169 + 170 + Note key resets buffer: in `startButtonNote` (line 6123) and `startRelayButtonNote` (line ~5780), add `gmDigitBuffer = ""` at the top — mirrors menuband line 1176. 171 + 172 + ## 6. MIDI input parity 173 + 174 + **Recommend yes** — incoming MIDI program-change messages should switch the GM patch. 175 + 176 + In `lib/midi.mjs` line 13, the filter `if (command !== NOTE_ON && command !== NOTE_OFF && command !== PITCH_BEND) return;` drops program-change. Add `0xC0` (PROGRAM_CHANGE) to the allow-list and forward it via `acSEND`. 177 + 178 + In notepat.mjs `act`'s `midi:keyboard` block (line 7233), after the existing `MIDI_PITCH_BEND` branch, add: 179 + 180 + ```js 181 + const MIDI_PROGRAM_CHANGE = 0xC0; 182 + if (command === MIDI_PROGRAM_CHANGE) { 183 + const program = e.data?.[1] ?? 0; 184 + setGmProgram(Math.max(0, Math.min(127, program))); 185 + return; 186 + } 187 + ``` 188 + 189 + This keeps the relay/MIDI plumbing in the existing single `midi:keyboard` event channel; no new event type needed. 190 + 191 + ## 7. Step-by-step implementation order 192 + 193 + 1. **Add `gm.mjs` import + state (~30 lines).** Top of notepat.mjs near other imports. Add module-level state from §2. *Blocks on:* parallel agent's gm.mjs landing — but the import will simply 404 until then; we can guard with a try/catch around `await import(...)`. For development, a stubbed local gm.mjs that no-ops is sufficient. **Can start immediately.** 194 + 2. **Extend `wavetypes` array + `displayWave` map (~10 lines).** Lines 380, 8504. Independent — can land first. 195 + 3. **Boot-time GM init (~20 lines).** In `boot()` around line 1457, after `setSoundContext`, call `await gm.loadManifest(window.audioContext)` and `setGmProgram(0)`. Set `gmReady = true`. *Blocks on:* gm.mjs API. 196 + 4. **`makeNoteSound` GM branch (~25 lines).** Line 5967. Add the early-exit gm path with fallback. *Blocks on:* gm.mjs play API. 197 + 5. **Stop-path / panic / pitch-bend GM handling (~20 lines).** `stopButtonNote`, `clearHeldVoices`, escape panic block (line 6640), `applyPitchBendToNotes` early-return for gm. Independent of gm.mjs once the play path is shaped — uses the returned handle's `release()`. 198 + 6. **Digit picker handler (~35 lines).** Insert in `act()` around line 6620 (just after the tab/wave handler so digits can't clobber tab cycling). Add `gmDigitBuffer = ""` resets in `startButtonNote` and `startRelayButtonNote`. Independent. 199 + 7. **Wave-button label refresh (~5 lines).** `buildWaveButton` line 8500 — when `wave === "gm"`, label is `GM:NNN`. 200 + 8. **MIDI program-change forwarding (~10 lines).** `lib/midi.mjs` (allow 0xC0) + notepat.mjs midi:keyboard branch. Coordinate with the midi.mjs owner — minor edit, low risk. 201 + 9. **Bake-output verification (manual).** Once the bake agent publishes to `assets.aesthetic.computer/gm/`, smoke-test by switching to a few patches (1, 25, 78, 128) and a drum kit. *Blocks on:* bake landing. 202 + 10. **Optional polish.** GM drum kit routing, sustain/pan parity for GM voices, persisted last-program in `store`. 203 + 204 + Total disk-side change ≈ 150–180 net lines added, ~10 lines edited. 205 + 206 + ## 8. Risks / open questions — decisions needed 207 + 208 + 1. **0 / 00 / 000 mapping.** Menuband uses these for "MIDI passthrough." Notepat has no equivalent backend slot. **Recommended decision:** map `0`/`00`/`000` to GM program 0 (Acoustic Grand). Document the divergence in a code comment. Alternative: use `0` to *toggle MIDI passthrough* — i.e., temporarily disable GM and let the user's external synth handle voicing. Probably overkill for v1. 209 + 2. **Auto-switch wave on digit press?** Plan above auto-switches `wave` to `"gm"` when the user types a digit while in legacy mode. Alternative: queue the program but stay in legacy mode. **Recommended:** auto-switch — matches user intent ("I'm picking an instrument"). 210 + 3. **gm.mjs API timing.** If notepat ships before `gm.mjs` does, the import will 404 and break boot. Wrap in `try { gmModule = await import("../lib/gm.mjs"); } catch { gmModule = null; }` and treat `gmModule == null` as "fall back to oscillator forever." This lets disk-side ship independently. (Recommended.) 211 + 4. **Bake assets missing at runtime.** `gm.loadManifest` will fail on first run if `assets.aesthetic.computer/gm/` is empty. The plan's fallback (`!gmReady || !gmPatch` → oscillator path) handles this, but the user will silently get sine instead of a GM voice. **Recommended:** when GM fails to load, surface a one-time HUD note (`"GM unavailable, using oscillator"`) and keep the wave label as `gm` but parenthesized: `(gm)`. 212 + 5. **Pitch bend on GM voices.** gm.mjs API as documented does not expose per-voice frequency setters. v1 plan: skip pitch bend for GM voices (early-return). **Decision needed:** acceptable for v1, or block on gm.mjs adding `noteHandle.setDetune(cents)`? 213 + 6. **`wave === "gm" && wave === "drum"` collision.** If the user wants a GM drum kit, today's `wavetypes` entry `"drum"` means percussion-lib drums, not GM kit. v1 plan keeps `"drum"` as legacy percussion-lib and leaves "GM drum kit" as a follow-up. **Decision needed:** add a separate `"gm-drum"` wavetypes entry, or extend program > 127 to mean drum kit? 214 + 7. **`tone` → MIDI note conversion.** notepat's `tone` is a string. `soundContext.freq(tone)` returns Hz. `Math.round(12 * Math.log2(hz/440) + 69)` produces the MIDI note. **Decision needed:** put the helper in notepat.mjs locally, or add to `lib/note-colors.mjs` for reuse? 215 + 8. **Velocity scaling.** gm.mjs `play(note, { velocity })` semantics not documented (0–127? 0–1?). Assume 0–127 to match menuband. **Coordinate with the gm.mjs author.** 216 + 9. **Voice stealing.** If the user holds a chord on GM and the patch's polyphony cap is exceeded, who wins? Probably gm.mjs handles internally; notepat just calls `play` and trusts the player. Document. 217 + 10. **`gmDigitBuffer` and NuPhy.** NuPhy delivers digit keys as `keyboard:down:0`–`9` through the same bus, so the picker works for hardware presses too — desirable. 218 + 219 + ## Critical Files for Implementation 220 + 221 + - /Users/jas/aesthetic-computer/system/public/aesthetic.computer/disks/notepat.mjs 222 + - /Users/jas/aesthetic-computer/system/public/aesthetic.computer/lib/gm.mjs 223 + - /Users/jas/aesthetic-computer/system/public/aesthetic.computer/lib/midi.mjs 224 + - /Users/jas/aesthetic-computer/slab/menuband/Sources/MenuBand/MenuBandController.swift 225 + - /Users/jas/aesthetic-computer/system/public/aesthetic.computer/lib/note-colors.mjs
+178 -11
system/public/aesthetic.computer/disks/notepat.mjs
··· 13 13 loadPaintingAsAudio, 14 14 } from "../lib/pixel-sample.mjs"; 15 15 import { playPercussion } from "../lib/percussion.mjs"; 16 + import * as gm from "../lib/gm.mjs"; 16 17 17 18 // 🎹 NuPhy Air60 HE — WebHID analog pressure support 18 19 // Sends activation commands then reads 0xA0 analog reports with per-key pressure. ··· 378 379 379 380 let STARTING_OCTAVE = "4"; 380 381 const wavetypes = [ 381 - "sine", // 0 382 - "triangle", // 1 383 - "sawtooth", // 2 384 - "square", // 3 385 - "harp", // 4 - Karplus-Strong plucked string (Karplus & Strong 1983) 386 - "whistle", // 5 - digital waveguide flute (Cook/STK) 387 - "composite", // 6 388 - "stample", // 7 389 - "drum", // 8 - shared 12-drum kit (lib/percussion.mjs), both octaves 382 + "gm", // 0 - General MIDI (lib/gm.mjs); number-row digits pick programs 0-127 383 + "sine", // 1 384 + "triangle", // 2 385 + "sawtooth", // 3 386 + "square", // 4 387 + "harp", // 5 - Karplus-Strong plucked string (Karplus & Strong 1983) 388 + "whistle", // 6 - digital waveguide flute (Cook/STK) 389 + "composite", // 7 390 + "stample", // 8 391 + "drum", // 9 - shared 12-drum kit (lib/percussion.mjs), both octaves 390 392 ]; 391 393 let waveIndex = 0; // 0; 392 394 const STARTING_WAVE = wavetypes[waveIndex]; //"sine"; ··· 398 400 let roomAmount = 0.5; // 🎚️ Room/reverb amount (0-1) 399 401 let glitchMode = false; // 🧩 Global glitch toggle 400 402 let octave = STARTING_OCTAVE; 403 + 404 + // 🎼 GM (General MIDI) state — see lib/gm.mjs 405 + // Number-row digits 0-9 type into a 1-3 digit decimal buffer that picks GM 406 + // program 0-127 live (mirrors menuband). 700ms pause auto-clears the buffer. 407 + // "0" / "00" / "000" map to program 0 (Acoustic Grand Piano) — notepat has 408 + // no MIDI-passthrough slot like menuband does. 409 + let gmReady = false; 410 + let gmProgram = 0; 411 + let gmPatch = null; 412 + let gmPatchLoading = false; 413 + let gmInitStarted = false; 414 + let gmDigitBuffer = ""; 415 + let gmDigitBufferDeadline = 0; 416 + const GM_DIGIT_TIMEOUT_MS = 700; 417 + 418 + // Fire the GM bootstrap exactly once, as soon as window.audioContext is 419 + // available. Browsers gate audio creation behind the first user gesture, 420 + // so this can't run at boot — it has to wait for setSoundContext to see 421 + // a real context. 422 + function maybeInitGM() { 423 + if (gmInitStarted) return; 424 + if (typeof window === "undefined" || !(/** @type {any} */ (window).audioContext)) return; 425 + gmInitStarted = true; 426 + (async () => { 427 + try { 428 + await gm.loadManifest(); 429 + const patch = await gm.loadPatch(gmProgram); 430 + if (patch) { 431 + gmPatch = patch; 432 + gmReady = true; 433 + } 434 + } catch (err) { 435 + console.warn("🎼 GM init failed — staying on oscillator fallback:", err); 436 + } 437 + })(); 438 + } 439 + 401 440 let keys = ""; 402 441 let tap = false; 403 442 let tapIndex = 0; ··· 1662 1701 // } 1663 1702 1664 1703 const wavetypes = [ 1704 + "gm", 1665 1705 "square", 1666 1706 "sine", 1667 1707 "triangle", ··· 5683 5723 5684 5724 function setSoundContext(ctx) { 5685 5725 soundContext = ctx; 5726 + maybeInitGM(); 5686 5727 } 5687 5728 5688 5729 function lowerBaseOctave() { ··· 5964 6005 } 5965 6006 } 5966 6007 6008 + // 🎼 Switch the live GM patch. Awaitable; safely no-ops if a newer call 6009 + // supersedes this one mid-load. Triggered by the digit-buffered picker 6010 + // in `act()` and by incoming MIDI program-change messages. 6011 + async function setGmProgram(program, apiRef) { 6012 + const next = Math.max(0, Math.min(127, program | 0)); 6013 + gmProgram = next; 6014 + if (gmPatchLoading) return; 6015 + gmPatchLoading = true; 6016 + try { 6017 + const patch = await gm.loadPatch(next); 6018 + if (gmProgram === next) { 6019 + gmPatch = patch; 6020 + gmReady = true; 6021 + } 6022 + if (apiRef) buildWaveButton(apiRef); 6023 + } catch (err) { 6024 + console.warn("🎼 GM: loadPatch failed for", next, err); 6025 + } finally { 6026 + gmPatchLoading = false; 6027 + } 6028 + } 6029 + 5967 6030 function makeNoteSound(tone, velocity = 127, pan = 0) { 5968 6031 const synth = soundContext?.synth; 5969 6032 const play = soundContext?.play; ··· 5991 6054 const minVelocityVolume = 0.05; // Keep a subtle floor so very light taps still play. 5992 6055 const volumeScale = minVelocityVolume + (1 - minVelocityVolume) * velocityRatio; 5993 6056 5994 - if (wave === "stample" || wave === "sample") { 6057 + if (wave === "gm") { 6058 + // 🎼 GM playback via lib/gm.mjs. Falls back to a sine oscillator if the 6059 + // patch isn't ready yet (manifest still loading, or asset host down). 6060 + // The shim mimics the synth handle shape — { startedAt, kill, update } — 6061 + // so the rest of notepat (panic, sustain, pitch-bend) doesn't have to 6062 + // care which backend played the note. Pitch-bend update is a no-op for 6063 + // GM voices in v1; gm.mjs doesn't expose a per-voice frequency setter. 6064 + if (gmReady && gmPatch) { 6065 + const hz = freq(tone); 6066 + const midi = Math.round(12 * Math.log2(hz / 440) + 69); 6067 + const ctx = 6068 + typeof window !== "undefined" ? /** @type {any} */ (window).audioContext : null; 6069 + const startedAt = ctx?.currentTime ?? performance.now() / 1000; 6070 + const noteHandle = gmPatch.play(midi, { 6071 + velocity: Math.round(velocityRatio * 127), 6072 + }); 6073 + return { 6074 + startedAt, 6075 + kill: (_fade) => noteHandle.release(), 6076 + update: () => {}, 6077 + }; 6078 + } 6079 + // Fallback while GM is still loading or unavailable. 6080 + return synth({ 6081 + type: "sine", 6082 + attack: quickFade ? 0.0015 : attack, 6083 + tone, 6084 + duration: "🔁", 6085 + volume: toneVolume * volumeScale, 6086 + pan, 6087 + }); 6088 + } else if (wave === "stample" || wave === "sample") { 5995 6089 const sampleId = stampleSampleId || startupSfx; 5996 6090 return play(sampleId, { 5997 6091 volume: volumeScale, ··· 6619 6713 buildOsButton(api); 6620 6714 } 6621 6715 6716 + // 🎼 GM digit-buffered patch picker — top-row 0-9 like menuband. Each 6717 + // digit press updates the live GM program; the buffer auto-clears after 6718 + // GM_DIGIT_TIMEOUT_MS of inactivity or when a non-digit key arrives. 6719 + // NuPhy WebHID also delivers digits through this channel, so hardware 6720 + // presses pick patches too. 6721 + { 6722 + const isKbdDown = e.is("keyboard:down") && !e.repeat; 6723 + const digit = isKbdDown && /^[0-9]$/.test(e.key) ? e.key : null; 6724 + const now = performance.now(); 6725 + 6726 + if (gmDigitBuffer && now > gmDigitBufferDeadline) { 6727 + gmDigitBuffer = ""; 6728 + } 6729 + 6730 + if (digit) { 6731 + if (gmDigitBuffer.length >= 3) gmDigitBuffer = ""; 6732 + gmDigitBuffer += digit; 6733 + gmDigitBufferDeadline = now + GM_DIGIT_TIMEOUT_MS; 6734 + 6735 + // "0" / "00" / "000" → program 0; otherwise typed value clamped 0-127. 6736 + const v = parseInt(gmDigitBuffer, 10); 6737 + const program = Math.max(0, Math.min(127, v)); 6738 + 6739 + // Friendly auto-switch: typing a digit while in a legacy wave snaps 6740 + // the wave switcher to "gm" so the user actually hears the program 6741 + // they just picked. 6742 + if (wave !== "gm") { 6743 + waveIndex = wavetypes.indexOf("gm"); 6744 + if (waveIndex >= 0) { 6745 + wave = "gm"; 6746 + buildAbletonButton(api); 6747 + buildOsButton(api); 6748 + } 6749 + } 6750 + 6751 + setGmProgram(program, api); 6752 + buildWaveButton(api); // Immediate label refresh — shows the buffer live. 6753 + api.beep(); 6754 + return; 6755 + } 6756 + 6757 + // Non-digit keypress clears any pending buffer (the live program was 6758 + // already applied per-digit, so nothing to commit). 6759 + if (isKbdDown && gmDigitBuffer) { 6760 + gmDigitBuffer = ""; 6761 + } 6762 + } 6763 + 6622 6764 // if (e.is("keyboard:down:shift") && !e.repeat) { 6623 6765 // lowerOctaveShift -= 1; 6624 6766 // } ··· 7016 7158 const MIDI_NOTE_ON = 0x90; 7017 7159 const MIDI_NOTE_OFF = 0x80; 7018 7160 const MIDI_PITCH_BEND = 0xe0; 7161 + const MIDI_PROGRAM_CHANGE = 0xc0; 7019 7162 7020 7163 const midiNoteToButton = (noteNumber) => { 7021 7164 if (!midiUtil?.note || typeof noteNumber !== "number") return null; ··· 7252 7395 applyPitchBendToNotes(undefined, { immediate: true }); 7253 7396 } 7254 7397 7398 + return; 7399 + } 7400 + 7401 + // 🎼 Incoming GM program-change → switch the active GM patch. Auto- 7402 + // snaps the wave switcher to "gm" so the user hears the new voice. 7403 + if (command === MIDI_PROGRAM_CHANGE) { 7404 + const program = noteNumber ?? 0; 7405 + if (wave !== "gm") { 7406 + const idx = wavetypes.indexOf("gm"); 7407 + if (idx >= 0) { 7408 + waveIndex = idx; 7409 + wave = "gm"; 7410 + } 7411 + } 7412 + setGmProgram(program, api); 7255 7413 return; 7256 7414 } 7257 7415 ··· 8510 8668 composite: "cmp", 8511 8669 stample: "stp", 8512 8670 drum: "drm", 8671 + gm: "gm", 8513 8672 }; 8514 - const displayWave = isNarrow ? (shortWaveNames[wave] || wave.slice(0, 3)) : wave; 8673 + // GM wave shows the live program/buffer so the user always knows what's 8674 + // loaded and what they're partway through typing. 8675 + let displayWave; 8676 + if (wave === "gm") { 8677 + const shown = gmDigitBuffer || String(gmProgram).padStart(3, "0"); 8678 + displayWave = isNarrow ? `g${shown}` : `GM:${shown}`; 8679 + } else { 8680 + displayWave = isNarrow ? (shortWaveNames[wave] || wave.slice(0, 3)) : wave; 8681 + } 8515 8682 const waveWidth = displayWave.length * glyphWidth; 8516 8683 const margin = isNarrow ? 2 : 4; 8517 8684 waveBtn = new ui.Button(
+490
system/public/aesthetic.computer/lib/gm.mjs
··· 1 + // GM (General MIDI) sample-based player. 2 + // 3 + // Loads pitched melodic patches and the GM standard drum kit from the 4 + // AC asset CDN and plays them through the existing browser AudioContext 5 + // (`window.audioContext`, set up by bios.mjs). Each patch is sparsely 6 + // rendered (every 3rd semitone) so this player pitch-shifts the nearest 7 + // rendered sample via Web Audio's `playbackRate` to fill in missing 8 + // notes, applies a snappy ADSR-ish gain envelope, and exposes per-note 9 + // stop()/release() handles. 10 + // 11 + // Asset layout (produced by the lith bake script — see lith/): 12 + // https://assets.aesthetic.computer/gm/manifest.json 13 + // https://assets.aesthetic.computer/gm/<NNN>/<note>.mp3 (NNN = 000..127) 14 + // https://assets.aesthetic.computer/gm/drum-000/<note>.mp3 15 + // 16 + // Note files use scientific-pitch names with `s` for sharps: 17 + // C4.mp3, Cs4.mp3, D4.mp3, Ds4.mp3, ... A4.mp3, As4.mp3, B4.mp3 18 + // 19 + // This module has NO build step — it is a plain ES module loaded by the 20 + // runtime. It does not modify disk.mjs, midi.mjs, or any disk file; the 21 + // program-change wiring will be hooked up elsewhere. 22 + 23 + const ASSET_BASE = "https://assets.aesthetic.computer/gm"; 24 + const MANIFEST_URL = `${ASSET_BASE}/manifest.json`; 25 + 26 + // ─── AudioContext access ────────────────────────────────────────────── 27 + // AC's bios.mjs creates and stores a single AudioContext at 28 + // `window.audioContext`. All other lib helpers either receive a `sound` 29 + // API or read the global. We accept an explicit context for testability 30 + // and fall back to the window global. 31 + function resolveAudioContext(audioContext) { 32 + if (audioContext) return audioContext; 33 + const w = typeof window !== "undefined" ? /** @type {any} */ (window) : null; 34 + if (w && w.audioContext) return w.audioContext; 35 + return null; 36 + } 37 + 38 + // ─── Note name <-> MIDI helpers ─────────────────────────────────────── 39 + // Sample filenames use `s` (e.g. "Cs4") instead of "#" because URL 40 + // safety. Octave numbering is scientific-pitch: C4 = MIDI 60. 41 + const NOTE_TO_PC = { 42 + C: 0, Cs: 1, D: 2, Ds: 3, E: 4, F: 5, 43 + Fs: 6, G: 7, Gs: 8, A: 9, As: 10, B: 11, 44 + }; 45 + const PC_TO_NOTE = ["C", "Cs", "D", "Ds", "E", "F", "Fs", "G", "Gs", "A", "As", "B"]; 46 + 47 + export function midiToNoteName(midi) { 48 + const pc = ((midi % 12) + 12) % 12; 49 + const octave = Math.floor(midi / 12) - 1; 50 + return `${PC_TO_NOTE[pc]}${octave}`; 51 + } 52 + 53 + export function noteNameToMidi(name) { 54 + const m = /^([A-G])(s?)(-?\d+)$/.exec(name); 55 + if (!m) return null; 56 + const letter = m[1] + (m[2] || ""); 57 + const pc = NOTE_TO_PC[letter]; 58 + if (pc === undefined) return null; 59 + const octave = parseInt(m[3], 10); 60 + return (octave + 1) * 12 + pc; 61 + } 62 + 63 + // ─── Manifest ───────────────────────────────────────────────────────── 64 + // The bake script writes a manifest.json describing which programs are 65 + // available, which notes were rendered per program, sample rate, etc. 66 + // We tolerate a missing/incomplete manifest by falling back to a 67 + // default note grid (every 3rd semitone over MIDI 24..96). 68 + let _manifest = null; 69 + let _manifestPromise = null; 70 + 71 + const DEFAULT_NOTE_STEP = 3; 72 + const DEFAULT_LOW = 24; // C1 73 + const DEFAULT_HIGH = 96; // C7 74 + 75 + function defaultNoteList() { 76 + const list = []; 77 + for (let m = DEFAULT_LOW; m <= DEFAULT_HIGH; m += DEFAULT_NOTE_STEP) { 78 + list.push(m); 79 + } 80 + return list; 81 + } 82 + 83 + export async function loadManifest(audioContext) { 84 + // audioContext is accepted for API symmetry with loadPatch / loadDrumKit 85 + // (they need it to decode); manifest loading itself is pure JSON. 86 + void audioContext; 87 + if (_manifest) return _manifest; 88 + if (_manifestPromise) return _manifestPromise; 89 + 90 + _manifestPromise = (async () => { 91 + try { 92 + const res = await fetch(MANIFEST_URL, { cache: "force-cache" }); 93 + if (!res.ok) throw new Error(`HTTP ${res.status}`); 94 + const json = await res.json(); 95 + _manifest = normalizeManifest(json); 96 + } catch (err) { 97 + console.warn("🎼 GM: manifest unavailable, using defaults:", err.message); 98 + _manifest = { 99 + patches: {}, 100 + drumKits: { 0: { notes: defaultNoteList() } }, 101 + noteStep: DEFAULT_NOTE_STEP, 102 + defaultNotes: defaultNoteList(), 103 + _fallback: true, 104 + }; 105 + } 106 + return _manifest; 107 + })(); 108 + 109 + return _manifestPromise; 110 + } 111 + 112 + function normalizeManifest(raw) { 113 + const out = { 114 + patches: {}, 115 + drumKits: {}, 116 + noteStep: raw.noteStep || raw.step || DEFAULT_NOTE_STEP, 117 + defaultNotes: null, 118 + _raw: raw, 119 + }; 120 + // Patches may be an array, an object keyed by program number, or a 121 + // top-level `patches` field. Normalize to { [program]: { notes:[...] } }. 122 + const patchSrc = raw.patches || raw.programs || raw; 123 + if (Array.isArray(patchSrc)) { 124 + for (const entry of patchSrc) { 125 + if (entry?.program != null) { 126 + out.patches[entry.program] = { notes: entry.notes || null, name: entry.name }; 127 + } 128 + } 129 + } else if (patchSrc && typeof patchSrc === "object") { 130 + for (const [k, v] of Object.entries(patchSrc)) { 131 + const pn = parseInt(k, 10); 132 + if (!Number.isFinite(pn) || pn < 0 || pn > 127) continue; 133 + if (v && typeof v === "object") { 134 + out.patches[pn] = { notes: v.notes || null, name: v.name }; 135 + } 136 + } 137 + } 138 + const drumSrc = raw.drumKits || raw.drums || {}; 139 + for (const [k, v] of Object.entries(drumSrc)) { 140 + const id = parseInt(k, 10); 141 + if (!Number.isFinite(id)) continue; 142 + out.drumKits[id] = { notes: v?.notes || null, name: v?.name }; 143 + } 144 + if (!out.drumKits[0]) out.drumKits[0] = { notes: null }; 145 + out.defaultNotes = defaultNoteList(); 146 + return out; 147 + } 148 + 149 + // ─── Buffer fetch + decode (cached per URL) ─────────────────────────── 150 + const _bufferCache = new Map(); // url -> Promise<AudioBuffer | null> 151 + 152 + function fetchAndDecode(url, ctx) { 153 + if (_bufferCache.has(url)) return _bufferCache.get(url); 154 + const p = (async () => { 155 + try { 156 + const res = await fetch(url, { cache: "force-cache" }); 157 + if (!res.ok) throw new Error(`HTTP ${res.status}`); 158 + const arrayBuf = await res.arrayBuffer(); 159 + // decodeAudioData has both promise and callback forms. Use the 160 + // promise form; old Safari is wrapped by the AC bios so it should 161 + // be fine here. 162 + const audioBuf = await ctx.decodeAudioData(arrayBuf); 163 + return audioBuf; 164 + } catch (err) { 165 + console.warn(`🎼 GM: failed to load ${url}:`, err.message); 166 + return null; 167 + } 168 + })(); 169 + _bufferCache.set(url, p); 170 + return p; 171 + } 172 + 173 + // ─── Patch / kit handles ────────────────────────────────────────────── 174 + // A patch handle keeps its set of rendered MIDI notes and lazily loads 175 + // any individual sample on demand. `play()` finds the nearest rendered 176 + // note, pitch-shifts it, and schedules with an ADSR-ish envelope. 177 + 178 + const _patchCache = new Map(); // programNumber -> Promise<patchHandle> 179 + const _drumCache = new Map(); // kitId -> Promise<kitHandle> 180 + 181 + function programDir(programNumber) { 182 + return `${ASSET_BASE}/${String(programNumber).padStart(3, "0")}`; 183 + } 184 + 185 + function drumDir(kitId) { 186 + return `${ASSET_BASE}/drum-${String(kitId).padStart(3, "0")}`; 187 + } 188 + 189 + // Build the URL for a single rendered note (e.g. midi 60 -> ".../C4.mp3"). 190 + function noteUrl(dir, midi) { 191 + return `${dir}/${midiToNoteName(midi)}.mp3`; 192 + } 193 + 194 + // Pick the closest rendered MIDI note to a target. 195 + function nearestRenderedNote(noteList, target) { 196 + if (!noteList || noteList.length === 0) return null; 197 + let best = noteList[0]; 198 + let bestDist = Math.abs(best - target); 199 + for (let i = 1; i < noteList.length; i++) { 200 + const d = Math.abs(noteList[i] - target); 201 + if (d < bestDist) { 202 + best = noteList[i]; 203 + bestDist = d; 204 + } 205 + } 206 + return best; 207 + } 208 + 209 + // No-op note handle returned when something fails — keeps callers safe. 210 + function noopNoteHandle(reason) { 211 + return { 212 + stop() {}, 213 + release() {}, 214 + _failed: true, 215 + _reason: reason, 216 + }; 217 + } 218 + 219 + // Schedule a single sample with envelope. Returns a noteHandle. 220 + function scheduleNote(ctx, audioBuffer, opts) { 221 + const { 222 + playbackRate = 1, 223 + velocity = 100, 224 + when = 0, 225 + duration, // optional; if omitted note sustains until release() 226 + attack = 0.005, // 5ms snappy 227 + decay = 0.04, 228 + sustain = 0.85, // fraction of peak 229 + release = 0.15, // 150ms 230 + destination, 231 + } = opts; 232 + 233 + const startTime = when || ctx.currentTime; 234 + const peak = Math.max(0, Math.min(1, velocity / 127)); 235 + 236 + let source, gain; 237 + try { 238 + source = ctx.createBufferSource(); 239 + source.buffer = audioBuffer; 240 + source.playbackRate.value = playbackRate; 241 + 242 + gain = ctx.createGain(); 243 + gain.gain.setValueAtTime(0, startTime); 244 + gain.gain.linearRampToValueAtTime(peak, startTime + attack); 245 + gain.gain.linearRampToValueAtTime( 246 + peak * sustain, 247 + startTime + attack + decay, 248 + ); 249 + 250 + source.connect(gain); 251 + gain.connect(destination || ctx.destination); 252 + 253 + source.start(startTime); 254 + } catch (err) { 255 + console.warn("🎼 GM: scheduleNote failed:", err); 256 + return noopNoteHandle("schedule-failed"); 257 + } 258 + 259 + let stopped = false; 260 + let scheduledStop = null; 261 + 262 + const releaseAt = (t) => { 263 + if (stopped) return; 264 + stopped = true; 265 + const time = Math.max(t, ctx.currentTime); 266 + try { 267 + gain.gain.cancelScheduledValues(time); 268 + // Hold current value, then ramp to 0 over `release` seconds. 269 + const current = gain.gain.value; 270 + gain.gain.setValueAtTime(current, time); 271 + gain.gain.linearRampToValueAtTime(0.0001, time + release); 272 + // Schedule actual stop slightly after the release fade. 273 + scheduledStop = time + release + 0.02; 274 + source.stop(scheduledStop); 275 + } catch (err) { 276 + // Already stopped or invalid state — ignore. 277 + } 278 + }; 279 + 280 + // If a fixed duration was supplied, schedule the release at start+dur. 281 + if (typeof duration === "number" && duration > 0) { 282 + releaseAt(startTime + duration); 283 + } 284 + 285 + return { 286 + stop(when = 0) { 287 + releaseAt(when || ctx.currentTime); 288 + }, 289 + release() { 290 + releaseAt(ctx.currentTime); 291 + }, 292 + get _node() { return source; }, 293 + get _gain() { return gain; }, 294 + }; 295 + } 296 + 297 + // ─── Patch loader ───────────────────────────────────────────────────── 298 + export async function loadPatch(programNumber, audioContext) { 299 + const program = programNumber | 0; 300 + if (program < 0 || program > 127) { 301 + console.warn("🎼 GM: program out of range:", programNumber); 302 + return null; 303 + } 304 + 305 + if (_patchCache.has(program)) return _patchCache.get(program); 306 + 307 + const p = (async () => { 308 + const ctx = resolveAudioContext(audioContext); 309 + if (!ctx) { 310 + console.warn("🎼 GM: no AudioContext available"); 311 + return null; 312 + } 313 + 314 + const manifest = await loadManifest(ctx); 315 + const patchEntry = manifest.patches[program]; 316 + const noteList = 317 + (patchEntry && patchEntry.notes) || manifest.defaultNotes || defaultNoteList(); 318 + const dir = programDir(program); 319 + 320 + // Per-note buffer cache scoped to this patch — the inner URL cache 321 + // dedupes across patches if any happen to share notes. 322 + const samples = new Map(); // midi -> Promise<AudioBuffer | null> 323 + 324 + function getSample(midi) { 325 + if (samples.has(midi)) return samples.get(midi); 326 + const promise = fetchAndDecode(noteUrl(dir, midi), ctx); 327 + samples.set(midi, promise); 328 + return promise; 329 + } 330 + 331 + return { 332 + program, 333 + name: patchEntry?.name || `program-${program}`, 334 + notes: noteList, 335 + // Warm the cache for a single rendered note (or all of them). 336 + async prefetch(midi) { 337 + if (typeof midi === "number") return getSample(midi); 338 + return Promise.all(noteList.map((m) => getSample(m))); 339 + }, 340 + play(midiNote, opts = {}) { 341 + const target = midiNote | 0; 342 + const sampleNote = nearestRenderedNote(noteList, target); 343 + if (sampleNote == null) return noopNoteHandle("no-samples"); 344 + const playbackRate = Math.pow(2, (target - sampleNote) / 12); 345 + 346 + let live = noopNoteHandle("pending"); 347 + let externalReleased = false; 348 + 349 + getSample(sampleNote).then((buf) => { 350 + if (!buf) { 351 + live = noopNoteHandle("decode-failed"); 352 + return; 353 + } 354 + if (externalReleased) { 355 + // Caller already released before the buffer arrived; skip. 356 + return; 357 + } 358 + live = scheduleNote(ctx, buf, { 359 + ...opts, 360 + playbackRate, 361 + }); 362 + }); 363 + 364 + return { 365 + stop(when = 0) { 366 + externalReleased = true; 367 + live?.stop?.(when); 368 + }, 369 + release() { 370 + externalReleased = true; 371 + live?.release?.(); 372 + }, 373 + }; 374 + }, 375 + }; 376 + })(); 377 + 378 + _patchCache.set(program, p); 379 + return p; 380 + } 381 + 382 + // Warm a patch's samples without playing anything. 383 + export async function prefetchPatch(programNumber, audioContext) { 384 + const patch = await loadPatch(programNumber, audioContext); 385 + if (!patch) return null; 386 + await patch.prefetch(); 387 + return patch; 388 + } 389 + 390 + // ─── Drum kit loader ────────────────────────────────────────────────── 391 + // GM percussion uses MIDI channel 10 with one-shot samples per MIDI 392 + // note number (35..81 in the standard kit). Drum hits don't pitch-shift 393 + // — we play whichever exact note was requested if rendered, otherwise 394 + // fall back to the nearest rendered note WITHOUT changing playbackRate 395 + // (changing rate would distort the timbre). A future option flag could 396 + // re-enable pitched drums. 397 + export async function loadDrumKit(kitId = 0, audioContext) { 398 + const id = kitId | 0; 399 + if (_drumCache.has(id)) return _drumCache.get(id); 400 + 401 + const p = (async () => { 402 + const ctx = resolveAudioContext(audioContext); 403 + if (!ctx) return null; 404 + const manifest = await loadManifest(ctx); 405 + const kitEntry = manifest.drumKits[id] || manifest.drumKits[0]; 406 + const noteList = (kitEntry && kitEntry.notes) || manifest.defaultNotes || defaultNoteList(); 407 + const dir = drumDir(id); 408 + const samples = new Map(); 409 + 410 + function getSample(midi) { 411 + if (samples.has(midi)) return samples.get(midi); 412 + const promise = fetchAndDecode(noteUrl(dir, midi), ctx); 413 + samples.set(midi, promise); 414 + return promise; 415 + } 416 + 417 + return { 418 + kitId: id, 419 + name: kitEntry?.name || `drum-${id}`, 420 + notes: noteList, 421 + async prefetch(midi) { 422 + if (typeof midi === "number") return getSample(midi); 423 + return Promise.all(noteList.map((m) => getSample(m))); 424 + }, 425 + play(midiNote, opts = {}) { 426 + const target = midiNote | 0; 427 + const sampleNote = nearestRenderedNote(noteList, target); 428 + if (sampleNote == null) return noopNoteHandle("no-samples"); 429 + 430 + let live = noopNoteHandle("pending"); 431 + let externalReleased = false; 432 + 433 + getSample(sampleNote).then((buf) => { 434 + if (!buf) { live = noopNoteHandle("decode-failed"); return; } 435 + if (externalReleased) return; 436 + // Drums: no pitch shift, very short release, let the sample's 437 + // own decay carry the tail. 438 + live = scheduleNote(ctx, buf, { 439 + ...opts, 440 + playbackRate: 1, 441 + attack: 0.001, 442 + decay: 0.01, 443 + sustain: 1.0, 444 + release: 0.03, 445 + }); 446 + }); 447 + 448 + return { 449 + stop(when = 0) { externalReleased = true; live?.stop?.(when); }, 450 + release() { externalReleased = true; live?.release?.(); }, 451 + }; 452 + }, 453 + }; 454 + })(); 455 + 456 + _drumCache.set(id, p); 457 + return p; 458 + } 459 + 460 + // ─── Cache controls (mostly for tests / hot reload) ─────────────────── 461 + export function _resetGMCaches() { 462 + _bufferCache.clear(); 463 + _patchCache.clear(); 464 + _drumCache.clear(); 465 + _manifest = null; 466 + _manifestPromise = null; 467 + } 468 + 469 + // ────────────────────────────────────────────────────────────────────── 470 + // Self-test snippet — paste into a piece's `boot` to sanity-check. 471 + // (Requires the bake script to have populated assets.aesthetic.computer.) 472 + // 473 + // import { loadPatch, loadDrumKit, prefetchPatch } from "/aesthetic.computer/lib/gm.mjs"; 474 + // 475 + // async function boot({ sound }) { 476 + // // 1. Acoustic Grand Piano (GM program 0): 477 + // const piano = await loadPatch(0); 478 + // const note = piano.play(60, { velocity: 110 }); // middle C 479 + // setTimeout(() => note.release(), 800); 480 + // 481 + // // 2. Pitch-shifted note that wasn't directly rendered: 482 + // piano.play(61, { velocity: 90, duration: 0.5 }); // C# via nearest sample 483 + // 484 + // // 3. Standard drum kit — kick (MIDI 36) and snare (MIDI 38): 485 + // const kit = await loadDrumKit(0); 486 + // kit.play(36); setTimeout(() => kit.play(38), 250); 487 + // 488 + // // 4. Warm a patch ahead of time: 489 + // prefetchPatch(24); // nylon guitar 490 + // }
+7 -1
system/public/aesthetic.computer/lib/midi.mjs
··· 4 4 const NOTE_ON = 0x90; 5 5 const NOTE_OFF = 0x80; 6 6 const PITCH_BEND = 0xe0; 7 + const PROGRAM_CHANGE = 0xc0; 7 8 8 9 function handleMidiMessage(message) { 9 10 const [status, note, velocity] = message.data || []; 10 11 if (status === undefined) return; 11 12 12 13 const command = status & 0xf0; 13 - if (command !== NOTE_ON && command !== NOTE_OFF && command !== PITCH_BEND) { 14 + if ( 15 + command !== NOTE_ON && 16 + command !== NOTE_OFF && 17 + command !== PITCH_BEND && 18 + command !== PROGRAM_CHANGE 19 + ) { 14 20 return; 15 21 } 16 22