Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

notepat-remote: M4L relay device bridging ac-native → session-server → Ableton

Adds AC 🎹 notepat-remote.amxd, a MIDI Effect Max for Live device that
subscribes to session-server's notepat:midi fanout and emits MIDI into
the track. Pairs with the already-wired UDP broadcast from ac-native
notepat.mjs (port 10010) so ThinkPad key presses land on a track in
Ableton on the Mac.

- ac-m4l/AC-NotepatRemote.amxd.json: jweb~ → route note channel → noteout
- disks/notepat-remote.mjs: jweb~ page with WS client, status UI, Max bridge
- ac-m4l/devices.json: register device for build.py
- fedac/native/pieces/notepat.mjs: pkt/note counters in relay status overlay

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

+405 -2
+118
ac-m4l/AC-NotepatRemote.amxd.json
··· 1 + { 2 + "patcher": { 3 + "fileversion": 1, 4 + "appversion": { "major": 9, "minor": 0, "revision": 7, "architecture": "x64", "modernui": 1 }, 5 + "classnamespace": "box", 6 + "rect": [100, 100, 700, 500], 7 + "openrect": [0, 0, 360, 220], 8 + "openinpresentation": 1, 9 + "gridsize": [15, 15], 10 + "enablehscroll": 0, 11 + "enablevscroll": 0, 12 + "devicewidth": 360, 13 + "description": "Receive ac-native notepat MIDI via session-server relay and emit to this track", 14 + "boxes": [ 15 + { 16 + "box": { 17 + "disablefind": 0, 18 + "id": "obj-jweb", 19 + "latency": 0, 20 + "maxclass": "jweb~", 21 + "numinlets": 1, 22 + "numoutlets": 3, 23 + "outlettype": ["signal", "signal", ""], 24 + "patching_rect": [10, 10, 360, 220], 25 + "presentation": 1, 26 + "presentation_rect": [0, 0, 360, 220], 27 + "rendermode": 1, 28 + "url": "https://aesthetic.computer/notepat-remote?daw=1&nogap" 29 + } 30 + }, 31 + { 32 + "box": { 33 + "comment": "Strip 'note' and 'channel' keywords from jweb messages", 34 + "id": "obj-route", 35 + "maxclass": "newobj", 36 + "numinlets": 1, 37 + "numoutlets": 3, 38 + "outlettype": ["", "", ""], 39 + "patching_rect": [10, 250, 180, 22], 40 + "text": "route note channel" 41 + } 42 + }, 43 + { 44 + "box": { 45 + "comment": "Emit MIDI into the track's next device", 46 + "id": "obj-noteout", 47 + "maxclass": "newobj", 48 + "numinlets": 2, 49 + "numoutlets": 0, 50 + "patching_rect": [10, 300, 60, 22], 51 + "text": "noteout" 52 + } 53 + }, 54 + { 55 + "box": { 56 + "comment": "Debug: unmatched messages land here", 57 + "id": "obj-print", 58 + "maxclass": "newobj", 59 + "numinlets": 1, 60 + "numoutlets": 0, 61 + "patching_rect": [200, 300, 200, 22], 62 + "text": "print NOTEPAT-REMOTE" 63 + } 64 + }, 65 + { 66 + "box": { 67 + "id": "obj-thisdevice", 68 + "maxclass": "newobj", 69 + "numinlets": 1, 70 + "numoutlets": 3, 71 + "outlettype": ["bang", "int", "int"], 72 + "patching_rect": [420, 250, 90, 22], 73 + "text": "live.thisdevice" 74 + } 75 + }, 76 + { 77 + "box": { 78 + "id": "obj-routeready", 79 + "maxclass": "newobj", 80 + "numinlets": 1, 81 + "numoutlets": 2, 82 + "outlettype": ["", ""], 83 + "patching_rect": [420, 280, 60, 22], 84 + "text": "route ready" 85 + } 86 + }, 87 + { 88 + "box": { 89 + "comment": "Tell jweb~ page we are live (page can use this to trigger subscribe)", 90 + "id": "obj-activate", 91 + "maxclass": "message", 92 + "numinlets": 2, 93 + "numoutlets": 1, 94 + "outlettype": [""], 95 + "patching_rect": [420, 310, 90, 22], 96 + "text": "script daw-activate" 97 + } 98 + } 99 + ], 100 + "lines": [ 101 + { "patchline": { "source": ["obj-jweb", 2], "destination": ["obj-route", 0] } }, 102 + { "patchline": { "source": ["obj-route", 0], "destination": ["obj-noteout", 0] } }, 103 + { "patchline": { "source": ["obj-route", 1], "destination": ["obj-noteout", 1] } }, 104 + { "patchline": { "source": ["obj-route", 2], "destination": ["obj-print", 0] } }, 105 + { "patchline": { "source": ["obj-thisdevice", 0], "destination": ["obj-routeready", 0] } }, 106 + { "patchline": { "source": ["obj-routeready", 0], "destination": ["obj-activate", 0] } }, 107 + { "patchline": { "source": ["obj-activate", 0], "destination": ["obj-jweb", 0] } } 108 + ], 109 + "dependency_cache": [], 110 + "latency": 0, 111 + "is_mpe": 0, 112 + "external_mpe_tuning_enabled": 0, 113 + "minimum_live_version": "", 114 + "minimum_max_version": "", 115 + "platform_compatibility": 0, 116 + "autosave": 0 117 + } 118 + }
+10
ac-m4l/devices.json
··· 57 57 "type": "midi", 58 58 "source": "AC-KnobMap.amxd.json", 59 59 "version": "1.0.5" 60 + }, 61 + { 62 + "name": "AC 🎹 notepat-remote (aesthetic.computer)", 63 + "piece": "notepat-remote", 64 + "description": "Relay MIDI from ac-native notepat (ThinkPad) to this track via session-server", 65 + "width": 360, 66 + "height": 220, 67 + "type": "midi", 68 + "source": "AC-NotepatRemote.amxd.json", 69 + "version": "0.1.0" 60 70 } 61 71 ], 62 72 "defaults": {
+29 -2
fedac/native/pieces/notepat.mjs
··· 325 325 let usbMidiTypecSignature = ""; 326 326 let udpMidiBroadcast = false; 327 327 let udpMidiNextHeartbeatFrame = 0; 328 + // Connectivity telemetry for the UDP midi relay overlay 329 + let udpMidiSentCount = 0; 330 + let udpMidiOnCount = 0; 331 + let udpMidiOffCount = 0; 332 + let udpMidiLastPitch = -1; 333 + let udpMidiLastVelocity = 0; 334 + let udpMidiLastSentFrame = -9999; 328 335 329 336 330 337 // OS update panel state machine ··· 1830 1837 function sendUdpMidiEvent(system, event, midiNote, velocity, channel = 0) { 1831 1838 if (!udpMidiBroadcast || !system?.udp?.connected) return; 1832 1839 system?.udp?.sendMidi?.(event, midiNote, velocity, channel, "notepat"); 1840 + udpMidiSentCount += 1; 1841 + if (event === "note_off" || (event === "note_on" && velocity === 0)) { 1842 + udpMidiOffCount += 1; 1843 + } else { 1844 + udpMidiOnCount += 1; 1845 + } 1846 + udpMidiLastPitch = midiNote; 1847 + udpMidiLastVelocity = velocity; 1848 + udpMidiLastSentFrame = frame; 1833 1849 } 1834 1850 1835 1851 function maybeSendUdpMidiHeartbeat(system) { ··· 1842 1858 function udpMidiRelayStatusText(system) { 1843 1859 if (!udpMidiBroadcast) return ""; 1844 1860 const handle = system?.udp?.handle || system?.config?.handle || ""; 1845 - if (system?.udp?.connected) return handle ? "relay @" + handle : "relay on"; 1846 - return handle ? "relay ...@" + handle : "relay ..."; 1861 + const connected = !!system?.udp?.connected; 1862 + const prefix = connected 1863 + ? (handle ? "relay @" + handle : "relay on") 1864 + : (handle ? "relay ...@" + handle : "relay ..."); 1865 + // Append counters + last note when actively sending so the overlay shows 1866 + // the ThinkPad actually broadcasts notes (and not just that the socket is up). 1867 + if (!connected) return prefix; 1868 + if (udpMidiSentCount === 0) return prefix + " 0"; 1869 + const recent = frame - udpMidiLastSentFrame < 90; // ~1.5s fresh window 1870 + const tail = recent && udpMidiLastPitch >= 0 1871 + ? ` ${udpMidiSentCount} ${udpMidiLastPitch}v${udpMidiLastVelocity}` 1872 + : ` ${udpMidiSentCount}`; 1873 + return prefix + tail; 1847 1874 } 1848 1875 1849 1876 function rememberSound(key, entry, system, velocity = 1) {
+248
system/public/aesthetic.computer/disks/notepat-remote.mjs
··· 1 + // notepat-remote, 26.4.20 2 + // AC 🎹 Notepat Remote — receives notepat:midi events from session-server 3 + // and bridges them to Max for Live via window.max.outlet. 4 + // 5 + // Meant to load inside jweb~ in AC-NotepatRemote.amxd. When opened standalone 6 + // in a browser it still renders the connection status UI (MIDI out is a no-op). 7 + // 8 + // Wire path: 9 + // ThinkPad ac-native notepat.mjs 10 + // → UDP :10010 → session-server.aesthetic.computer 11 + // → WS fanout → this piece 12 + // → window.max.outlet(["note", pitch, vel]) → Max [route note] → [noteout] 13 + 14 + const { floor, min, max } = Math; 15 + 16 + const WS_URL = "wss://session-server.aesthetic.computer/"; 17 + const RECONNECT_FRAMES = 120; // ~2s @ 60fps 18 + 19 + let ws = null; 20 + let wsState = "idle"; // idle | connecting | open | closed | error 21 + let wsError = ""; 22 + let reconnectAt = 0; 23 + 24 + let sources = []; // [{ handle, machineId, piece, lastSeen }] 25 + let pktCount = 0; 26 + let noteOnCount = 0; 27 + let noteOffCount = 0; 28 + let lastNote = null; // { pitch, vel, chan, event, handle, ts, latencyMs } 29 + let lastNoteFrame = -9999; 30 + 31 + let frame = 0; 32 + let lastSubscribedCh = -1; 33 + 34 + // Max for Live jweb~ bridge (exposes window.max.outlet) 35 + const maxBridge = 36 + typeof window !== "undefined" && 37 + window.max && 38 + typeof window.max.outlet === "function" 39 + ? window.max 40 + : null; 41 + 42 + function emitMaxNote(pitch, velocity, channel) { 43 + if (!maxBridge) return; 44 + try { 45 + if (channel !== lastSubscribedCh) { 46 + maxBridge.outlet(["channel", channel]); 47 + lastSubscribedCh = channel; 48 + } 49 + maxBridge.outlet(["note", pitch, velocity]); 50 + } catch (_err) {} 51 + } 52 + 53 + function connectWs() { 54 + if (typeof WebSocket === "undefined") return; 55 + if (ws && (ws.readyState === 0 || ws.readyState === 1)) return; 56 + try { 57 + wsState = "connecting"; 58 + wsError = ""; 59 + ws = new WebSocket(WS_URL); 60 + ws.onopen = () => { 61 + wsState = "open"; 62 + try { 63 + ws.send( 64 + JSON.stringify({ 65 + type: "notepat:midi:subscribe", 66 + content: { all: true }, 67 + }), 68 + ); 69 + } catch (_e) {} 70 + }; 71 + ws.onmessage = (ev) => { 72 + let msg; 73 + try { 74 + msg = JSON.parse(ev.data); 75 + } catch { 76 + return; 77 + } 78 + if (!msg || !msg.type) return; 79 + if (msg.type === "notepat:midi") { 80 + handleMidiEvent(msg.content || {}); 81 + } else if (msg.type === "notepat:midi:sources") { 82 + const list = (msg.content && msg.content.sources) || []; 83 + sources = list.map((s) => ({ 84 + handle: s.handle || "", 85 + machineId: s.machineId || "", 86 + piece: s.piece || "notepat", 87 + lastSeen: s.lastSeen || 0, 88 + })); 89 + } 90 + }; 91 + ws.onerror = () => { 92 + wsState = "error"; 93 + wsError = "ws error"; 94 + }; 95 + ws.onclose = () => { 96 + wsState = "closed"; 97 + reconnectAt = frame + RECONNECT_FRAMES; 98 + }; 99 + } catch (err) { 100 + wsState = "error"; 101 + wsError = err?.message || "connect failed"; 102 + reconnectAt = frame + RECONNECT_FRAMES; 103 + } 104 + } 105 + 106 + function handleMidiEvent(ev) { 107 + const pitch = Number(ev.note); 108 + const vel = Number(ev.velocity); 109 + const chan = Number(ev.channel) || 0; 110 + if (!Number.isFinite(pitch) || !Number.isFinite(vel)) return; 111 + pktCount += 1; 112 + const now = Date.now(); 113 + const tsNum = Number(ev.ts); 114 + const latency = Number.isFinite(tsNum) ? max(0, now - tsNum) : 0; 115 + const isOff = ev.event === "note_off" || (ev.event === "note_on" && vel === 0); 116 + if (isOff) { 117 + noteOffCount += 1; 118 + emitMaxNote(pitch, 0, chan); 119 + } else { 120 + noteOnCount += 1; 121 + emitMaxNote(pitch, max(1, min(127, vel)), chan); 122 + } 123 + lastNote = { 124 + pitch, 125 + vel, 126 + chan, 127 + event: ev.event || (isOff ? "note_off" : "note_on"), 128 + handle: ev.handle || "", 129 + ts: now, 130 + latencyMs: latency, 131 + }; 132 + lastNoteFrame = frame; 133 + } 134 + 135 + const PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; 136 + function pitchName(p) { 137 + const n = PITCH_NAMES[((p % 12) + 12) % 12]; 138 + const o = floor(p / 12) - 1; 139 + return n + o; 140 + } 141 + 142 + function boot({ wipe, cursor }) { 143 + wipe(8, 10, 18); 144 + cursor?.("native"); 145 + connectWs(); 146 + } 147 + 148 + function sim() { 149 + frame += 1; 150 + if (wsState === "closed" && frame >= reconnectAt) connectWs(); 151 + } 152 + 153 + function paint({ wipe, ink, box, line, screen }) { 154 + const bg = [8, 10, 18]; 155 + const fg = [220, 225, 255]; 156 + const fgDim = [130, 140, 170]; 157 + const accent = [255, 170, 80]; 158 + const good = [140, 255, 180]; 159 + const bad = [255, 120, 120]; 160 + const warn = [255, 220, 100]; 161 + wipe(...bg); 162 + 163 + const W = screen.width; 164 + const H = screen.height; 165 + let y = 4; 166 + 167 + // Header + bridge badge 168 + ink(...accent).write("NOTEPAT-REMOTE", { x: 4, y, size: 1 }); 169 + ink(...(maxBridge ? good : warn)).write( 170 + maxBridge ? "[M4L]" : "[solo]", 171 + { x: 112, y }, 172 + ); 173 + y += 10; 174 + 175 + // WS status line 176 + const stateColor = 177 + wsState === "open" 178 + ? good 179 + : wsState === "connecting" 180 + ? warn 181 + : wsState === "error" || wsState === "closed" 182 + ? bad 183 + : fgDim; 184 + ink(...fgDim).write("ws", { x: 4, y }); 185 + ink(...stateColor).write(wsState.toUpperCase(), { x: 18, y }); 186 + if (wsError) ink(...bad).write(wsError.slice(0, 24), { x: 60, y }); 187 + y += 8; 188 + 189 + // Sources 190 + ink(...fgDim).write("src", { x: 4, y }); 191 + if (sources.length === 0) { 192 + ink(...fgDim).write("(none — start relay on)", { x: 18, y }); 193 + } else { 194 + const label = sources 195 + .slice(0, 4) 196 + .map((s) => (s.handle ? "@" + s.handle : s.machineId.slice(0, 6))) 197 + .join(" "); 198 + ink(...fg).write(label.slice(0, 36), { x: 18, y }); 199 + } 200 + y += 8; 201 + 202 + // Counters 203 + ink(...fgDim).write( 204 + `pkt ${pktCount} on ${noteOnCount} off ${noteOffCount}`, 205 + { x: 4, y }, 206 + ); 207 + y += 10; 208 + 209 + // Last note — flashes accent, fades to fg over ~30 frames 210 + if (lastNote) { 211 + const age = frame - lastNoteFrame; 212 + const flashing = age < 30; 213 + const color = flashing ? accent : fg; 214 + const arrow = lastNote.vel === 0 || lastNote.event === "note_off" ? "v" : "^"; 215 + const label = `${arrow} ${pitchName(lastNote.pitch)} (${lastNote.pitch}) vel ${lastNote.vel} ch ${lastNote.chan}`; 216 + ink(...color).write(label, { x: 4, y }); 217 + y += 8; 218 + const who = lastNote.handle ? "@" + lastNote.handle : "?"; 219 + ink(...fgDim).write(`${who} ${lastNote.latencyMs}ms`, { x: 4, y }); 220 + y += 10; 221 + } else { 222 + ink(...fgDim).write("(waiting for notes…)", { x: 4, y }); 223 + y += 10; 224 + } 225 + 226 + // Indicator strip at bottom — one bar per source, bars flash on note 227 + if (H - y > 14) { 228 + const barY = H - 10; 229 + ink(...fgDim).line(2, barY - 1, W - 2, barY - 1); 230 + ink(...fg).write("hint: enable on ThinkPad: 'midi relay on'", { x: 4, y: H - 8 }); 231 + } 232 + } 233 + 234 + function leave() { 235 + try { 236 + ws?.close(); 237 + } catch {} 238 + ws = null; 239 + } 240 + 241 + function meta() { 242 + return { 243 + title: "Notepat Remote", 244 + desc: "Max for Live bridge: session-server notepat:midi → MIDI track", 245 + }; 246 + } 247 + 248 + export { boot, sim, paint, leave, meta };