Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

notepat-remote: button grid, octave 1-9 hot-switch, focus attract mode, stop-all-on-blur

+430 -333
+72 -115
ac-m4l/AC-NotepatRemote.amxd.json
··· 3 3 "fileversion": 1, 4 4 "appversion": { "major": 9, "minor": 0, "revision": 7, "architecture": "x64", "modernui": 1 }, 5 5 "classnamespace": "box", 6 - "rect": [100, 100, 900, 620], 6 + "rect": [100, 100, 900, 520], 7 7 "openrect": [0, 0, 360, 220], 8 8 "openinpresentation": 1, 9 9 "gridsize": [15, 15], 10 10 "enablehscroll": 0, 11 11 "enablevscroll": 0, 12 12 "devicewidth": 360, 13 - "description": "ac-native notepat relay + local hotkey input. BIOS in the jweb~ iframe captures keys and forwards via window.max.outlet so Max's focus limits don't apply.", 13 + "description": "ac-native notepat relay + local hotkey input. BIOS in the jweb~ iframe owns keyboard + octave state and emits finished pitches via window.max.outlet (notedown/noteup). Patcher just routes to [noteout].", 14 14 "boxes": [ 15 15 { 16 16 "box": { ··· 25 25 "presentation": 1, 26 26 "presentation_rect": [0, 0, 360, 220], 27 27 "rendermode": 1, 28 - "url": "https://aesthetic.computer/notepat-remote?daw=1&density=1&nogap&v=8" 28 + "url": "https://aesthetic.computer/notepat-remote?daw=1&density=1&nogap&v=9" 29 29 } 30 30 }, 31 31 { 32 32 "box": { 33 - "comment": "Split jweb messages: note / channel / keydown / keyup / other", 33 + "comment": "Split jweb messages by symbol", 34 34 "id": "obj-route", 35 35 "maxclass": "newobj", 36 36 "numinlets": 1, 37 - "numoutlets": 5, 38 - "outlettype": ["", "", "", "", ""], 39 - "patching_rect": [10, 250, 360, 22], 40 - "text": "route note channel keydown keyup" 37 + "numoutlets": 6, 38 + "outlettype": ["", "", "", "", "", ""], 39 + "patching_rect": [10, 250, 560, 22], 40 + "text": "route note channel notedown noteup octave focus" 41 41 } 42 42 }, 43 43 { 44 44 "box": { 45 - "comment": "MIDI output for the track's device chain", 45 + "comment": "MIDI output into the track's device chain", 46 46 "id": "obj-noteout", 47 47 "maxclass": "newobj", 48 48 "numinlets": 2, 49 49 "numoutlets": 0, 50 - "patching_rect": [10, 580, 60, 22], 50 + "patching_rect": [10, 400, 60, 22], 51 51 "text": "noteout" 52 52 } 53 53 }, 54 54 { 55 55 "box": { 56 - "comment": "Debug: note pitch/vel after route (relay path)", 56 + "comment": "Note-on: pitch → (pitch, 100)", 57 + "id": "obj-pack-on", 58 + "maxclass": "newobj", 59 + "numinlets": 2, 60 + "numoutlets": 1, 61 + "outlettype": ["list"], 62 + "patching_rect": [10, 360, 90, 22], 63 + "text": "pack 0 100" 64 + } 65 + }, 66 + { 67 + "box": { 68 + "comment": "Note-off: pitch → (pitch, 0)", 69 + "id": "obj-pack-off", 70 + "maxclass": "newobj", 71 + "numinlets": 2, 72 + "numoutlets": 1, 73 + "outlettype": ["list"], 74 + "patching_rect": [120, 360, 90, 22], 75 + "text": "pack 0 0" 76 + } 77 + }, 78 + { 79 + "box": { 80 + "comment": "Debug: relay note pitch/vel (from WS path)", 57 81 "id": "obj-print-note", 58 82 "maxclass": "newobj", 59 83 "numinlets": 1, 60 84 "numoutlets": 0, 61 - "patching_rect": [400, 290, 200, 22], 85 + "patching_rect": [600, 290, 200, 22], 62 86 "text": "print NOTEPAT-NOTE" 63 87 } 64 88 }, 65 89 { 66 90 "box": { 67 - "comment": "Debug: channel after route", 91 + "comment": "Debug: channel from relay", 68 92 "id": "obj-print-chan", 69 93 "maxclass": "newobj", 70 94 "numinlets": 1, 71 95 "numoutlets": 0, 72 - "patching_rect": [400, 320, 200, 22], 96 + "patching_rect": [600, 320, 200, 22], 73 97 "text": "print NOTEPAT-CHAN" 74 98 } 75 99 }, 76 100 { 77 101 "box": { 78 - "comment": "Debug: unmatched jweb messages", 79 - "id": "obj-print-other", 102 + "comment": "Debug: BIOS-computed pitch (keydown)", 103 + "id": "obj-print-keydown", 80 104 "maxclass": "newobj", 81 105 "numinlets": 1, 82 106 "numoutlets": 0, 83 - "patching_rect": [400, 350, 200, 22], 84 - "text": "print NOTEPAT-OTHER" 107 + "patching_rect": [600, 360, 200, 22], 108 + "text": "print NOTEPAT-DOWN" 109 + } 110 + }, 111 + { 112 + "box": { 113 + "comment": "Debug: BIOS-computed pitch (keyup)", 114 + "id": "obj-print-keyup", 115 + "maxclass": "newobj", 116 + "numinlets": 1, 117 + "numoutlets": 0, 118 + "patching_rect": [600, 390, 200, 22], 119 + "text": "print NOTEPAT-UP" 85 120 } 86 121 }, 87 122 { 88 123 "box": { 89 - "comment": "Debug: ASCII code on keydown forwarded from BIOS", 90 - "id": "obj-print-keydown", 124 + "comment": "Debug: current base octave", 125 + "id": "obj-print-octave", 91 126 "maxclass": "newobj", 92 127 "numinlets": 1, 93 128 "numoutlets": 0, 94 - "patching_rect": [400, 410, 200, 22], 95 - "text": "print KEY-DOWN" 129 + "patching_rect": [600, 420, 200, 22], 130 + "text": "print NOTEPAT-OCT" 96 131 } 97 132 }, 98 133 { 99 134 "box": { 100 - "comment": "Debug: ASCII code on keyup", 101 - "id": "obj-print-keyup", 135 + "comment": "Debug: iframe focus state (1 / 0)", 136 + "id": "obj-print-focus", 102 137 "maxclass": "newobj", 103 138 "numinlets": 1, 104 139 "numoutlets": 0, 105 - "patching_rect": [400, 440, 200, 22], 106 - "text": "print KEY-UP" 140 + "patching_rect": [600, 450, 200, 22], 141 + "text": "print NOTEPAT-FOCUS" 107 142 } 108 143 }, 109 144 { 110 145 "box": { 111 - "comment": "Debug: mapped pitch after expr (0 means unmapped)", 112 - "id": "obj-print-keypitch", 146 + "comment": "Debug: unmatched jweb messages", 147 + "id": "obj-print-other", 113 148 "maxclass": "newobj", 114 149 "numinlets": 1, 115 150 "numoutlets": 0, 116 - "patching_rect": [400, 510, 200, 22], 117 - "text": "print KEY-PITCH" 151 + "patching_rect": [600, 480, 200, 22], 152 + "text": "print NOTEPAT-OTHER" 118 153 } 119 154 }, 120 155 { ··· 124 159 "numinlets": 1, 125 160 "numoutlets": 3, 126 161 "outlettype": ["bang", "int", "int"], 127 - "patching_rect": [620, 250, 90, 22], 162 + "patching_rect": [240, 290, 90, 22], 128 163 "text": "live.thisdevice" 129 164 } 130 165 }, ··· 135 170 "numinlets": 1, 136 171 "numoutlets": 2, 137 172 "outlettype": ["", ""], 138 - "patching_rect": [620, 280, 60, 22], 173 + "patching_rect": [240, 320, 60, 22], 139 174 "text": "route ready" 140 175 } 141 176 }, ··· 147 182 "numinlets": 2, 148 183 "numoutlets": 1, 149 184 "outlettype": [""], 150 - "patching_rect": [620, 310, 120, 22], 185 + "patching_rect": [240, 350, 120, 22], 151 186 "text": "script daw-activate" 152 187 } 153 - }, 154 - { 155 - "box": { 156 - "comment": "ASCII → MIDI pitch (notepat layout). 0 = unmapped.", 157 - "id": "obj-expr-on", 158 - "maxclass": "newobj", 159 - "numinlets": 1, 160 - "numoutlets": 1, 161 - "outlettype": ["int"], 162 - "patching_rect": [10, 420, 780, 22], 163 - "text": "expr (($i1==97)*69)+(($i1==98)*71)+(($i1==99)*60)+(($i1==100)*62)+(($i1==101)*64)+(($i1==102)*65)+(($i1==103)*67)+(($i1==104)*72)+(($i1==105)*74)+(($i1==106)*76)+(($i1==107)*77)+(($i1==108)*79)+(($i1==109)*81)+(($i1==110)*83)+(($i1==111)*80)+(($i1==112)*82)+(($i1==113)*70)+(($i1==114)*68)+(($i1==115)*63)+(($i1==116)*73)+(($i1==117)*78)+(($i1==118)*61)+(($i1==119)*66)+(($i1==120)*59)+(($i1==121)*75)+(($i1==122)*58)" 164 - } 165 - }, 166 - { 167 - "box": { 168 - "comment": "Same mapping for keyup side", 169 - "id": "obj-expr-off", 170 - "maxclass": "newobj", 171 - "numinlets": 1, 172 - "numoutlets": 1, 173 - "outlettype": ["int"], 174 - "patching_rect": [10, 450, 780, 22], 175 - "text": "expr (($i1==97)*69)+(($i1==98)*71)+(($i1==99)*60)+(($i1==100)*62)+(($i1==101)*64)+(($i1==102)*65)+(($i1==103)*67)+(($i1==104)*72)+(($i1==105)*74)+(($i1==106)*76)+(($i1==107)*77)+(($i1==108)*79)+(($i1==109)*81)+(($i1==110)*83)+(($i1==111)*80)+(($i1==112)*82)+(($i1==113)*70)+(($i1==114)*68)+(($i1==115)*63)+(($i1==116)*73)+(($i1==117)*78)+(($i1==118)*61)+(($i1==119)*66)+(($i1==120)*59)+(($i1==121)*75)+(($i1==122)*58)" 176 - } 177 - }, 178 - { 179 - "box": { 180 - "comment": "Drop 0 (unmapped key), pass pitch out of right outlet", 181 - "id": "obj-sel-on", 182 - "maxclass": "newobj", 183 - "numinlets": 1, 184 - "numoutlets": 2, 185 - "outlettype": ["bang", "int"], 186 - "patching_rect": [10, 480, 60, 22], 187 - "text": "select 0" 188 - } 189 - }, 190 - { 191 - "box": { 192 - "id": "obj-sel-off", 193 - "maxclass": "newobj", 194 - "numinlets": 1, 195 - "numoutlets": 2, 196 - "outlettype": ["bang", "int"], 197 - "patching_rect": [200, 480, 60, 22], 198 - "text": "select 0" 199 - } 200 - }, 201 - { 202 - "box": { 203 - "comment": "pitch → (pitch, 100) list for noteout (note-on)", 204 - "id": "obj-pack-on", 205 - "maxclass": "newobj", 206 - "numinlets": 2, 207 - "numoutlets": 1, 208 - "outlettype": ["list"], 209 - "patching_rect": [10, 540, 80, 22], 210 - "text": "pack 0 100" 211 - } 212 - }, 213 - { 214 - "box": { 215 - "comment": "pitch → (pitch, 0) list for noteout (note-off)", 216 - "id": "obj-pack-off", 217 - "maxclass": "newobj", 218 - "numinlets": 2, 219 - "numoutlets": 1, 220 - "outlettype": ["list"], 221 - "patching_rect": [200, 540, 80, 22], 222 - "text": "pack 0 0" 223 - } 224 188 } 225 189 ], 226 190 "lines": [ ··· 229 193 { "patchline": { "source": ["obj-route", 0], "destination": ["obj-print-note", 0] } }, 230 194 { "patchline": { "source": ["obj-route", 1], "destination": ["obj-noteout", 1] } }, 231 195 { "patchline": { "source": ["obj-route", 1], "destination": ["obj-print-chan", 0] } }, 232 - { "patchline": { "source": ["obj-route", 2], "destination": ["obj-expr-on", 0] } }, 196 + { "patchline": { "source": ["obj-route", 2], "destination": ["obj-pack-on", 0] } }, 233 197 { "patchline": { "source": ["obj-route", 2], "destination": ["obj-print-keydown", 0] } }, 234 - { "patchline": { "source": ["obj-route", 3], "destination": ["obj-expr-off", 0] } }, 198 + { "patchline": { "source": ["obj-route", 3], "destination": ["obj-pack-off", 0] } }, 235 199 { "patchline": { "source": ["obj-route", 3], "destination": ["obj-print-keyup", 0] } }, 236 - { "patchline": { "source": ["obj-route", 4], "destination": ["obj-print-other", 0] } }, 237 - 238 - { "patchline": { "source": ["obj-expr-on", 0], "destination": ["obj-sel-on", 0] } }, 239 - { "patchline": { "source": ["obj-sel-on", 1], "destination": ["obj-pack-on", 0] } }, 240 - { "patchline": { "source": ["obj-sel-on", 1], "destination": ["obj-print-keypitch", 0] } }, 200 + { "patchline": { "source": ["obj-route", 4], "destination": ["obj-print-octave", 0] } }, 201 + { "patchline": { "source": ["obj-route", 5], "destination": ["obj-print-focus", 0] } }, 241 202 { "patchline": { "source": ["obj-pack-on", 0], "destination": ["obj-noteout", 0] } }, 242 - 243 - { "patchline": { "source": ["obj-expr-off", 0], "destination": ["obj-sel-off", 0] } }, 244 - { "patchline": { "source": ["obj-sel-off", 1], "destination": ["obj-pack-off", 0] } }, 245 203 { "patchline": { "source": ["obj-pack-off", 0], "destination": ["obj-noteout", 0] } }, 246 - 247 204 { "patchline": { "source": ["obj-thisdevice", 0], "destination": ["obj-routeready", 0] } }, 248 205 { "patchline": { "source": ["obj-routeready", 0], "destination": ["obj-activate", 0] } }, 249 206 { "patchline": { "source": ["obj-activate", 0], "destination": ["obj-jweb", 0] } }
+65 -9
system/public/aesthetic.computer/bios.mjs
··· 4515 4515 // objects never see keystrokes. Capture them here on the main thread and 4516 4516 // forward directly via window.max.outlet — sub-ms, no worker round-trip, 4517 4517 // no iframe focus fight. 4518 - const _dawKeyEmit = (kind, e) => { 4519 - if (e.repeat) return; // skip auto-repeat 4520 - const k = typeof e.key === "string" ? e.key : ""; 4521 - if (k.length !== 1) return; // skip modifier / arrow / function keys 4522 - const ascii = k.toLowerCase().charCodeAt(0); 4518 + // 4519 + // BIOS owns the notepat key→pitch layout and octave state so we can emit 4520 + // a finished MIDI pitch and keep the Max patcher trivial. The piece UI 4521 + // also listens to the same keystrokes to render matching visual feedback. 4522 + const _dawKeyOffsets = { 4523 + z: -2, x: -1, 4524 + c: 0, v: 1, d: 2, s: 3, e: 4, f: 5, w: 6, 4525 + g: 7, r: 8, a: 9, q: 10, b: 11, 4526 + h: 12, t: 13, i: 14, y: 15, j: 16, k: 17, u: 18, 4527 + l: 19, o: 20, m: 21, p: 22, n: 23, 4528 + ";": 24, "'": 25, "]": 26, 4529 + }; 4530 + let _dawBaseOctave = 4; 4531 + const _dawHeldPitch = {}; // keyLower → emitted pitch (for correct note-off across octave shifts) 4532 + 4533 + function _dawEmitMax(sym, value) { 4523 4534 if ( 4524 4535 typeof window !== "undefined" && 4525 4536 window.max && 4526 4537 typeof window.max.outlet === "function" 4527 4538 ) { 4528 - try { window.max.outlet(kind, ascii); } catch (_err) {} 4539 + try { window.max.outlet(sym, value); } catch (_err) {} 4529 4540 } 4530 - }; 4531 - window.addEventListener("keydown", (e) => _dawKeyEmit("keydown", e), true); 4532 - window.addEventListener("keyup", (e) => _dawKeyEmit("keyup", e), true); 4541 + } 4542 + function _dawComputePitch(k) { 4543 + const off = _dawKeyOffsets[k]; 4544 + if (off === undefined) return null; 4545 + return (_dawBaseOctave + 1) * 12 + off; // baseOctave 4 → C = 60 4546 + } 4547 + 4548 + window.addEventListener("keydown", (e) => { 4549 + if (e.repeat) return; 4550 + const k = typeof e.key === "string" ? e.key : ""; 4551 + if (k.length !== 1) return; 4552 + // Octave hot-switch 1-9 4553 + if (k >= "1" && k <= "9") { 4554 + _dawBaseOctave = parseInt(k, 10); 4555 + _dawEmitMax("octave", _dawBaseOctave); 4556 + return; 4557 + } 4558 + const low = k.toLowerCase(); 4559 + const pitch = _dawComputePitch(low); 4560 + if (pitch === null) return; 4561 + _dawHeldPitch[low] = pitch; 4562 + _dawEmitMax("notedown", pitch); 4563 + }, true); 4564 + 4565 + window.addEventListener("keyup", (e) => { 4566 + const k = typeof e.key === "string" ? e.key : ""; 4567 + if (k.length !== 1) return; 4568 + const low = k.toLowerCase(); 4569 + const pitch = _dawHeldPitch[low]; 4570 + if (pitch === undefined) return; 4571 + delete _dawHeldPitch[low]; 4572 + _dawEmitMax("noteup", pitch); 4573 + }, true); 4574 + 4575 + // Focus/blur indicator. On blur, release all held pitches so we never 4576 + // leave a note hanging when the iframe loses focus (common during an 4577 + // Ableton window switch). The piece UI uses the same events to show a 4578 + // "tap me!" attract state. 4579 + window.addEventListener("focus", () => { 4580 + _dawEmitMax("focus", 1); 4581 + }, true); 4582 + window.addEventListener("blur", () => { 4583 + for (const k of Object.keys(_dawHeldPitch)) { 4584 + _dawEmitMax("noteup", _dawHeldPitch[k]); 4585 + } 4586 + for (const k of Object.keys(_dawHeldPitch)) delete _dawHeldPitch[k]; 4587 + _dawEmitMax("focus", 0); 4588 + }, true); 4533 4589 } 4534 4590 4535 4591 function requestBeat(time) {
+293 -209
system/public/aesthetic.computer/disks/notepat-remote.mjs
··· 1 1 // notepat-remote, 26.4.20 2 - // AC 🎹 Notepat Remote — dual-mode Max for Live device: 3 - // 1) Subscribes to session-server's notepat:midi fanout, so ac-native 4 - // notepat.mjs on a ThinkPad plays this track. 5 - // 2) Accepts local computer-keyboard input (same key→note layout as the 6 - // standard notepat piece) so the device plays as a standalone 7 - // instrument too. 8 - // 9 - // Both paths emit MIDI to Max via window.max.outlet, which the surrounding 10 - // patcher routes through [route note channel] → [noteout]. 2 + // AC 🎹 Notepat Remote — Max for Live device UI. 3 + // • Local keyboard input is owned by BIOS (bios.mjs dawKeyEmit) — it 4 + // captures keydown/keyup in the jweb iframe and calls window.max.outlet 5 + // with a finished MIDI pitch, routed straight to [noteout] in the 6 + // patcher for sub-ms latency. This piece only mirrors that state for 7 + // visual feedback. 8 + // • Session-server relay path: this piece opens a WebSocket to 9 + // wss://session-server.aesthetic.computer/ and forwards notepat:midi 10 + // events via send({type:"daw:midi",...}) which BIOS turns into 11 + // window.max.outlet("note"/"channel", ...). Same bridge, async path. 12 + // • Button grid under the status header lets you tap notes on touch 13 + // devices — each button fires the same note your keyboard would. 14 + // • When the iframe loses focus, Max's key listener stops receiving, so 15 + // the UI goes red + "TAP ME!" attract mode to prompt a click. 11 16 12 - const { floor, min, max } = Math; 17 + const { floor, min, max, abs, sin, PI } = Math; 13 18 14 19 const WS_URL = "wss://session-server.aesthetic.computer/"; 15 - const RECONNECT_FRAMES = 120; // ~2s @ 60fps 20 + const RECONNECT_FRAMES = 120; 16 21 17 - // ─── Keyboard → MIDI pitch (mirrors notepat.mjs NOTE_TO_KEYBOARD_KEY) ─── 18 - // Row 1 (below default octave): z=A#3, x=B3 19 - // Row 2 (C4..B4): c v d s e f w g r a q b 20 - // Row 3 (C5..B5): h t i y j k u l o m p n 21 - // Row 4 (C6+): ; ' ] 22 - const KEY_TO_PITCH = { 23 - z: 58, x: 59, 24 - c: 60, v: 61, d: 62, s: 63, e: 64, f: 65, w: 66, 25 - g: 67, r: 68, a: 69, q: 70, b: 71, 26 - h: 72, t: 73, i: 74, y: 75, j: 76, k: 77, u: 78, 27 - l: 79, o: 80, m: 81, p: 82, n: 83, 28 - ";": 84, "'": 85, "]": 86, 22 + // Key offsets relative to base-octave C. Mirrors bios.mjs _dawKeyOffsets. 23 + const KEY_OFFSETS = { 24 + z: -2, x: -1, 25 + c: 0, v: 1, d: 2, s: 3, e: 4, f: 5, w: 6, 26 + g: 7, r: 8, a: 9, q: 10, b: 11, 27 + h: 12, t: 13, i: 14, y: 15, j: 16, k: 17, u: 18, 28 + l: 19, o: 20, m: 21, p: 22, n: 23, 29 + ";": 24, "'": 25, "]": 26, 29 30 }; 30 31 32 + // Rows laid out like a QWERTY keyboard (for physical muscle memory). 33 + const KEY_ROWS = [ 34 + ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"], 35 + ["a", "s", "d", "f", "g", "h", "j", "k", "l", ";", "'"], 36 + ["z", "x", "c", "v", "b", "n", "m"], 37 + ]; 38 + 39 + const PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; 40 + function pitchName(p) { 41 + return PITCH_NAMES[((p % 12) + 12) % 12] + (floor(p / 12) - 1); 42 + } 43 + function isBlackKey(p) { 44 + return [1, 3, 6, 8, 10].includes(((p % 12) + 12) % 12); 45 + } 46 + 31 47 let ws = null; 32 - let wsState = "idle"; // idle | connecting | open | closed | error 48 + let wsState = "idle"; 33 49 let wsError = ""; 34 50 let reconnectAt = 0; 35 51 36 - let sources = []; // [{ handle, machineId, piece, lastSeen }] 37 - let pktCount = 0; // relay packets received 38 - let noteOnCount = 0; 39 - let noteOffCount = 0; 40 - 41 - let localOnCount = 0; // keyboard-originated notes 42 - let localOffCount = 0; 52 + let sources = []; 53 + let relayCount = 0; 43 54 44 - let lastNote = null; // { pitch, vel, chan, source, handle, ts, latencyMs } 55 + let lastNote = null; 45 56 let lastNoteFrame = -9999; 46 57 47 - const heldKeys = new Set(); // currently-held local keyboard keys 58 + // Local state mirrored from keyboard events (visual only — BIOS emits MIDI). 59 + let baseOctave = 4; 60 + const heldKeys = new Set(); 61 + let focused = true; // assume focused until we learn otherwise 62 + let focusedChangedFrame = 0; 63 + let lastInteractionFrame = -999; 48 64 65 + let _send = null; 49 66 let frame = 0; 50 - let lastEmittedChannel = -1; 51 67 52 - // Piece runs in a Worker, so `window.max.outlet` isn't reachable here. 53 - // BIOS (main thread) owns that bridge: we send a `daw:midi` message via 54 - // the worker→BIOS `send` pipe and BIOS forwards it to Max. 55 - // 56 - // Handler lives in bios.mjs — it calls window.max.outlet("channel", ch) + 57 - // window.max.outlet("note", pitch, vel), which the patcher routes: 58 - // [jweb~ msg outlet] → [route note channel] → [noteout]. 59 - let _send = null; // captured from boot(); used from ws callbacks too 60 - 61 - function emitMaxNote(pitch, velocity, channel) { 62 - if (!_send) return; 63 - _send({ 64 - type: "daw:midi", 65 - content: { pitch, velocity, channel }, 66 - }); 67 - console.log(`🎹 out note=${pitch} vel=${velocity} ch=${channel}`); 68 - } 68 + // Button grid layout (recomputed on each paint in case screen size changes). 69 + let buttons = []; 69 70 70 71 function connectWs() { 71 72 if (typeof WebSocket === "undefined") return; ··· 77 78 ws.onopen = () => { 78 79 wsState = "open"; 79 80 try { 80 - ws.send( 81 - JSON.stringify({ 82 - type: "notepat:midi:subscribe", 83 - content: { all: true }, 84 - }), 85 - ); 81 + ws.send(JSON.stringify({ 82 + type: "notepat:midi:subscribe", 83 + content: { all: true }, 84 + })); 86 85 } catch (_e) {} 87 86 }; 88 87 ws.onmessage = (ev) => { 89 88 let msg; 90 - try { 91 - msg = JSON.parse(ev.data); 92 - } catch { 93 - return; 94 - } 89 + try { msg = JSON.parse(ev.data); } catch { return; } 95 90 if (!msg || !msg.type) return; 96 91 if (msg.type === "notepat:midi") { 97 - handleRelayEvent(msg.content || {}); 92 + handleRelay(msg.content || {}); 98 93 } else if (msg.type === "notepat:midi:sources") { 99 94 const list = (msg.content && msg.content.sources) || []; 100 95 sources = list.map((s) => ({ 101 96 handle: s.handle || "", 102 97 machineId: s.machineId || "", 103 - piece: s.piece || "notepat", 104 - lastSeen: s.lastSeen || 0, 105 98 })); 106 99 } 107 100 }; 108 - ws.onerror = () => { 109 - wsState = "error"; 110 - wsError = "ws error"; 111 - }; 112 - ws.onclose = () => { 113 - wsState = "closed"; 114 - reconnectAt = frame + RECONNECT_FRAMES; 115 - }; 101 + ws.onerror = () => { wsState = "error"; wsError = "ws err"; }; 102 + ws.onclose = () => { wsState = "closed"; reconnectAt = frame + RECONNECT_FRAMES; }; 116 103 } catch (err) { 117 104 wsState = "error"; 118 - wsError = err?.message || "connect failed"; 105 + wsError = err?.message || "connect fail"; 119 106 reconnectAt = frame + RECONNECT_FRAMES; 120 107 } 121 108 } 122 109 123 - function handleRelayEvent(ev) { 110 + function handleRelay(ev) { 111 + if (!_send) return; 124 112 const pitch = Number(ev.note); 125 113 const vel = Number(ev.velocity); 126 114 const chan = Number(ev.channel) || 0; 127 115 if (!Number.isFinite(pitch) || !Number.isFinite(vel)) return; 128 - pktCount += 1; 129 - const now = Date.now(); 130 - const tsNum = Number(ev.ts); 131 - const latency = Number.isFinite(tsNum) ? max(0, now - tsNum) : 0; 116 + relayCount += 1; 132 117 const isOff = ev.event === "note_off" || (ev.event === "note_on" && vel === 0); 133 - if (isOff) { 134 - noteOffCount += 1; 135 - emitMaxNote(pitch, 0, chan); 136 - } else { 137 - noteOnCount += 1; 138 - emitMaxNote(pitch, max(1, min(127, vel)), chan); 139 - } 118 + _send({ 119 + type: "daw:midi", 120 + content: { 121 + pitch, 122 + velocity: isOff ? 0 : max(1, min(127, vel)), 123 + channel: chan, 124 + }, 125 + }); 140 126 lastNote = { 141 127 pitch, 142 128 vel, 143 - chan, 144 129 source: "relay", 145 130 handle: ev.handle || "", 146 - ts: now, 147 - latencyMs: latency, 131 + ts: Date.now(), 148 132 }; 149 133 lastNoteFrame = frame; 150 134 } 151 135 152 - function pressLocalKey(key) { 153 - if (heldKeys.has(key)) return; 154 - const pitch = KEY_TO_PITCH[key]; 155 - if (pitch === undefined) return; 156 - heldKeys.add(key); 157 - localOnCount += 1; 158 - emitMaxNote(pitch, 100, 0); 159 - lastNote = { 160 - pitch, 161 - vel: 100, 162 - chan: 0, 163 - source: "local", 164 - handle: "", 165 - ts: Date.now(), 166 - latencyMs: 0, 167 - }; 136 + // Emit a note via the daw:midi pipe (touch/click path — keyboard notes go 137 + // through BIOS directly for speed, not through here). 138 + function tapNote(pitch, on) { 139 + if (!_send) return; 140 + _send({ 141 + type: "daw:midi", 142 + content: { pitch, velocity: on ? 100 : 0, channel: 0 }, 143 + }); 144 + lastNote = { pitch, vel: on ? 100 : 0, source: "tap", handle: "", ts: Date.now() }; 168 145 lastNoteFrame = frame; 146 + lastInteractionFrame = frame; 169 147 } 170 148 171 - function releaseLocalKey(key) { 172 - if (!heldKeys.has(key)) return; 173 - const pitch = KEY_TO_PITCH[key]; 174 - heldKeys.delete(key); 175 - if (pitch === undefined) return; 176 - localOffCount += 1; 177 - emitMaxNote(pitch, 0, 0); 178 - } 179 - 180 - const PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; 181 - function pitchName(p) { 182 - const n = PITCH_NAMES[((p % 12) + 12) % 12]; 183 - const o = floor(p / 12) - 1; 184 - return n + o; 149 + function pitchForKey(key) { 150 + const off = KEY_OFFSETS[key]; 151 + if (off === undefined) return null; 152 + return (baseOctave + 1) * 12 + off; 185 153 } 186 154 187 155 function boot({ wipe, cursor, hud, send }) { 188 - wipe(8, 10, 18); 156 + wipe(10, 12, 22); 189 157 cursor?.("native"); 190 - // Hide the default HUD piece-name label — device UI has its own header. 191 158 hud?.label?.(""); 192 - // Capture `send` for use in ws callbacks and key/touch handlers below. 193 159 _send = send; 194 - console.log("🎹 boot ready, send captured:", typeof _send); 195 160 connectWs(); 196 161 } 197 162 ··· 200 165 if (wsState === "closed" && frame >= reconnectAt) connectWs(); 201 166 } 202 167 203 - let eventsSeen = 0; 168 + let tappedButton = null; // button object currently pressed via touch 204 169 205 170 function act({ event: e }) { 206 171 if (!e?.is) return; 207 - // Keyboard input is handled natively in the Max patcher ([key] / [keyup] 208 - // objects direct to [noteout]) for sub-ms latency — no worker round-trip. 209 - // The piece intentionally does NOT listen for keyboard events here to 210 - // avoid double-firing. 211 - // 212 - // Click-to-test-note: tapping the device UI fires C4 so we can smoke-test 213 - // the iframe→BIOS→Max bridge path without depending on keyboard focus. 172 + 173 + // Focus/blur signals from AC (if AC forwards them; harmless if it doesn't). 174 + if (e.is("focus")) { 175 + focused = true; 176 + focusedChangedFrame = frame; 177 + return; 178 + } 179 + if (e.is("blur")) { 180 + focused = false; 181 + focusedChangedFrame = frame; 182 + heldKeys.clear(); 183 + return; 184 + } 185 + 186 + // Button grid taps — tapNote for touch, release on lift. 214 187 if (e.is("touch")) { 215 - pressLocalKey("c"); 188 + lastInteractionFrame = frame; 189 + const hit = hitButton(e.x, e.y); 190 + if (hit) { 191 + tappedButton = hit; 192 + tapNote(hit.pitch, true); 193 + } 216 194 return; 217 195 } 218 196 if (e.is("lift")) { 219 - releaseLocalKey("c"); 197 + if (tappedButton) { 198 + tapNote(tappedButton.pitch, false); 199 + tappedButton = null; 200 + } 220 201 return; 221 202 } 203 + if (e.is("draw")) { 204 + // Allow drag across buttons (piano-roll tap). Release previous, press new. 205 + const hit = hitButton(e.x, e.y); 206 + if (hit && hit !== tappedButton) { 207 + if (tappedButton) tapNote(tappedButton.pitch, false); 208 + tappedButton = hit; 209 + tapNote(hit.pitch, true); 210 + } 211 + return; 212 + } 213 + 214 + // Octave hot-switch 1-9 (BIOS also tracks this, we mirror for UI). 215 + for (let n = 1; n <= 9; n += 1) { 216 + if (e.is(`keyboard:down:${n}`)) { 217 + baseOctave = n; 218 + focused = true; 219 + lastInteractionFrame = frame; 220 + return; 221 + } 222 + } 223 + 224 + // Track keyboard state for visual feedback only. 225 + for (const key of Object.keys(KEY_OFFSETS)) { 226 + if (e.is(`keyboard:down:${key}`)) { 227 + if (!heldKeys.has(key)) { 228 + heldKeys.add(key); 229 + const p = pitchForKey(key); 230 + if (p !== null) { 231 + lastNote = { pitch: p, vel: 100, source: "kbd", handle: "", ts: Date.now() }; 232 + lastNoteFrame = frame; 233 + } 234 + } 235 + focused = true; 236 + lastInteractionFrame = frame; 237 + return; 238 + } 239 + if (e.is(`keyboard:up:${key}`)) { 240 + heldKeys.delete(key); 241 + return; 242 + } 243 + } 244 + } 245 + 246 + function hitButton(x, y) { 247 + if (typeof x !== "number" || typeof y !== "number") return null; 248 + for (const b of buttons) { 249 + if (x >= b.x && x < b.x + b.w && y >= b.y && y < b.y + b.h) return b; 250 + } 251 + return null; 222 252 } 223 253 224 254 function paint({ wipe, ink, box, line, screen }) { 225 - const bg = [8, 10, 18]; 226 - const fg = [220, 225, 255]; 227 - const fgDim = [130, 140, 170]; 228 - const accent = [255, 170, 80]; 229 - const good = [140, 255, 180]; 230 - const bad = [255, 120, 120]; 231 - const warn = [255, 220, 100]; 232 - wipe(...bg); 233 - 234 255 const W = screen.width; 235 256 const H = screen.height; 236 - let y = 4; 237 257 238 - // Header — bridge status is whether `send` was captured at boot. 239 - const bridgeActive = !!_send; 240 - ink(...accent).write("NOTEPAT-REMOTE", { x: 4, y, size: 1 }); 241 - ink(...(bridgeActive ? good : warn)).write( 242 - bridgeActive ? "[M4L]" : "[solo]", 243 - { x: 112, y }, 244 - ); 258 + // Attract mode: blink brightness between 0.4 and 1 every ~30 frames. 259 + const attract = !focused; 260 + const blinkPhase = (sin(frame * 0.1) + 1) / 2; // 0..1 261 + const attractPulse = attract ? 0.4 + blinkPhase * 0.6 : 1; 262 + 263 + // Palette — active uses lime/green, attract uses warning red. 264 + const accent = focused 265 + ? [floor(140 + blinkPhase * 30), 255, floor(180 + blinkPhase * 40)] 266 + : [255, floor(80 + blinkPhase * 100), floor(80 + blinkPhase * 40)]; 267 + const dim = [130, 140, 170]; 268 + const fg = [220, 225, 255]; 269 + const bgBase = focused ? [10, 20, 18] : [22, 10, 10]; 270 + wipe(floor(bgBase[0] * attractPulse), floor(bgBase[1] * attractPulse), floor(bgBase[2] * attractPulse)); 271 + 272 + // Flash overlay on recent note-on. 273 + const sinceNote = frame - lastNoteFrame; 274 + if (lastNote && sinceNote < 12) { 275 + const f = 1 - sinceNote / 12; 276 + const alpha = floor(50 * f); 277 + ink(...accent, alpha).box(0, 0, W, H, "fill"); 278 + } 279 + 280 + // Title bar 281 + let y = 3; 282 + ink(...accent).write("notepat-remote", { x: 4, y }); 283 + const wsColor = 284 + wsState === "open" ? [140, 255, 180] : 285 + wsState === "connecting" ? [255, 220, 100] : 286 + wsState === "error" || wsState === "closed" ? [255, 120, 120] : dim; 287 + ink(...wsColor).write(wsState, { x: W - wsState.length * 6 - 4, y }); 245 288 y += 10; 246 289 247 - // WS + sources on one line 248 - const stateColor = 249 - wsState === "open" 250 - ? good 251 - : wsState === "connecting" 252 - ? warn 253 - : wsState === "error" || wsState === "closed" 254 - ? bad 255 - : fgDim; 256 - ink(...fgDim).write("ws", { x: 4, y }); 257 - ink(...stateColor).write(wsState.toUpperCase(), { x: 18, y }); 258 - if (sources.length === 0) { 259 - ink(...fgDim).write("src: (none)", { x: 80, y }); 290 + // Focus state pill / attract message 291 + if (focused) { 292 + ink(...accent).box(4, y, 8, 8, "fill"); 293 + ink(...fg).write("ACTIVE", { x: 16, y }); 294 + ink(...dim).write(`oct ${baseOctave}`, { x: 64, y }); 295 + if (sources.length > 0) { 296 + const src = sources 297 + .slice(0, 2) 298 + .map((s) => s.handle ? "@" + s.handle : s.machineId.slice(0, 6)) 299 + .join(" "); 300 + ink(...dim).write(`relay ${src}`, { x: W - (src.length + 7) * 5, y }); 301 + } else if (relayCount > 0) { 302 + ink(...dim).write(`relay ${relayCount}`, { x: W - 60, y }); 303 + } 260 304 } else { 261 - const label = sources 262 - .slice(0, 3) 263 - .map((s) => (s.handle ? "@" + s.handle : s.machineId.slice(0, 6))) 264 - .join(" "); 265 - ink(...fg).write("src:" + label.slice(0, 24), { x: 80, y }); 266 - } 267 - y += 8; 268 - if (wsError) { 269 - ink(...bad).write(wsError.slice(0, 34), { x: 4, y }); 270 - y += 8; 305 + // Blink red "TAP ME!" in the middle of the header area. 306 + const blinkOn = blinkPhase > 0.3; 307 + if (blinkOn) { 308 + ink(255, 40, 40).write("TAP ME!", { x: 16, y }); 309 + } else { 310 + ink(100, 20, 20).write("TAP ME!", { x: 16, y }); 311 + } 312 + ink(...dim).write(`oct ${baseOctave}`, { x: 64, y }); 271 313 } 272 - 273 - // Counters 274 - ink(...fgDim).write( 275 - `relay ${pktCount} (on ${noteOnCount} off ${noteOffCount})`, 276 - { x: 4, y }, 277 - ); 278 - y += 8; 279 - ink(...fgDim).write( 280 - `local ${localOnCount + localOffCount} (on ${localOnCount} off ${localOffCount})`, 281 - { x: 4, y }, 282 - ); 283 314 y += 10; 284 315 285 - // Last note — flashes accent, fades to fg over ~30 frames 316 + // Last note readout 286 317 if (lastNote) { 287 - const age = frame - lastNoteFrame; 288 - const flashing = age < 30; 289 - const color = flashing ? accent : fg; 290 - const arrow = lastNote.vel === 0 ? "v" : "^"; 291 - const src = lastNote.source === "local" ? "kbd" : "@" + (lastNote.handle || "?"); 292 - const label = `${arrow} ${pitchName(lastNote.pitch)} (${lastNote.pitch}) v${lastNote.vel} ${src}`; 293 - ink(...color).write(label, { x: 4, y }); 294 - y += 8; 318 + const noteFresh = sinceNote < 30; 319 + const noteColor = noteFresh ? accent : fg; 320 + const pn = pitchName(lastNote.pitch); 321 + const src = lastNote.source === "relay" 322 + ? `@${lastNote.handle || "?"}` 323 + : lastNote.source; 324 + const arrow = lastNote.vel === 0 ? "▽" : "▲"; 325 + ink(...noteColor).write(`${arrow} ${pn} (${lastNote.pitch}) ${src}`, { x: 4, y }); 295 326 } else { 296 - ink(...fgDim).write("(no notes yet — type or start relay)", { x: 4, y }); 297 - y += 8; 327 + ink(...dim).write("(no notes yet)", { x: 4, y }); 298 328 } 329 + y += 12; 299 330 300 - // Held-key strip — shows which keyboard keys are currently down 301 - y += 4; 302 - if (heldKeys.size > 0) { 303 - const held = Array.from(heldKeys) 304 - .map((k) => pitchName(KEY_TO_PITCH[k])) 305 - .join(" "); 306 - ink(...accent).write("held: " + held.slice(0, 32), { x: 4, y }); 307 - y += 8; 331 + // Layout the button grid — QWERTY rows mirroring a physical keyboard. 332 + const gridTop = y; 333 + const gridBottom = H - 12; 334 + const rowCount = KEY_ROWS.length; 335 + const rowH = floor((gridBottom - gridTop) / rowCount) - 2; 336 + const maxRowLen = KEY_ROWS.reduce((m, r) => Math.max(m, r.length), 0); 337 + const gridW = W - 8; 338 + const btnW = floor(gridW / maxRowLen); 339 + 340 + buttons = []; 341 + for (let r = 0; r < rowCount; r += 1) { 342 + const row = KEY_ROWS[r]; 343 + const rowW = row.length * btnW; 344 + const rowX = 4 + floor((gridW - rowW) / 2) + r * 4; // slight indent per row for keyboard feel 345 + const rowY = gridTop + r * (rowH + 2); 346 + for (let i = 0; i < row.length; i += 1) { 347 + const key = row[i]; 348 + const pitch = pitchForKey(key); 349 + if (pitch === null) continue; 350 + const b = { 351 + x: rowX + i * btnW, 352 + y: rowY, 353 + w: btnW - 1, 354 + h: rowH, 355 + key, 356 + pitch, 357 + }; 358 + buttons.push(b); 359 + 360 + // Draw 361 + const held = 362 + heldKeys.has(key) || 363 + (tappedButton && tappedButton.key === key); 364 + const recentFlash = lastNote && lastNote.pitch === pitch && sinceNote < 20; 365 + const black = isBlackKey(pitch); 366 + 367 + let fill; 368 + if (held) { 369 + fill = accent; 370 + } else if (recentFlash) { 371 + const f = 1 - sinceNote / 20; 372 + fill = [ 373 + floor(bgBase[0] + (accent[0] - bgBase[0]) * f * 0.7), 374 + floor(bgBase[1] + (accent[1] - bgBase[1]) * f * 0.7), 375 + floor(bgBase[2] + (accent[2] - bgBase[2]) * f * 0.7), 376 + ]; 377 + } else if (black) { 378 + fill = focused ? [28, 38, 34] : [50, 20, 20]; 379 + } else { 380 + fill = focused ? [48, 58, 55] : [72, 30, 30]; 381 + } 382 + ink(...fill).box(b.x, b.y, b.w, b.h, "fill"); 383 + // Outline 384 + ink(...(held ? accent : [90, 100, 110])).box(b.x, b.y, b.w, b.h, "outline"); 385 + // Letter label centered 386 + const letterX = b.x + floor(b.w / 2) - 2; 387 + const letterY = b.y + floor(b.h / 2) - 4; 388 + const letterColor = held ? [10, 20, 10] : (black ? [200, 210, 220] : fg); 389 + ink(...letterColor).write(key.toUpperCase(), { x: letterX, y: letterY }); 390 + } 308 391 } 309 392 310 393 // Footer hint 311 - if (H - y > 12) { 312 - ink(...fgDim).write("keys=native | click=C4 | ws=relay", { x: 4, y: H - 8 }); 394 + const hintY = H - 8; 395 + if (focused) { 396 + ink(...dim).write(`1-9=oct ${baseOctave}`, { x: 4, y: hintY }); 397 + } else { 398 + ink(255, 80, 80).write("click device to play!", { x: 4, y: hintY }); 313 399 } 314 400 } 315 401 316 402 function leave() { 317 - try { 318 - ws?.close(); 319 - } catch {} 403 + try { ws?.close(); } catch {} 320 404 ws = null; 321 405 heldKeys.clear(); 322 406 } ··· 324 408 function meta() { 325 409 return { 326 410 title: "Notepat Remote", 327 - desc: "M4L: ac-native notepat relay + local keyboard notes → MIDI track", 411 + desc: "M4L: native-latency keyboard + session-server relay → MIDI track", 328 412 }; 329 413 } 330 414
system/public/m4l/notepat-remote.amxd

This is a binary file and will not be displayed.