Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

notepat-remote: offline M4L device via chunked bundle + UDP relay

Max's ~32 KB data: URI cap blocks shipping the full AC runtime in a
single amxd. Chunked bootstrap pipeline reassembles a gzipped bundle
from ~28 KB executejavascript messages via a 3 KB handshake shell.
Notepat:midi relay also gains a UDP fan-out path (geckos subscriber
model mirrors the WS one) so Live devices get sub-frame latency.

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

+489 -35
+338 -20
oven/bundler.mjs
··· 423 423 /import\s*\((['"]aesthetic\.computer\/disks\/([^'"]+)['")])\)/g, 424 424 (match, fullPath, p) => "import('ac/disks/" + p + "')" 425 425 ); 426 + // Path char classes exclude `?` so greedy matching stops at the query 427 + // string boundary — otherwise `./foo.mjs?v=123` becomes the path and the 428 + // rewritten specifier (with query) doesn't match the import map's 429 + // query-less VFS keys, causing runtime resolve failures. 426 430 code = code.replace( 427 - /from\s*['"](\.\.\/[^'"]+|\.\/[^'"]+)(\?[^'"]+)?['"]/g, 431 + /from\s*['"](\.\.\/[^'"?]+|\.\/[^'"?]+)(\?[^'"]+)?['"]/g, 428 432 (match, p) => { 429 433 const resolved = resolvePath(filepath, p); 430 434 return 'from"' + resolved + '"'; 431 435 } 432 436 ); 433 437 code = code.replace( 434 - /import\s*\((['"](\.\.\/[^'"]+|\.\/[^'"]+)(\?[^'"]+)?['")])\)/g, 438 + /import\s*\((['"](\.\.\/[^'"?]+|\.\/[^'"?]+)(\?[^'"]+)?['")])\)/g, 435 439 (match, fullPath, p) => { 436 440 const resolved = resolvePath(filepath, p); 437 441 return 'import("' + resolved + '")'; ··· 510 514 511 515 try { 512 516 const content = await fs.readFile(fullPath, "utf8"); 513 - const importRegex = /from\s+["'](\.\.[^"']+|\.\/[^"']+)["']/g; 514 - const dynamicRegex = /import\s*\(\s*["'](\.\.[^"']+|\.\/[^"']+)["']\s*\)/g; 517 + // Exclude `?` from the path char class so cache-bust queries 518 + // (`./foo.mjs?v=123`) don't get baked into the resolved VFS key — 519 + // otherwise `fs.readFile('lib/foo.mjs?v=123')` fails and the file is 520 + // silently dropped from the bundle. 521 + const importRegex = /from\s+["'](\.\.[^"'?]+|\.\/[^"'?]+)(?:\?[^"']*)?["']/g; 522 + const dynamicRegex = /import\s*\(\s*["'](\.\.[^"'?]+|\.\/[^"'?]+)(?:\?[^"']*)?["']\s*\)/g; 515 523 516 524 let match; 517 525 while ((match = importRegex.exec(content)) !== null) { ··· 762 770 763 771 // ─── JS piece bundle ──────────────────────────────────────────────── 764 772 765 - export async function createJSPieceBundle(pieceName, onProgress = () => {}, nocompress = false, density = null, brotli = false, noboxart = false, keeplabel = false) { 773 + export async function createJSPieceBundle(pieceName, onProgress = () => {}, nocompress = false, density = null, brotli = false, noboxart = false, keeplabel = false, forceDaw = false) { 766 774 const acDir = AC_SOURCE_DIR; 767 775 onProgress({ stage: "init", message: `Bundling ${pieceName}...` }); 768 776 ··· 814 822 815 823 const boxArtPNG = noboxart ? null : await generateBoxArtPNG(pieceName, null, null, packDate).catch(() => null); 816 824 817 - const htmlContent = generateJSPieceHTMLBundle({ pieceName, files, packDate, packTime, gitVersion: GIT_COMMIT, bdfGlyphs, boxArtPNG, keeplabel }); 825 + const htmlContent = generateJSPieceHTMLBundle({ pieceName, files, packDate, packTime, gitVersion: GIT_COMMIT, bdfGlyphs, boxArtPNG, keeplabel, forceDaw }); 818 826 const filename = `${pieceName}-${bundleTimestamp}.html`; 819 827 820 828 const method = nocompress ? "none" : brotli ? "brotli" : "gzip"; ··· 843 851 const M4L_HEADER_INSTRUMENT = Buffer.from( 844 852 "ampf\x04\x00\x00\x00iiiimeta\x04\x00\x00\x00\x00\x00\x00\x00ptch", "binary" 845 853 ); 854 + // MIDI effect header — used by devices that emit MIDI (noteout) without audio 855 + // output, e.g. notepat-remote. Tag is 'mmmm' where instruments use 'iiii'. 856 + // Live refuses to load a 'mmmm' device on an audio track; an 'iiii' device 857 + // on a MIDI track with no audio outlet shows up but can't route MIDI cleanly. 858 + const M4L_HEADER_MIDI_EFFECT = Buffer.from( 859 + "ampf\x04\x00\x00\x00mmmmmeta\x04\x00\x00\x00\x00\x00\x00\x00ptch", "binary" 860 + ); 846 861 847 - function generateM4DPatcher(pieceName, dataUri, width = 400, height = 200) { 862 + function generateM4DPatcher(pieceName, dataUri, width = 400, height = 200, pieceProfile = "default") { 863 + if (pieceProfile === "notepat") return generateNotepatM4DPatcher(pieceName, dataUri); 848 864 return { 849 865 patcher: { 850 866 fileversion: 1, ··· 894 910 }; 895 911 } 896 912 897 - function packAMXD(patcher) { 913 + // Notepat-specific patcher: routes `notedown`/`noteup`/`octave`/`ping` from 914 + // jweb~ (emitted by bios.mjs's daw bridge) into [noteout], and echoes the 915 + // RTT pong back via script→window.acMaxPong. Mirrors the hand-rolled 916 + // system/public/m4l/notepat-remote.amxd so the offline bundle behaves 917 + // identically to the live version. 918 + function generateNotepatM4DPatcher(pieceName, dataUri) { 919 + const W = 360, H = 169; 920 + return { 921 + patcher: { 922 + fileversion: 1, 923 + appversion: { major: 9, minor: 0, revision: 7, architecture: "x64", modernui: 1 }, 924 + classnamespace: "box", 925 + rect: [100, 100, 900, 520], 926 + openrect: [0, 0, W, H], 927 + openinpresentation: 1, 928 + gridsize: [15, 15], 929 + enablehscroll: 0, enablevscroll: 0, 930 + devicewidth: W, 931 + description: `Aesthetic Computer ${pieceName} (offline) — packed notepat + local hotkey input. BIOS in jweb~ owns keyboard + octave state and emits pitches via window.max.outlet (notedown/noteup).`, 932 + boxes: [ 933 + { box: { disablefind: 0, id: "obj-jweb", latency: 0, maxclass: "jweb~", numinlets: 1, numoutlets: 3, outlettype: ["signal","signal",""], patching_rect: [10,10,W,H], presentation: 1, presentation_rect: [0,0,W,H], rendermode: 1, url: dataUri } }, 934 + { box: { id: "obj-route", maxclass: "newobj", numinlets: 1, numoutlets: 7, outlettype: ["","","","","","",""], patching_rect: [10,250,560,22], text: "route note channel notedown noteup octave focus ping" } }, 935 + { box: { id: "obj-noteout", maxclass: "newobj", numinlets: 2, numoutlets: 0, patching_rect: [10,400,60,22], text: "noteout" } }, 936 + { box: { id: "obj-pack-on", maxclass: "newobj", numinlets: 2, numoutlets: 1, outlettype: ["list"], patching_rect: [10,360,90,22], text: "pack 0 100" } }, 937 + { box: { id: "obj-pack-off", maxclass: "newobj", numinlets: 2, numoutlets: 1, outlettype: ["list"], patching_rect: [120,360,90,22], text: "pack 0 0" } }, 938 + { box: { id: "obj-print-note", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [600,290,200,22], text: "print NOTEPAT-NOTE" } }, 939 + { box: { id: "obj-print-chan", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [600,320,200,22], text: "print NOTEPAT-CHAN" } }, 940 + { box: { id: "obj-print-keydown", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [600,360,200,22], text: "print NOTEPAT-DOWN" } }, 941 + { box: { id: "obj-print-keyup", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [600,390,200,22], text: "print NOTEPAT-UP" } }, 942 + { box: { id: "obj-print-octave", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [600,420,200,22], text: "print NOTEPAT-OCT" } }, 943 + { box: { id: "obj-print-focus", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [600,450,200,22], text: "print NOTEPAT-FOCUS" } }, 944 + { box: { id: "obj-print-other", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [600,480,200,22], text: "print NOTEPAT-OTHER" } }, 945 + { box: { id: "obj-sprintf-pong", maxclass: "newobj", numinlets: 1, numoutlets: 1, outlettype: [""], patching_rect: [10,290,320,22], text: "sprintf script window.acMaxPong(%ld)" } }, 946 + { box: { id: "obj-thisdevice", maxclass: "newobj", numinlets: 1, numoutlets: 3, outlettype: ["bang","int","int"], patching_rect: [240,290,90,22], text: "live.thisdevice" } }, 947 + { box: { id: "obj-routeready", maxclass: "newobj", numinlets: 1, numoutlets: 2, outlettype: ["",""], patching_rect: [240,320,60,22], text: "route ready" } }, 948 + { box: { id: "obj-activate", maxclass: "message", numinlets: 2, numoutlets: 1, outlettype: [""], patching_rect: [240,350,120,22], text: "script daw-activate" } }, 949 + // Console → Max log routing. jweb's 3rd outlet emits any dict/list 950 + // from window.max.outlet, including "log"/"error"/"warn" calls wired 951 + // by bios.mjs. [route log error warn] peels them off the MIDI stream 952 + // into a Max console printer so we can see bundle errors in the 953 + // Max Console window when the UI goes gray. 954 + { box: { id: "obj-route-logs", maxclass: "newobj", numinlets: 1, numoutlets: 4, outlettype: ["","","",""], patching_rect: [340,220,160,22], text: "route log error warn" } }, 955 + { box: { id: "obj-print-log", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [340,250,200,22], text: "print [AC-LOG]" } }, 956 + { box: { id: "obj-print-error", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [450,250,200,22], text: "print [AC-ERROR]" } }, 957 + { box: { id: "obj-print-warn", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [560,250,200,22], text: "print [AC-WARN]" } }, 958 + ], 959 + lines: [ 960 + { patchline: { source: ["obj-jweb", 2], destination: ["obj-route", 0] } }, 961 + { patchline: { source: ["obj-jweb", 2], destination: ["obj-route-logs", 0] } }, 962 + { patchline: { source: ["obj-route", 0], destination: ["obj-noteout", 0] } }, 963 + { patchline: { source: ["obj-route", 0], destination: ["obj-print-note", 0] } }, 964 + { patchline: { source: ["obj-route", 1], destination: ["obj-noteout", 1] } }, 965 + { patchline: { source: ["obj-route", 1], destination: ["obj-print-chan", 0] } }, 966 + { patchline: { source: ["obj-route", 2], destination: ["obj-pack-on", 0] } }, 967 + { patchline: { source: ["obj-route", 2], destination: ["obj-print-keydown", 0] } }, 968 + { patchline: { source: ["obj-route", 3], destination: ["obj-pack-off", 0] } }, 969 + { patchline: { source: ["obj-route", 3], destination: ["obj-print-keyup", 0] } }, 970 + { patchline: { source: ["obj-route", 4], destination: ["obj-print-octave", 0] } }, 971 + { patchline: { source: ["obj-route", 5], destination: ["obj-print-focus", 0] } }, 972 + { patchline: { source: ["obj-route", 6], destination: ["obj-sprintf-pong", 0] } }, 973 + { patchline: { source: ["obj-sprintf-pong", 0], destination: ["obj-jweb", 0] } }, 974 + { patchline: { source: ["obj-pack-on", 0], destination: ["obj-noteout", 0] } }, 975 + { patchline: { source: ["obj-pack-off", 0], destination: ["obj-noteout", 0] } }, 976 + { patchline: { source: ["obj-thisdevice", 0], destination: ["obj-routeready", 0] } }, 977 + { patchline: { source: ["obj-routeready", 0], destination: ["obj-activate", 0] } }, 978 + { patchline: { source: ["obj-activate", 0], destination: ["obj-jweb", 0] } }, 979 + { patchline: { source: ["obj-route-logs", 0], destination: ["obj-print-log", 0] } }, 980 + { patchline: { source: ["obj-route-logs", 1], destination: ["obj-print-error", 0] } }, 981 + { patchline: { source: ["obj-route-logs", 2], destination: ["obj-print-warn", 0] } }, 982 + ], 983 + dependency_cache: [], latency: 0, is_mpe: 0, 984 + external_mpe_tuning_enabled: 0, minimum_live_version: "", 985 + minimum_max_version: "", platform_compatibility: 0, autosave: 0, 986 + }, 987 + }; 988 + } 989 + 990 + function packAMXD(patcher, kind = "instrument") { 898 991 const json = Buffer.from(JSON.stringify(patcher)); 899 992 const len = Buffer.alloc(4); 900 993 len.writeUInt32LE(json.length, 0); 901 - return Buffer.concat([M4L_HEADER_INSTRUMENT, len, json]); 994 + const header = kind === "midi_effect" ? M4L_HEADER_MIDI_EFFECT : M4L_HEADER_INSTRUMENT; 995 + return Buffer.concat([header, len, json]); 996 + } 997 + 998 + // ─── Chunked bootstrap (for amxd bundles > 32 KB) ─────────────────── 999 + // 1000 + // Max 9 caps jweb~'s `url` attribute (and message box text) at ~32 KB, so 1001 + // we can't ship the full AC runtime as a single data: URI. The chunked 1002 + // bootstrap pattern works around this: 1003 + // 1004 + // 1. The amxd sets `url` to a ~3 KB bootstrap HTML (fits in the attr cap). 1005 + // 2. Bootstrap polls for `window.max`, then calls `outlet("ready", 1)`. 1006 + // 3. Patcher's `[route ready …]` fires the "ready" signal into N message 1007 + // boxes, each holding one ~28 KB chunk of `executejavascript 1008 + // window._ac.p('I|N|<base64-gz-slice>')`. 1009 + // 4. Each chunk message goes to jweb → the JS runs → _ac.p accumulates. 1010 + // 5. On the final chunk, _ac.p base64-decodes → gunzips via 1011 + // DecompressionStream → `document.open/write/close` renders the real 1012 + // bundle. 1013 + // 1014 + // Handshake via "ready" avoids the race where chunks are sent before jweb 1015 + // has loaded the page (executejavascript silently drops in that state). 1016 + 1017 + // Max 9 caps message box `text` at ~32 KB. Wrapper is 1018 + // `executejavascript window._ac.p('I|N|')` + closing `')` — ~42 chars. 1019 + // 28 KB per chunk leaves ample headroom for the wrapper + ints. 1020 + const M4D_CHUNK_SIZE = 28000; 1021 + 1022 + function generateChunkedBootstrapHTML(pieceName) { 1023 + const title = pieceName.replace(/[<>&"']/g, ""); 1024 + // Compact JS so the whole document stays well under the 24 KB ceiling 1025 + // the `url` data: URI attribute can hold. 1026 + return `<!DOCTYPE html> 1027 + <html lang="en"><head><meta charset="utf-8"><title>${title} · loading</title> 1028 + <style>html,body{margin:0;padding:0;width:100%;height:100%;background:#0e1012;color:#4f9;font:12px -apple-system,monospace;overflow:hidden}pre{margin:0;padding:10px 12px;white-space:pre-wrap;word-break:break-all}</style> 1029 + </head><body><pre id="s">booting ${title}…</pre><script> 1030 + (function(){ 1031 + var el=document.getElementById("s"); 1032 + function log(m){if(el)el.textContent=(el.textContent+"\\n"+m).slice(-3000);try{if(window.max&&window.max.outlet)window.max.outlet("log",String(m).slice(0,900));}catch(_){}} 1033 + window._ac={chunks:[],total:0,received:0, 1034 + p:function(s){var parts=s.split("|");var i=+parts[0];var n=+parts[1];var d=parts.slice(2).join("|");this.total=n;this.chunks[i]=d;this.received++;if(el)el.textContent="loading "+this.received+"/"+n;if(this.received===n)this.render();}, 1035 + render:function(){log("all "+this.total+" chunks received, decompressing");try{ 1036 + var b64=this.chunks.join("");var bin=atob(b64);var bytes=new Uint8Array(bin.length);for(var j=0;j<bin.length;j++)bytes[j]=bin.charCodeAt(j); 1037 + var blob=new Blob([bytes],{type:"application/gzip"}); 1038 + fetch(URL.createObjectURL(blob)).then(function(r){return r.blob();}).then(function(b){return b.stream().pipeThrough(new DecompressionStream("gzip"));}).then(function(s){return new Response(s).text();}).then(function(h){log("decompressed, writing "+h.length+" bytes");document.open();document.write(h);document.close();}).catch(function(e){log("decompress failed: "+e);}); 1039 + }catch(e){log("render failed: "+e);}}}; 1040 + window.addEventListener("error",function(e){log("[err] "+e.message);}); 1041 + window.addEventListener("unhandledrejection",function(e){log("[rej] "+(e.reason&&e.reason.message||e.reason));}); 1042 + log("alive, polling for window.max"); 1043 + var tries=0;var iv=setInterval(function(){if(window.max&&typeof window.max.outlet==="function"){clearInterval(iv);log("window.max bound ("+tries+" tries), sending ready");try{window.max.outlet("ready",1);}catch(e){log("ready send failed: "+e);}}else if(++tries>100){clearInterval(iv);log("gave up waiting for window.max");}},50); 1044 + })(); 1045 + </script></body></html>`; 902 1046 } 903 1047 1048 + function chunkBundleForM4D(html) { 1049 + const gz = gzipSync(Buffer.from(html, "utf-8"), { level: 9 }); 1050 + const b64 = gz.toString("base64"); 1051 + const chunks = []; 1052 + for (let i = 0; i < b64.length; i += M4D_CHUNK_SIZE) { 1053 + chunks.push(b64.slice(i, i + M4D_CHUNK_SIZE)); 1054 + } 1055 + return { chunks, gzBytes: gz.length, b64Bytes: b64.length }; 1056 + } 1057 + 1058 + // Notepat patcher for chunked bundles. jweb outlet 2 feeds `route ready log 1059 + // error warn …` — the `ready` signal from the bootstrap fires every chunk 1060 + // message into jweb's inlet 0, and unmatched messages (notedown, noteup, 1061 + // octave, focus, ping from the daw bridge) pass through the final outlet to 1062 + // the MIDI router. Everything else matches the hand-rolled notepat device. 1063 + function generateChunkedNotepatM4DPatcher(pieceName, bootstrapDataUri, chunks) { 1064 + const W = 360, H = 169; 1065 + const boxes = [ 1066 + { box: { disablefind: 0, id: "obj-jweb", latency: 0, maxclass: "jweb~", numinlets: 1, numoutlets: 3, outlettype: ["signal","signal",""], patching_rect: [10,10,W,H], presentation: 1, presentation_rect: [0,0,W,H], rendermode: 1, url: bootstrapDataUri } }, 1067 + // Split jweb outlet 2 first by handshake/log symbols, then pass anything 1068 + // else to the MIDI router. `route` has (N matched + 1 unmatched) outlets. 1069 + { box: { id: "obj-route-top", maxclass: "newobj", numinlets: 1, numoutlets: 5, outlettype: ["","","","",""], patching_rect: [10,200,400,22], text: "route ready log error warn" } }, 1070 + { box: { id: "obj-print-log", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [100,230,200,22], text: "print [AC-LOG]" } }, 1071 + { box: { id: "obj-print-error", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [200,230,200,22], text: "print [AC-ERROR]" } }, 1072 + { box: { id: "obj-print-warn", maxclass: "newobj", numinlets: 1, numoutlets: 0, patching_rect: [300,230,200,22], text: "print [AC-WARN]" } }, 1073 + // MIDI routing (downstream of the unmatched outlet). 1074 + { box: { id: "obj-route", maxclass: "newobj", numinlets: 1, numoutlets: 7, outlettype: ["","","","","","",""], patching_rect: [10,300,560,22], text: "route note channel notedown noteup octave focus ping" } }, 1075 + { box: { id: "obj-noteout", maxclass: "newobj", numinlets: 2, numoutlets: 0, patching_rect: [10,450,60,22], text: "noteout" } }, 1076 + { box: { id: "obj-pack-on", maxclass: "newobj", numinlets: 2, numoutlets: 1, outlettype: ["list"], patching_rect: [10,410,90,22], text: "pack 0 100" } }, 1077 + { box: { id: "obj-pack-off", maxclass: "newobj", numinlets: 2, numoutlets: 1, outlettype: ["list"], patching_rect: [120,410,90,22], text: "pack 0 0" } }, 1078 + { box: { id: "obj-sprintf-pong", maxclass: "newobj", numinlets: 1, numoutlets: 1, outlettype: [""], patching_rect: [10,340,320,22], text: "sprintf script window.acMaxPong(%ld)" } }, 1079 + ]; 1080 + const lines = [ 1081 + { patchline: { source: ["obj-jweb", 2], destination: ["obj-route-top", 0] } }, 1082 + // Handshake "ready" fans out to every chunk message (see below). 1083 + { patchline: { source: ["obj-route-top", 1], destination: ["obj-print-log", 0] } }, 1084 + { patchline: { source: ["obj-route-top", 2], destination: ["obj-print-error", 0] } }, 1085 + { patchline: { source: ["obj-route-top", 3], destination: ["obj-print-warn", 0] } }, 1086 + // Unmatched messages (notedown/noteup/octave/focus/ping) → MIDI router. 1087 + { patchline: { source: ["obj-route-top", 4], destination: ["obj-route", 0] } }, 1088 + { patchline: { source: ["obj-route", 0], destination: ["obj-noteout", 0] } }, 1089 + { patchline: { source: ["obj-route", 1], destination: ["obj-noteout", 1] } }, 1090 + { patchline: { source: ["obj-route", 2], destination: ["obj-pack-on", 0] } }, 1091 + { patchline: { source: ["obj-route", 3], destination: ["obj-pack-off", 0] } }, 1092 + // Octave/focus: no MIDI wiring, just stream into the log prints 1093 + // via route-top's log outlet already (skipped here — no-op on absence). 1094 + { patchline: { source: ["obj-route", 6], destination: ["obj-sprintf-pong", 0] } }, 1095 + { patchline: { source: ["obj-sprintf-pong", 0], destination: ["obj-jweb", 0] } }, 1096 + { patchline: { source: ["obj-pack-on", 0], destination: ["obj-noteout", 0] } }, 1097 + { patchline: { source: ["obj-pack-off", 0], destination: ["obj-noteout", 0] } }, 1098 + ]; 1099 + // One message box per chunk. All share a single source (ready) so they 1100 + // fire together — order doesn't matter, _ac.p reassembles by index. 1101 + chunks.forEach((data, i) => { 1102 + const id = "obj-chunk-" + i; 1103 + const text = "executejavascript window._ac.p('" + i + "|" + chunks.length + "|" + data + "')"; 1104 + boxes.push({ box: { id, maxclass: "message", numinlets: 2, numoutlets: 1, outlettype: [""], patching_rect: [10, 480 + i * 4, 200, 22], text } }); 1105 + lines.push({ patchline: { source: ["obj-route-top", 0], destination: [id, 0] } }); 1106 + lines.push({ patchline: { source: [id, 0], destination: ["obj-jweb", 0] } }); 1107 + }); 1108 + return { 1109 + patcher: { 1110 + fileversion: 1, 1111 + appversion: { major: 9, minor: 0, revision: 7, architecture: "x64", modernui: 1 }, 1112 + classnamespace: "box", 1113 + rect: [100, 100, 900, 520], 1114 + openrect: [0, 0, W, H], 1115 + openinpresentation: 1, 1116 + gridsize: [15, 15], 1117 + enablehscroll: 0, enablevscroll: 0, 1118 + devicewidth: W, 1119 + description: `Aesthetic Computer ${pieceName} (offline, chunked) — ${chunks.length} chunks`, 1120 + boxes, lines, 1121 + dependency_cache: [], latency: 0, is_mpe: 0, 1122 + external_mpe_tuning_enabled: 0, minimum_live_version: "", 1123 + minimum_max_version: "", platform_compatibility: 0, autosave: 0, 1124 + }, 1125 + }; 1126 + } 1127 + 1128 + // Pieces that need the bios.mjs DAW bridge (window.max.outlet for MIDI) and 1129 + // a piece-specific Max patcher + amxd kind. Auto-detected so callers don't 1130 + // need to pass flags — keeps the m4d CLI/prompt command one-liners. 1131 + // `profile` picks the patcher wiring; `kind` picks the amxd header tag 1132 + // (instrument=iiii vs midi_effect=mmmm) since Live uses it to categorize. 1133 + // `chunked: true` routes through the chunked bootstrap pipeline, required 1134 + // whenever the bundle exceeds Max's ~32 KB attribute/message text cap. 1135 + const DAW_PIECE_PROFILES = { 1136 + "notepat-remote": { profile: "notepat", kind: "midi_effect", chunked: true }, 1137 + }; 1138 + 904 1139 export async function createM4DBundle(pieceName, isJSPiece, onProgress = () => {}, density = null) { 905 1140 onProgress({ stage: "fetch", message: `Building M4L device for ${pieceName}...` }); 906 1141 1142 + const profileSpec = DAW_PIECE_PROFILES[pieceName] || { profile: "default", kind: "instrument", chunked: false }; 1143 + const { profile: pieceProfile, kind: amxdKind, chunked } = profileSpec; 1144 + const forceDaw = pieceProfile !== "default"; 1145 + 1146 + // Chunked pipeline always gzips the bundle (decompressed client-side by 1147 + // the bootstrap). The single-URI pipeline can't use gzip inside jweb, so 1148 + // it stays uncompressed when it's used. 907 1149 const bundleResult = isJSPiece 908 - ? await createJSPieceBundle(pieceName, onProgress, false, density) 909 - : await createBundle(pieceName, onProgress, false, density); 1150 + ? await createJSPieceBundle(pieceName, onProgress, !chunked, density, false, false, false, forceDaw) 1151 + : await createBundle(pieceName, onProgress, !chunked, density); 910 1152 911 - onProgress({ stage: "generate", message: "Embedding bundle in M4L device..." }); 912 - 913 - const dataUri = `data:text/html;base64,${Buffer.from(bundleResult.html).toString("base64")}`; 914 - const patcher = generateM4DPatcher(pieceName, dataUri); 1153 + let patcher; 1154 + if (chunked) { 1155 + onProgress({ stage: "generate", message: "Chunking bundle for M4L device..." }); 1156 + const { chunks, gzBytes, b64Bytes } = chunkBundleForM4D(bundleResult.html); 1157 + onProgress({ stage: "generate", message: `Chunking: gzip=${Math.round(gzBytes/1024)}KB base64=${Math.round(b64Bytes/1024)}KB → ${chunks.length} chunks` }); 1158 + const bootstrapHTML = generateChunkedBootstrapHTML(pieceName); 1159 + const bootstrapDataUri = `data:text/html;base64,${Buffer.from(bootstrapHTML).toString("base64")}`; 1160 + if (bootstrapDataUri.length > 32000) { 1161 + throw new Error(`chunked bootstrap data URI ${bootstrapDataUri.length} bytes exceeds 32000 cap`); 1162 + } 1163 + if (pieceProfile === "notepat") { 1164 + patcher = generateChunkedNotepatM4DPatcher(pieceName, bootstrapDataUri, chunks); 1165 + } else { 1166 + throw new Error(`chunked pipeline has no patcher template for profile "${pieceProfile}"`); 1167 + } 1168 + } else { 1169 + onProgress({ stage: "generate", message: "Embedding bundle in M4L device..." }); 1170 + const dataUri = `data:text/html;base64,${Buffer.from(bundleResult.html).toString("base64")}`; 1171 + patcher = generateM4DPatcher(pieceName, dataUri, 400, 200, pieceProfile); 1172 + } 915 1173 916 1174 onProgress({ stage: "compress", message: "Packing .amxd binary..." }); 917 1175 918 - const binary = packAMXD(patcher); 1176 + const binary = packAMXD(patcher, amxdKind); 919 1177 const filename = `AC ${pieceName} (offline).amxd`; 920 1178 921 1179 return { binary, filename, sizeKB: Math.round(binary.length / 1024) }; ··· 1333 1591 try { 1334 1592 await import(window.VFS_BLOB_URLS['boot.mjs']); 1335 1593 } catch (err) { 1336 - document.body.style.cssText='color:#fff;background:#000;padding:20px;font-family:monospace'; 1337 - document.body.textContent='Boot failed: '+err.message; 1594 + document.body.style.cssText='color:#fff;background:#400;padding:20px;font:12px monospace;white-space:pre-wrap'; 1595 + document.body.textContent='Boot failed: '+err.message+'\n'+(err.stack||''); 1596 + try { if (window.max && window.max.outlet) window.max.outlet('error', '[boot] ' + err.message + ' :: ' + (err.stack || '')); } catch(_){} 1338 1597 } 1339 1598 </script> 1340 1599 </body> ··· 1342 1601 } 1343 1602 1344 1603 function generateJSPieceHTMLBundle(opts) { 1345 - const { pieceName, files, packDate, packTime, gitVersion, bdfGlyphs, boxArtPNG, keeplabel } = opts; 1604 + const { pieceName, files, packDate, packTime, gitVersion, bdfGlyphs, boxArtPNG, keeplabel, forceDaw } = opts; 1346 1605 1347 1606 const boxArtImg = renderBoxArt(pieceName, boxArtPNG); 1348 1607 ··· 1353 1612 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> 1354 1613 <title>${pieceName} · Aesthetic Computer</title> 1355 1614 <style> 1356 - body { margin: 0; padding: 0; overflow: hidden; } 1615 + body { margin: 0; padding: 0; overflow: hidden; background: #222; } 1357 1616 canvas { display: block; image-rendering: pixelated; } 1358 1617 #ac-box-art { position: fixed; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; pointer-events: none; } 1359 1618 </style> 1360 1619 </head> 1361 1620 <body> 1362 1621 ${boxArtImg} 1622 + ${forceDaw ? `<script> 1623 + // Max console bridge — must run FIRST so we see anything that breaks 1624 + // below. Forwards console.log/warn/error + window.onerror to 1625 + // window.max.outlet, which [route log error warn] in the patcher 1626 + // funnels into [print] for the Max Console. Without this a bundle 1627 + // failure is invisible (gray jweb, no diagnostics). 1628 + // 1629 + // window.max may be injected slightly after page load — buffer early 1630 + // messages and flush once the outlet shows up (or give up after 5s). 1631 + (function(){ 1632 + var buf = []; 1633 + var ready = false; 1634 + function flush() { 1635 + if (!window.max || typeof window.max.outlet !== 'function') return false; 1636 + ready = true; 1637 + for (var i = 0; i < buf.length; i++) { 1638 + try { window.max.outlet(buf[i][0], buf[i][1]); } catch (_) {} 1639 + } 1640 + buf.length = 0; 1641 + return true; 1642 + } 1643 + function emit(tag, args) { 1644 + var parts = []; 1645 + for (var i = 0; i < args.length; i++) { 1646 + var a = args[i]; 1647 + if (a && a.stack) parts.push(String(a.stack)); 1648 + else if (typeof a === 'object') { try { parts.push(JSON.stringify(a)); } catch(_) { parts.push(String(a)); } } 1649 + else parts.push(String(a)); 1650 + } 1651 + var msg = parts.join(' ').slice(0, 900); 1652 + if (ready) { try { window.max.outlet(tag, msg); } catch (_) {} } 1653 + else buf.push([tag, msg]); 1654 + } 1655 + var orig = { log: console.log, warn: console.warn, error: console.error }; 1656 + console.log = function() { emit('log', arguments); orig.log.apply(console, arguments); }; 1657 + console.warn = function() { emit('warn', arguments); orig.warn.apply(console, arguments); }; 1658 + console.error = function() { emit('error', arguments); orig.error.apply(console, arguments); }; 1659 + window.addEventListener('error', function(e) { 1660 + emit('error', ['[uncaught]', e.message, '@', (e.filename||'?') + ':' + (e.lineno||0) + ':' + (e.colno||0)]); 1661 + }); 1662 + window.addEventListener('unhandledrejection', function(e) { 1663 + emit('error', ['[unhandled-rejection]', (e.reason && e.reason.stack) || String(e.reason)]); 1664 + }); 1665 + emit('log', ['[ac-bundle] jweb alive — piece=' + (${JSON.stringify(pieceName)}) + ' ua=' + navigator.userAgent]); 1666 + // Flush as soon as window.max is injected. 1667 + var tries = 0; 1668 + var iv = setInterval(function() { 1669 + if (flush() || ++tries > 50) clearInterval(iv); 1670 + }, 100); 1671 + })(); 1672 + </script>` : ""} 1363 1673 <script> 1364 1674 // Phase 1: Setup VFS, blob URLs, import map, and fetch interception. 1365 1675 // This MUST run in a regular <script> (not type="module") so the import map 1366 1676 // is in the DOM BEFORE any <script type="module"> executes. 1367 1677 window.acPACK_MODE = true; 1368 1678 ${keeplabel ? `window.acKEEP_LABEL = true;` : ""} 1679 + ${forceDaw ? `window.acFORCE_DAW = true; 1680 + // M4L devices always want density=1, nogap, and keep the corner HUD 1681 + // label (matches the live URL's ?density=1&nogap params — the data: 1682 + // URI has no query string, so we set the equivalent globals). 1683 + window.acPACK_DENSITY = 1; 1684 + window.acFORCE_NOGAP = true; 1685 + window.acKEEP_LABEL = true; 1686 + try { window.max && window.max.outlet && window.max.outlet("log", "[viewport] inner=" + innerWidth + "x" + innerHeight + " dpr=" + devicePixelRatio); } catch(_) {}` : ""} 1369 1687 window.KIDLISP_SUPPRESS_SNAPSHOT_LOGS = true; 1370 1688 window.__acKidlispConsoleEnabled = false; 1371 1689 window.acSTARTING_PIECE = "${pieceName}";
+50 -2
session-server/session.mjs
··· 79 79 const UDP_MIDI_SOURCE_TTL_MS = 20000; 80 80 const notepatMidiSources = new Map(); // key "@handle:machine" -> source metadata 81 81 const notepatMidiSubscribers = new Map(); // connection id -> { ws, all, handle, machineId } 82 + // UDP-side subscribers for notepat:midi fan-out over geckos.io. The WS map 83 + // above handles reliable subscription handshakes; this map mirrors the same 84 + // filter model against geckos channels so we can emit events twice (once 85 + // reliably over WS, once low-latency over UDP) to consumers that opened both. 86 + const notepatMidiUdpSubscribers = new Map(); // channel id -> { channel, all, handle, machineId } 82 87 83 88 // Error logging ring buffer (for dashboard display) 84 89 const errorLog = []; ··· 3244 3249 error("🎹 Failed to fan out notepat midi event:", err); 3245 3250 } 3246 3251 } 3252 + // UDP fan-out. Same filter model, emitted on the geckos channel. M4L 3253 + // notepat-remote devices care about this path for sub-frame latency — 3254 + // the WS path is ~5-15 ms slower end-to-end on a typical home network. 3255 + for (const [id, sub] of notepatMidiUdpSubscribers) { 3256 + if (!sub?.channel || sub.channel.webrtcConnection?.state !== "open") { 3257 + notepatMidiUdpSubscribers.delete(id); 3258 + continue; 3259 + } 3260 + if (!notepatMidiSubscriberMatches(sub, event)) continue; 3261 + try { 3262 + sub.channel.emit("notepat:midi", event); 3263 + } catch (err) { 3264 + error("🎹 UDP fan-out failed:", err); 3265 + } 3266 + } 3247 3267 } 3248 3268 3249 3269 function upsertNotepatMidiSource({ handle, machineId, piece, lastEvent, ts, address, port }) { ··· 3581 3601 log(`🩰 ${channel.id} got disconnected`); 3582 3602 delete udpChannels[channel.id]; 3583 3603 fairyThrottle.delete(channel.id); 3584 - 3604 + notepatMidiUdpSubscribers.delete(channel.id); 3605 + 3585 3606 // Clean up client record if no longer connected via any protocol 3586 3607 if (clients[channel.id]) { 3587 3608 clients[channel.id].udp = false; ··· 3590 3611 delete clients[channel.id]; 3591 3612 } 3592 3613 } 3593 - 3614 + 3594 3615 channel.close(); 3616 + }); 3617 + 3618 + // 🎹 Notepat MIDI relay over UDP. Same subscribe/unsubscribe model as the 3619 + // WS path (cross-session, filter on handle/machineId or all:true). Events 3620 + // fan out via notepatMidiUdpSubscribers in broadcastNotepatMidiEvent. 3621 + channel.on("notepat:midi:subscribe", (data) => { 3622 + let filter = {}; 3623 + try { filter = typeof data === "string" ? JSON.parse(data) : (data || {}); } catch {} 3624 + // Optional: wrap in `{ filter: {...} }` or pass fields directly — accept both. 3625 + if (filter.filter) filter = filter.filter; 3626 + notepatMidiUdpSubscribers.set(channel.id, { 3627 + channel, 3628 + all: filter.all === true, 3629 + handle: normalizeMidiHandle(filter.handle), 3630 + machineId: filter.machineId ? `${filter.machineId}`.trim() : "", 3631 + }); 3632 + try { channel.emit("notepat:midi:subscribed", { 3633 + all: filter.all === true, 3634 + handle: normalizeMidiHandle(filter.handle) || null, 3635 + machineId: filter.machineId ? `${filter.machineId}`.trim() : null, 3636 + }); } catch {} 3637 + log(`🎹 UDP ${channel.id} subscribed to notepat:midi (all=${filter.all === true})`); 3638 + }); 3639 + 3640 + channel.on("notepat:midi:unsubscribe", () => { 3641 + notepatMidiUdpSubscribers.delete(channel.id); 3642 + try { channel.emit("notepat:midi:unsubscribed", true); } catch {} 3595 3643 }); 3596 3644 3597 3645 // 💎 TODO: Make these channel names programmable somehow? 24.12.08.04.12
+13 -3
system/public/aesthetic.computer/bios.mjs
··· 1217 1217 ? window.acPACK_DENSITY 1218 1218 : 2); 1219 1219 1220 + // acFORCE_NOGAP pins gap=0 for packed M4L bundles loaded via data: URI 1221 + // where there's no `?nogap` query param to read — equivalent behavior. 1220 1222 const startGap = 1221 - location.host.indexOf("botce") > -1 || AestheticExtension ? 0 : 8; 1223 + location.host.indexOf("botce") > -1 || AestheticExtension || window.acFORCE_NOGAP === true ? 0 : 8; 1222 1224 1223 1225 // Runs one on boot & every time display resizes to adjust the framebuffer. 1224 1226 function frame(width, height, gap) { 1227 + // Packed M4L bundles pin gap=0 on every reframe so the nogap CSS class 1228 + // sticks — without this, a later frame() call with an explicit non-zero 1229 + // gap would remove nogap even though the device never wants margin. 1230 + if (window.acFORCE_NOGAP === true) gap = 0; 1225 1231 // Notify parent on first frame setup 1226 1232 if (!imageData) { 1227 1233 if (window.acBOOT_LOG) { ··· 4505 4511 sound.bpm = bpm; 4506 4512 4507 4513 // 🎹 DAW Sync (for Max for Live integration) 4508 - // Only connect if ?daw query param is present (for M4L browser-based embedding) 4509 - const hasDawParam = new URLSearchParams(window.location.search).has("daw"); 4514 + // Connect if ?daw query param is present (for M4L browser-based embedding) 4515 + // or if window.acFORCE_DAW is set (for packed offline amxd bundles loaded 4516 + // via data: URI, where window.location.search is always empty). 4517 + const hasDawParam = 4518 + new URLSearchParams(window.location.search).has("daw") || 4519 + window.acFORCE_DAW === true; 4510 4520 if (hasDawParam) { 4511 4521 _dawConnectSend(send, updateMetronome); 4512 4522
+88 -10
system/public/aesthetic.computer/disks/notepat-remote.mjs
··· 61 61 let wsError = ""; 62 62 let reconnectAt = 0; 63 63 64 + // Dual-transport net channels. Raw WS above carries the authoritative 65 + // notepat:midi subscription (reliable, cross-session). UDP via geckos.io 66 + // carries the same events on a low-latency path — session-server fans out 67 + // notepat:midi over both when a subscriber opens both. 68 + let netSocket = null; 69 + let netUdp = null; 70 + let udpSubscribed = false; 71 + let udpRelayCount = 0; 72 + 64 73 let sources = []; 65 74 let relayCount = 0; 66 75 ··· 86 95 try { 87 96 wsState = "connecting"; 88 97 wsError = ""; 98 + console.log(`[notepat-remote] WS connecting → ${WS_URL}`); 89 99 ws = new WebSocket(WS_URL); 90 100 ws.onopen = () => { 91 101 wsState = "open"; 102 + console.log(`[notepat-remote] WS open, subscribing to notepat:midi`); 92 103 try { 93 104 ws.send(JSON.stringify({ 94 105 type: "notepat:midi:subscribe", ··· 110 121 })); 111 122 } 112 123 }; 113 - ws.onerror = () => { wsState = "error"; wsError = "ws err"; }; 114 - ws.onclose = () => { wsState = "closed"; reconnectAt = frame + RECONNECT_FRAMES; }; 124 + ws.onerror = (e) => { 125 + wsState = "error"; wsError = "ws err"; 126 + console.warn(`[notepat-remote] WS error`, e?.message || e); 127 + }; 128 + ws.onclose = (e) => { 129 + wsState = "closed"; reconnectAt = frame + RECONNECT_FRAMES; 130 + console.log(`[notepat-remote] WS closed code=${e?.code} reason=${e?.reason || ""} — reconnect in ${RECONNECT_FRAMES} frames`); 131 + }; 115 132 } catch (err) { 116 133 wsState = "error"; 117 134 wsError = err?.message || "connect fail"; 118 135 reconnectAt = frame + RECONNECT_FRAMES; 136 + console.warn(`[notepat-remote] WS connect threw: ${err?.message || err}`); 119 137 } 120 138 } 121 139 122 - function handleRelay(ev) { 140 + function handleRelay(ev, transport = "ws") { 123 141 if (!_send) return; 124 142 const pitch = Number(ev.note); 125 143 const vel = Number(ev.velocity); ··· 139 157 pitch, 140 158 vel, 141 159 source: "relay", 160 + transport, 142 161 handle: ev.handle || "", 143 162 ts: Date.now(), 144 163 }; ··· 164 183 return (baseOctave + 1) * 12 + off; 165 184 } 166 185 167 - function boot({ wipe, cursor, hud, send }) { 186 + function boot({ wipe, cursor, hud, send, net }) { 168 187 wipe(10, 12, 22); 169 188 cursor?.("native"); 170 189 hud?.label?.(""); 171 190 _send = send; 172 191 connectWs(); 192 + // Also open AC's session-scoped socket + udp purely so the transport 193 + // status indicator can show UDP connectivity. Callbacks are no-ops — 194 + // the notepat:midi subscription flows through the raw WS above. 195 + try { 196 + netUdp = net?.udp?.((type, content) => { 197 + if (type === "notepat:midi") { 198 + udpRelayCount += 1; 199 + handleRelay(content, "udp"); 200 + } else if (type === "notepat:midi:subscribed") { 201 + console.log(`[notepat-remote] UDP subscribe ack`, content); 202 + } 203 + }); 204 + console.log(`[notepat-remote] UDP channel opened (via net.udp)`); 205 + } catch (err) { 206 + console.warn(`[notepat-remote] UDP open threw: ${err?.message || err}`); 207 + } 208 + try { 209 + netSocket = net?.socket?.(() => {}); 210 + } catch (err) { 211 + console.warn(`[notepat-remote] session WS open threw: ${err?.message || err}`); 212 + } 173 213 } 174 214 215 + let lastStatusLogFrame = -9999; 216 + const STATUS_LOG_INTERVAL = 60 * 3; // every ~3 seconds at 60fps 217 + 175 218 function sim() { 176 219 frame += 1; 177 220 if (wsState === "closed" && frame >= reconnectAt) connectWs(); 221 + 222 + // Subscribe over UDP once the geckos channel comes up. Lost connection 223 + // resets the flag so we re-subscribe on reconnect. 224 + if (netUdp?.connected && !udpSubscribed) { 225 + udpSubscribed = true; 226 + try { 227 + netUdp.send("notepat:midi:subscribe", { all: true }); 228 + console.log(`[notepat-remote] UDP subscribed to notepat:midi`); 229 + } catch (err) { 230 + console.warn(`[notepat-remote] UDP subscribe failed: ${err?.message || err}`); 231 + udpSubscribed = false; 232 + } 233 + } else if (!netUdp?.connected && udpSubscribed) { 234 + udpSubscribed = false; 235 + } 236 + 237 + // Periodic transport heartbeat so Max Console users can see without a 238 + // UI screenshot whether WS and UDP are actually linked. 239 + if (frame - lastStatusLogFrame >= STATUS_LOG_INTERVAL) { 240 + lastStatusLogFrame = frame; 241 + const udpOk = !!netUdp?.connected; 242 + const wsOk = wsState === "open"; 243 + console.log(`[notepat-remote] status: WS=${wsState}${wsError?`(${wsError})`:""} UDP=${udpOk?(udpSubscribed?"subscribed":"connected"):"--"} ws-relays=${relayCount - udpRelayCount} udp-relays=${udpRelayCount}`); 244 + } 178 245 } 179 246 180 247 let tappedButton = null; // button object currently pressed via touch ··· 310 377 ink(...accentBright, floor(40 * f)).box(0, 0, W, H, "fill"); 311 378 } 312 379 313 - // ── Header row: piece name + ws state ───────────────────────────────── 380 + // ── Header row: piece name + transport state ───────────────────────── 314 381 let y = 2; 315 382 ink(...accent).write("notepat-remote", { x: 4, y }); 316 - const wsColor = 383 + // Transport indicator mirrors arena.mjs: UDP (green) > WS (yellow) > 384 + // OFFLINE (red). UDP means net.udp is live (session geckos channel up); 385 + // WS means the raw notepat:midi subscription socket is open. 386 + const udpOk = !!netUdp?.connected; 387 + const wsOk = wsState === "open"; 388 + const transport = udpOk && wsOk ? "UDP+WS" 389 + : udpOk ? "UDP" 390 + : wsOk ? "WS" 391 + : wsState === "connecting" ? "..." 392 + : "OFFLINE"; 393 + const transportColor = 317 394 !focused ? dim : 318 - wsState === "open" ? [120, 220, 140] : 395 + udpOk ? [120, 220, 140] : 396 + wsOk ? [230, 200, 80] : 319 397 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 }); 398 + [255, 100, 100]; 399 + ink(...transportColor).write(transport, { x: W - transport.length * 6 - 4, y }); 322 400 y += 10; 323 401 324 402 // ── Status row: ACTIVE + octave + last note ────────────────────────── ··· 332 410 const pn = pitchName(lastNote.pitch); 333 411 const noteColor = noteFresh ? accent : dim; 334 412 const srcTag = 335 - lastNote.source === "relay" ? "@" + (lastNote.handle || "?") : 413 + lastNote.source === "relay" ? (lastNote.transport === "udp" ? "⚡" : "") + "@" + (lastNote.handle || "?") : 336 414 lastNote.source === "tap" ? "tap" : "kbd"; 337 415 const label = `${lastNote.vel === 0 ? "v" : "^"} ${pn} ${srcTag}`; 338 416 ink(...noteColor).write(label, { x: W - label.length * 6 - 4, y });
system/public/m4l/notepat-remote.amxd

This is a binary file and will not be displayed.