Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

WIP: Spreadnob M4L overhaul — knob control working, UI bridge in progress

- Rewrite AC-KnobMap.amxd.json: wider 400x170 rack, white-keys-only
range (C3-D4, MIDI 48-62), is_active observer, script+postMessage
bridge for M4L→AC communication
- New spreadnob.mjs: big arc knob UI with 9 white key ticks (A-L),
reads sound.daw.sn* state from disk.mjs persistent DAW state
- Add spreadnob message bridge in index.mjs inline script (catches
spreadnob:* postMessages, routes through DAW send pipeline)
- Add spreadnob:* handlers in disk.mjs (stores on persistentDawState)
- Knob value spreading works: keys A-L set Ableton parameters across
full min/max range via Max expr chain
- UI feedback still in progress: script commands reach page on first
load (proven by message queue flush) but data not yet reaching piece
sim() — investigating sound.daw.sn* path

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

+561 -524
+284 -78
ac-m4l/AC-KnobMap.amxd.json
··· 18 18 "openrect": [ 19 19 0.0, 20 20 0.0, 21 - 250.0, 22 - 194.0 21 + 400.0, 22 + 170.0 23 23 ], 24 24 "openinpresentation": 1, 25 25 "gridsize": [ ··· 28 28 ], 29 29 "enablehscroll": 0, 30 30 "enablevscroll": 0, 31 - "devicewidth": 250.0, 31 + "devicewidth": 400.0, 32 32 "description": "Spreadnob \u2014 spread any knob across your MIDI keys", 33 33 "boxes": [ 34 34 { ··· 47 47 "presentation_rect": [ 48 48 0.0, 49 49 0.0, 50 - 250.0, 51 - 194.0 50 + 401.0, 51 + 171.0 52 52 ], 53 53 "background": 1, 54 54 "ignoreclick": 1, ··· 85 85 "presentation_rect": [ 86 86 0.0, 87 87 0.0, 88 - 250.0, 89 - 194.0 88 + 401.0, 89 + 171.0 90 90 ], 91 91 "rendermode": 1, 92 - "url": "https://localhost:8888/spreadnob?daw=1&nogap=true&width=250&height=194" 92 + "url": "https://localhost:8888/spreadnob?daw=1&density=1&nogap&width=400&height=170" 93 93 } 94 94 }, 95 95 { ··· 153 153 }, 154 154 { 155 155 "box": { 156 + "id": "obj-test-script", 157 + "maxclass": "message", 158 + "numinlets": 2, 159 + "numoutlets": 1, 160 + "outlettype": [ 161 + "" 162 + ], 163 + "patching_rect": [ 164 + 700.0, 165 + 326.0, 166 + 200.0, 167 + 22.0 168 + ], 169 + "text": "script window.postMessage({type:'spreadnob:ready'},'*')" 170 + } 171 + }, 172 + { 173 + "box": { 156 174 "id": "obj-jweb-print", 157 175 "maxclass": "newobj", 158 176 "numinlets": 1, ··· 235 253 56.0, 236 254 18.0 237 255 ], 238 - "text": "v1.0.4", 256 + "text": "v1.0.5", 239 257 "fontname": "YWFTProcessing-Regular", 240 258 "fontsize": 10.0, 241 259 "textcolor": [ ··· 911 929 65.0, 912 930 22.0 913 931 ], 914 - "text": "loadmess 60" 932 + "text": "loadmess 48" 915 933 } 916 934 }, 917 935 { ··· 929 947 65.0, 930 948 22.0 931 949 ], 932 - "text": "loadmess 76" 950 + "text": "loadmess 62" 933 951 } 934 952 }, 935 953 { ··· 947 965 70.0, 948 966 22.0 949 967 ], 950 - "text": "pak 60 76" 968 + "text": "pak 48 62" 951 969 } 952 970 }, 953 971 { ··· 965 983 230.0, 966 984 22.0 967 985 ], 968 - "text": "executejavascript if(window.acSpreadnobSetRange)window.acSpreadnobSetRange($1,$2)" 986 + "text": "script window.postMessage({type:'spreadnob:range',low:$1,high:$2},'*')" 969 987 } 970 988 }, 971 989 { ··· 983 1001 205.0, 984 1002 22.0 985 1003 ], 986 - "text": "executejavascript if(window.acSpreadnobNote){window.acSpreadnobNote($1)}else{console.log(\"[SPREADNOB-UI/MISS NOTE]\",$1)}" 1004 + "text": "script window.postMessage({type:'spreadnob:note',note:$1},'*')" 987 1005 } 988 1006 }, 989 1007 { ··· 1001 1019 215.0, 1002 1020 22.0 1003 1021 ], 1004 - "text": "executejavascript if(window.acSpreadnobSetValue)window.acSpreadnobSetValue($1)" 1022 + "text": "script window.postMessage({type:'spreadnob:value',value:$1},'*')" 1005 1023 } 1006 1024 }, 1007 1025 { ··· 1019 1037 220.0, 1020 1038 22.0 1021 1039 ], 1022 - "text": "executejavascript if(window.acSpreadnobSetActive){window.acSpreadnobSetActive($1)}else{console.log(\"[SPREADNOB-UI/MISS ACTIVE]\",$1)}" 1040 + "text": "script window.postMessage({type:'spreadnob:active',active:$1},'*')" 1023 1041 } 1024 1042 }, 1025 1043 { ··· 1055 1073 255.0, 1056 1074 22.0 1057 1075 ], 1058 - "text": "executejavascript if(window.acSpreadnobSetTarget)window.acSpreadnobSetTarget(\"$1\")" 1076 + "text": "script window.postMessage({type:'spreadnob:target',name:'$1'},'*')" 1059 1077 } 1060 1078 }, 1061 1079 { ··· 1118 1136 }, 1119 1137 { 1120 1138 "box": { 1139 + "id": "obj-route-active", 1140 + "maxclass": "newobj", 1141 + "numinlets": 2, 1142 + "numoutlets": 2, 1143 + "outlettype": [ 1144 + "", 1145 + "" 1146 + ], 1147 + "patching_rect": [ 1148 + 30.0, 1149 + 530.0, 1150 + 95.0, 1151 + 22.0 1152 + ], 1153 + "text": "route is_active" 1154 + } 1155 + }, 1156 + { 1157 + "box": { 1158 + "id": "obj-jweb-write-route", 1159 + "maxclass": "newobj", 1160 + "numinlets": 1, 1161 + "numoutlets": 2, 1162 + "outlettype": [ 1163 + "", 1164 + "" 1165 + ], 1166 + "patching_rect": [ 1167 + 30.0, 1168 + 560.0, 1169 + 85.0, 1170 + 22.0 1171 + ], 1172 + "text": "route write" 1173 + } 1174 + }, 1175 + { 1176 + "box": { 1177 + "id": "obj-jweb-write-setval", 1178 + "maxclass": "message", 1179 + "numinlets": 2, 1180 + "numoutlets": 1, 1181 + "outlettype": [ 1182 + "" 1183 + ], 1184 + "patching_rect": [ 1185 + 30.0, 1186 + 590.0, 1187 + 85.0, 1188 + 22.0 1189 + ], 1190 + "text": "set value $1" 1191 + } 1192 + }, 1193 + { 1194 + "box": { 1195 + "id": "obj-ui-min-msg", 1196 + "maxclass": "message", 1197 + "numinlets": 2, 1198 + "numoutlets": 1, 1199 + "outlettype": [ 1200 + "" 1201 + ], 1202 + "patching_rect": [ 1203 + 760.0, 1204 + 482.0, 1205 + 215.0, 1206 + 22.0 1207 + ], 1208 + "text": "script window.postMessage({type:'spreadnob:min',min:$1},'*')" 1209 + } 1210 + }, 1211 + { 1212 + "box": { 1213 + "id": "obj-ui-max-msg", 1214 + "maxclass": "message", 1215 + "numinlets": 2, 1216 + "numoutlets": 1, 1217 + "outlettype": [ 1218 + "" 1219 + ], 1220 + "patching_rect": [ 1221 + 760.0, 1222 + 510.0, 1223 + 215.0, 1224 + 22.0 1225 + ], 1226 + "text": "script window.postMessage({type:'spreadnob:max',max:$1},'*')" 1227 + } 1228 + }, 1229 + { 1230 + "box": { 1121 1231 "id": "obj-songpath", 1122 1232 "maxclass": "newobj", 1123 1233 "numinlets": 1, ··· 1663 1773 90.0, 1664 1774 22.0 1665 1775 ], 1666 - "text": "clip 60. 76." 1776 + "text": "clip 48. 62." 1667 1777 } 1668 1778 }, 1669 1779 { ··· 2494 2604 { 2495 2605 "patchline": { 2496 2606 "destination": [ 2497 - "obj-jweb-range", 2498 - 0 2499 - ], 2500 - "source": [ 2501 - "obj-jweb-route", 2502 - 1 2503 - ] 2504 - } 2505 - }, 2506 - { 2507 - "patchline": { 2508 - "destination": [ 2509 - "obj-midi-low", 2510 - 0 2511 - ], 2512 - "source": [ 2513 - "obj-jweb-range", 2514 - 0 2515 - ] 2516 - } 2517 - }, 2518 - { 2519 - "patchline": { 2520 - "destination": [ 2521 - "obj-midi-high", 2522 - 0 2523 - ], 2524 - "source": [ 2525 - "obj-jweb-range", 2526 - 1 2527 - ] 2528 - } 2529 - }, 2530 - { 2531 - "patchline": { 2532 - "destination": [ 2533 2607 "obj-midi-low", 2534 2608 0 2535 2609 ], ··· 3228 3302 "destination": [ 3229 3303 "obj-expr", 3230 3304 1 3231 - ], 3232 - "source": [ 3233 - "obj-store-max", 3234 - 0 3235 - ] 3236 - } 3237 - }, 3238 - { 3239 - "patchline": { 3240 - "destination": [ 3241 - "obj-clip", 3242 - 1 3243 - ], 3244 - "source": [ 3245 - "obj-store-min", 3246 - 0 3247 - ] 3248 - } 3249 - }, 3250 - { 3251 - "patchline": { 3252 - "destination": [ 3253 - "obj-clip", 3254 - 2 3255 3305 ], 3256 3306 "source": [ 3257 3307 "obj-store-max", ··· 4007 4057 "patchline": { 4008 4058 "source": [ 4009 4059 "obj-thisdevice", 4060 + 0 4061 + ], 4062 + "destination": [ 4063 + "obj-device-prop-active", 4064 + 0 4065 + ] 4066 + } 4067 + }, 4068 + { 4069 + "patchline": { 4070 + "source": [ 4071 + "obj-device-path", 4072 + 0 4073 + ], 4074 + "destination": [ 4075 + "obj-device-observer", 4010 4076 1 4077 + ] 4078 + } 4079 + }, 4080 + { 4081 + "patchline": { 4082 + "source": [ 4083 + "obj-device-prop-active", 4084 + 0 4085 + ], 4086 + "destination": [ 4087 + "obj-device-observer", 4088 + 0 4089 + ] 4090 + } 4091 + }, 4092 + { 4093 + "patchline": { 4094 + "source": [ 4095 + "obj-device-observer", 4096 + 0 4097 + ], 4098 + "destination": [ 4099 + "obj-route-active", 4100 + 0 4101 + ] 4102 + } 4103 + }, 4104 + { 4105 + "patchline": { 4106 + "source": [ 4107 + "obj-route-active", 4108 + 0 4011 4109 ], 4012 4110 "destination": [ 4013 4111 "obj-ui-active-msg", 4112 + 0 4113 + ] 4114 + } 4115 + }, 4116 + { 4117 + "patchline": { 4118 + "source": [ 4119 + "obj-jweb", 4120 + 2 4121 + ], 4122 + "destination": [ 4123 + "obj-jweb-write-route", 4124 + 0 4125 + ] 4126 + } 4127 + }, 4128 + { 4129 + "patchline": { 4130 + "source": [ 4131 + "obj-jweb-write-route", 4132 + 0 4133 + ], 4134 + "destination": [ 4135 + "obj-jweb-write-setval", 4136 + 0 4137 + ] 4138 + } 4139 + }, 4140 + { 4141 + "patchline": { 4142 + "source": [ 4143 + "obj-jweb-write-setval", 4144 + 0 4145 + ], 4146 + "destination": [ 4147 + "obj-writer", 4148 + 0 4149 + ] 4150 + } 4151 + }, 4152 + { 4153 + "patchline": { 4154 + "source": [ 4155 + "obj-store-min", 4156 + 0 4157 + ], 4158 + "destination": [ 4159 + "obj-ui-min-msg", 4160 + 0 4161 + ] 4162 + } 4163 + }, 4164 + { 4165 + "patchline": { 4166 + "source": [ 4167 + "obj-ui-min-msg", 4168 + 0 4169 + ], 4170 + "destination": [ 4171 + "obj-jweb", 4172 + 0 4173 + ] 4174 + } 4175 + }, 4176 + { 4177 + "patchline": { 4178 + "source": [ 4179 + "obj-store-max", 4180 + 0 4181 + ], 4182 + "destination": [ 4183 + "obj-ui-max-msg", 4184 + 0 4185 + ] 4186 + } 4187 + }, 4188 + { 4189 + "patchline": { 4190 + "source": [ 4191 + "obj-ui-max-msg", 4192 + 0 4193 + ], 4194 + "destination": [ 4195 + "obj-jweb", 4196 + 0 4197 + ] 4198 + } 4199 + }, 4200 + { 4201 + "patchline": { 4202 + "source": [ 4203 + "obj-jweb-ready-bang", 4204 + 0 4205 + ], 4206 + "destination": [ 4207 + "obj-test-script", 4208 + 0 4209 + ] 4210 + } 4211 + }, 4212 + { 4213 + "patchline": { 4214 + "source": [ 4215 + "obj-test-script", 4216 + 0 4217 + ], 4218 + "destination": [ 4219 + "obj-jweb", 4014 4220 0 4015 4221 ] 4016 4222 }
+2 -2
ac-m4l/devices.json
··· 42 42 "name": "AC 🎹 spreadnob (aesthetic.computer)", 43 43 "piece": "spreadnob", 44 44 "description": "Spread any knob across your MIDI keys", 45 - "width": 250, 45 + "width": 400, 46 46 "height": 170, 47 47 "type": "midi", 48 48 "source": "AC-KnobMap.amxd.json", 49 - "version": "1.0.4" 49 + "version": "1.0.5" 50 50 } 51 51 ], 52 52 "defaults": {
+17 -9
system/netlify/functions/index.mjs
··· 936 936 console.log("🎹 Max for Live detected - DAW sync connected"); 937 937 }; 938 938 939 + // 🎛️ Spreadnob bridge — catches postMessage from M4L script commands 940 + window.addEventListener("message", function(event) { 941 + var d = event.data; 942 + if (d && d.type && typeof d.type === "string" && d.type.indexOf("spreadnob:") === 0) { 943 + send({ type: d.type, content: d }); 944 + } 945 + }); 946 + 939 947 // Signal to M4L that we're ready 940 948 if (window.max) { 941 949 console.log("🎹 Sent ready signal to Max"); ··· 1134 1142 } 1135 1143 }); 1136 1144 </script> 1137 - <script 1138 - src="/aesthetic.computer/boot.mjs" 1139 - type="module" 1140 - defer 1141 - ></script> 1145 + <script 1146 + src="/aesthetic.computer/boot.mjs" 1147 + type="module" 1148 + defer 1149 + ></script> 1142 1150 ${dev ? `<!-- Modulepreload for module-loader (needed for fast WebSocket connection) --> 1143 1151 <link rel="modulepreload" href="/aesthetic.computer/module-loader.mjs" />` : `<!-- Modulepreload hints for critical path modules (parallel fetch) --> 1144 1152 <link rel="modulepreload" href="/aesthetic.computer/module-loader.mjs" /> ··· 1195 1203 </script> 1196 1204 <!-- Preload the YWFT Processing font for instant boot animation --> 1197 1205 <link rel="preload" href="/type/webfonts/ywft-processing-bold.woff2" as="font" type="font/woff2" crossorigin="anonymous" /> 1198 - <link 1199 - rel="stylesheet" 1200 - href="/aesthetic.computer/style.css" 1201 - /> 1206 + <link 1207 + rel="stylesheet" 1208 + href="/aesthetic.computer/style.css" 1209 + /> 1202 1210 <link rel="stylesheet" href="/type/webfonts/ywft-processing-bold.css" /> 1203 1211 <script type="application/ld+json"> 1204 1212 ${JSON.stringify({
+163 -380
system/public/aesthetic.computer/disks/spreadnob.mjs
··· 1 1 // Spreadnob, 2026.03.31 2 2 // AC-native UI for the Ableton spreadnob device. 3 + // Big knob display — white keys A..L map linearly across the parameter range. 4 + // M4L → HTML bridge (acSn*) → bios send → disk → sound.spreadnob 3 5 4 6 import { getNoteColorWithOctave } from "../lib/note-colors.mjs"; 5 7 6 8 const FONT = "YWFTProcessing-Regular"; 7 9 const MINI_FONT = "MatrixChunky8"; 8 - const KEY_LABELS = ["A", "W", "S", "E", "D", "F", "T", "G", "Y", "H", "U", "J", "K", "O", "L", "P", ";"]; 9 - const PITCH_CLASSES = ["c", "c#", "d", "d#", "e", "f", "f#", "g", "g#", "a", "a#", "b"]; 10 - const DEFAULT_LOW = 60; 11 - const DEFAULT_HIGH = 76; 12 - const MAX_SPAN = KEY_LABELS.length - 1; 10 + 11 + const KEY_LABELS = ["A", "S", "D", "F", "G", "H", "J", "K", "L"]; 12 + const KEY_COUNT = KEY_LABELS.length; 13 + const WHITE_OFFSETS = [0, 2, 4, 5, 7, 9, 11, 12, 14]; 14 + const PITCH_CLASSES = ["c","c#","d","d#","e","f","f#","g","g#","a","a#","b"]; 15 + const DEFAULT_BASE = 48; 13 16 14 - let active = false; 17 + const ARC_START = 210; 18 + const ARC_END = -30; 19 + const ARC_SWEEP = ARC_START - ARC_END; 20 + 21 + let active = true; 15 22 let target = ""; 16 23 let value = null; 17 24 let currentNote = null; 18 - let low = DEFAULT_LOW; 19 - let high = DEFAULT_HIGH; 25 + let base = DEFAULT_BASE; 26 + let paramMin = 0; 27 + let paramMax = 1; 20 28 let flash = 0; 21 29 let noteHits = 0; 30 + let lastSnNote = null; 22 31 23 - let uiKit = null; 24 - let requestPaint = () => {}; 25 - let messageHandler = null; 26 - let layoutKey = ""; 32 + function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); } 33 + function deg2rad(d) { return (d * Math.PI) / 180; } 34 + function midiForKey(i) { return base + WHITE_OFFSETS[i]; } 27 35 28 - let lowDownBtn = null; 29 - let lowUpBtn = null; 30 - let resetBtn = null; 31 - let highDownBtn = null; 32 - let highUpBtn = null; 36 + function whiteKeyIndex(midi) { 37 + if (!Number.isFinite(midi)) return -1; 38 + return WHITE_OFFSETS.indexOf(midi - base); 39 + } 33 40 34 - function clamp(n, min, max) { 35 - return Math.max(min, Math.min(max, n)); 41 + function valueForIndex(i) { 42 + return (i / (KEY_COUNT - 1)) * (paramMax - paramMin) + paramMin; 36 43 } 37 44 38 45 function noteLabel(midi) { 39 46 if (!Number.isFinite(midi)) return "--"; 40 47 const pitch = PITCH_CLASSES[((midi % 12) + 12) % 12].toUpperCase(); 41 - const octave = Math.floor(midi / 12) - 1; 42 - return `${pitch}${octave}`; 48 + return `${pitch}${Math.floor(midi / 12) - 1}`; 43 49 } 44 50 45 51 function noteColor(midi) { 46 52 if (!Number.isFinite(midi)) return [190, 190, 190]; 47 53 const pitch = PITCH_CLASSES[((midi % 12) + 12) % 12]; 48 - const octave = Math.floor(midi / 12) - 1; 49 - return getNoteColorWithOctave(pitch, octave); 54 + return getNoteColorWithOctave(pitch, Math.floor(midi / 12) - 1); 50 55 } 51 56 52 - function isBlack(midi) { 53 - return [1, 3, 6, 8, 10].includes(((midi % 12) + 12) % 12); 57 + function angleForIndex(i) { 58 + return ARC_START - (i / (KEY_COUNT - 1)) * ARC_SWEEP; 54 59 } 55 60 56 - function isDefaultRange() { 57 - return low === DEFAULT_LOW && high === DEFAULT_HIGH; 61 + function angleForValue(v) { 62 + if (paramMax === paramMin) return ARC_START; 63 + return ARC_START - clamp((v - paramMin) / (paramMax - paramMin), 0, 1) * ARC_SWEEP; 58 64 } 59 65 60 - function rangeCount() { 61 - return clamp(high - low + 1, 1, KEY_LABELS.length); 66 + function arcXY(cx, cy, r, deg) { 67 + const rad = deg2rad(deg); 68 + return [cx + r * Math.cos(rad), cy - r * Math.sin(rad)]; 62 69 } 63 70 64 - function noteRangeState(note = currentNote) { 65 - if (!Number.isFinite(note)) return "none"; 66 - if (note < low) return "below"; 67 - if (note > high) return "above"; 68 - return "inside"; 69 - } 71 + function boot() {} 70 72 71 - function getLayout(screen) { 72 - const compact = screen.height <= 194; 73 - return compact 74 - ? { 75 - compact, 76 - inset: 4, 77 - headerH: 22, 78 - titleX: 9, 79 - titleY: 8, 80 - pillW: 40, 81 - pillH: 12, 82 - targetY: 31, 83 - subY: 45, 84 - statY: 54, 85 - statH: 15, 86 - modeY: 73, 87 - keyboardY: 83, 88 - keyboardH: 34, 89 - statusBaseY: 124, 90 - warningY: 124, 91 - footerLabelY: screen.height - 28, 92 - footerY: screen.height - 16, 93 - btnY: screen.height - 14, 94 - btnW: 14, 95 - btnH: 11, 96 - resetW: 50, 73 + function sim({ sound, needsPaint }) { 74 + let dirty = false; 75 + const sn = sound?.spreadnob; 76 + if (sn) { 77 + if (sn.active !== null && sn.active !== undefined) { 78 + active = !!Number(sn.active); 79 + sn.active = null; 80 + dirty = true; 81 + } 82 + if (sn.target !== null && sn.target !== undefined) { 83 + target = String(sn.target).trim(); 84 + sn.target = null; 85 + dirty = true; 86 + } 87 + if (sn.min !== null && sn.min !== undefined) { 88 + const n = Number(sn.min); 89 + if (Number.isFinite(n)) paramMin = n; 90 + sn.min = null; 91 + dirty = true; 92 + } 93 + if (sn.max !== null && sn.max !== undefined) { 94 + const n = Number(sn.max); 95 + if (Number.isFinite(n)) paramMax = n; 96 + sn.max = null; 97 + dirty = true; 98 + } 99 + if (sn.value !== null && sn.value !== undefined) { 100 + const n = Number(sn.value); 101 + value = Number.isFinite(n) ? n : null; 102 + sn.value = null; 103 + dirty = true; 104 + } 105 + if (sn.note !== null && sn.note !== undefined) { 106 + const n = Number(sn.note); 107 + if (n !== lastSnNote) { 108 + currentNote = Number.isFinite(n) ? n : null; 109 + lastSnNote = n; 110 + const idx = whiteKeyIndex(currentNote); 111 + if (idx >= 0) { 112 + value = valueForIndex(idx); 113 + noteHits++; 114 + flash = active ? 1 : 0.45; 115 + } 116 + dirty = true; 97 117 } 98 - : { 99 - compact, 100 - inset: 6, 101 - headerH: 28, 102 - titleX: 13, 103 - titleY: 10, 104 - pillW: 50, 105 - pillH: 14, 106 - targetY: 42, 107 - subY: 58, 108 - statY: 67, 109 - statH: 17, 110 - modeY: 92, 111 - keyboardY: 97, 112 - keyboardH: 46, 113 - statusBaseY: 152, 114 - warningY: 152, 115 - footerLabelY: screen.height - 28, 116 - footerY: screen.height - 18, 117 - btnY: screen.height - 16, 118 - btnW: 16, 119 - btnH: 13, 120 - resetW: 56, 121 - }; 122 - } 123 - 124 - function setRange(nextLow, nextHigh, { emit = false } = {}) { 125 - let resolvedLow = clamp(Math.round(nextLow), 0, 127); 126 - let resolvedHigh = clamp(Math.round(nextHigh), 0, 127); 127 - 128 - if (resolvedHigh < resolvedLow) resolvedHigh = resolvedLow; 129 - 130 - if (resolvedHigh - resolvedLow > MAX_SPAN) { 131 - if (resolvedLow !== low) { 132 - resolvedHigh = resolvedLow + MAX_SPAN; 133 - } else { 134 - resolvedLow = resolvedHigh - MAX_SPAN; 135 118 } 136 119 } 137 120 138 - low = clamp(resolvedLow, 0, 127); 139 - high = clamp(resolvedHigh, low, 127); 140 - 141 - if (emit && typeof window !== "undefined" && window.max?.outlet) { 142 - try { 143 - window.max.outlet("range", low, high); 144 - } catch {} 145 - } 146 - 147 - requestPaint(); 148 - } 149 - 150 - function updateFromMessage(data = {}) { 151 - if (data.type === "spreadnob:active") { 152 - active = !!Number(data.active); 153 - console.log("[SPREADNOB-UI/ACTIVE]", active ? 1 : 0); 154 - requestPaint(); 155 - return; 156 - } 157 - 158 - if (data.type === "spreadnob:target") { 159 - target = String(data.name || "").trim(); 160 - console.log("[SPREADNOB-UI/TARGET]", target || "(none)"); 161 - requestPaint(); 162 - return; 163 - } 164 - 165 - if (data.type === "spreadnob:value") { 166 - const num = Number(data.value); 167 - value = Number.isFinite(num) ? num : null; 168 - requestPaint(); 169 - return; 170 - } 171 - 172 - if (data.type === "spreadnob:range") { 173 - setRange(Number(data.low), Number(data.high), { emit: false }); 174 - return; 175 - } 176 - 177 - if (data.type === "spreadnob:note") { 178 - const num = Number(data.note); 179 - currentNote = Number.isFinite(num) ? num : null; 180 - noteHits++; 181 - flash = active ? 1 : 0.45; 182 - console.log("[SPREADNOB-UI/NOTE]", currentNote, "range", `${low}..${high}`); 183 - requestPaint(); 121 + if (flash > 0) { 122 + flash *= 0.82; 123 + if (flash < 0.025) flash = 0; 124 + dirty = true; 184 125 } 185 - } 186 - 187 - function buildUi(screen) { 188 - if (!uiKit) return; 189 - 190 - const key = `${screen.width}x${screen.height}`; 191 - if (layoutKey === key) return; 192 - layoutKey = key; 193 - 194 - const layout = getLayout(screen); 195 - const footerY = layout.footerY; 196 - const btnY = layout.btnY; 197 - const sideX = 8; 198 - const btnW = layout.btnW; 199 - const btnH = layout.btnH; 200 - const chipW = 48; 201 - const rightX = screen.width - sideX - btnW; 202 - const resetW = layout.resetW; 203 - const resetX = Math.round((screen.width - resetW) / 2); 204 - 205 - lowDownBtn = new uiKit.Button(sideX, btnY, btnW, btnH); 206 - lowUpBtn = new uiKit.Button(sideX + btnW + 2, btnY, btnW, btnH); 207 - resetBtn = new uiKit.Button(resetX, btnY, resetW, btnH); 208 - highDownBtn = new uiKit.Button(rightX - btnW - 2, btnY, btnW, btnH); 209 - highUpBtn = new uiKit.Button(rightX, btnY, btnW, btnH); 210 - 211 - lowDownBtn.labelBox = { x: sideX, y: footerY - 11, w: chipW, h: 10 }; 212 - highUpBtn.labelBox = { x: screen.width - sideX - chipW, y: footerY - 11, w: chipW, h: 10 }; 126 + if (dirty) needsPaint(); 213 127 } 214 128 215 - function boot({ ui, screen, needsPaint }) { 216 - uiKit = ui; 217 - requestPaint = needsPaint; 218 - buildUi(screen); 219 - 220 - if (typeof window !== "undefined") { 221 - messageHandler = (event) => updateFromMessage(event?.data || {}); 222 - window.addEventListener("message", messageHandler); 223 - 224 - window.acSpreadnobSetActive = (next) => 225 - updateFromMessage({ type: "spreadnob:active", active: next }); 226 - window.acSpreadnobSetTarget = (name) => 227 - updateFromMessage({ type: "spreadnob:target", name }); 228 - window.acSpreadnobSetValue = (next) => 229 - updateFromMessage({ type: "spreadnob:value", value: next }); 230 - window.acSpreadnobSetRange = (nextLow, nextHigh) => 231 - updateFromMessage({ type: "spreadnob:range", low: nextLow, high: nextHigh }); 232 - window.acSpreadnobNote = (note) => 233 - updateFromMessage({ type: "spreadnob:note", note }); 234 - } 235 - 236 - requestPaint(); 237 - } 238 - 239 - function sim({ needsPaint }) { 240 - if (flash <= 0) return; 241 - flash *= 0.82; 242 - if (flash < 0.025) flash = 0; 243 - needsPaint(); 244 - } 245 - 246 - function paintButton({ button, label, ink, box, fg = [245, 240, 250], bg = [52, 26, 41] }) { 247 - button?.paint((btn) => { 248 - ink(...bg).box(btn.box); 249 - ink(255, 110, 180, 80).box(btn.box.x, btn.box.y, btn.box.w, 1); 250 - ink(...fg).write(label, { x: btn.box.x + 5, y: btn.box.y + 1 }, undefined, undefined, false, FONT); 251 - }); 252 - } 253 - 254 - function paint({ wipe, ink, screen, line }) { 255 - buildUi(screen); 256 - 129 + function paint({ wipe, ink, screen }) { 257 130 const w = screen.width; 258 131 const h = screen.height; 259 - const layout = getLayout(screen); 260 - const targetName = target || "CLICK ABLETON KNOB"; 261 - const activeFill = active ? [110, 255, 120] : [255, 90, 95]; 262 - const shell = active ? [19, 33, 20] : [36, 17, 24]; 263 - const border = active ? [60, 180, 90] : [170, 50, 85]; 264 - const flashAlpha = Math.round(110 * flash); 265 - const keyboardY = layout.keyboardY; 266 - const keyboardX = 10; 267 - const keyboardW = w - 20; 268 - const keyboardH = layout.keyboardH; 269 - const count = rangeCount(); 270 - const noteState = noteRangeState(); 271 - const gap = 2; 272 - const keyW = Math.max(8, Math.floor((keyboardW - gap * (KEY_LABELS.length - 1)) / KEY_LABELS.length)); 273 - const totalKeyW = keyW * KEY_LABELS.length + gap * (KEY_LABELS.length - 1); 274 - const keyStartX = Math.round((w - totalKeyW) / 2); 132 + const flashAlpha = Math.round(100 * flash); 133 + const hitIdx = whiteKeyIndex(currentNote); 134 + 135 + const cx = Math.round(w / 2); 136 + const cy = Math.round(h * 0.54); 137 + const outerR = Math.min(w * 0.14, h * 0.32); 138 + const trackR = outerR - 2; 139 + const tickOuter = outerR + 4; 140 + const tickInner = outerR; 141 + const labelR = outerR + 14; 275 142 276 143 wipe(8, 10, 12); 277 - ink(...shell).box(0, 0, w, h); 278 - ink(255, 80, 150, 28).box(0, 0, w, layout.headerH); 279 - ink(40, 120, 80, active ? 44 : 18).box(0, layout.headerH - 4, w, h - (layout.headerH - 4)); 144 + ink(active ? 16 : 30, active ? 28 : 14, active ? 18 : 20).box(0, 0, w, h); 280 145 281 146 if (flashAlpha > 0) { 282 - ink(active ? 110 : 255, active ? 255 : 105, active ? 150 : 145, flashAlpha).box(0, 0, w, h); 147 + ink(active ? 80 : 200, active ? 200 : 80, 120, flashAlpha).box(0, 0, w, h); 283 148 } 284 149 285 - ink(...border).box(layout.inset, layout.inset, w - layout.inset * 2, h - layout.inset * 2); 286 - ink(255, 115, 180, 120).line(8, layout.headerH + 6, w - 8, layout.headerH + 6); 287 - ink(255, 115, 180, 60).line(8, layout.modeY + 8, w - 8, layout.modeY + 8); 288 - ink(255, 115, 180, 60).line(8, layout.statusBaseY - 3, w - 8, layout.statusBaseY - 3); 289 - 290 - ink(255, 120, 185).write("spreadnob", { x: layout.titleX, y: layout.titleY }, undefined, undefined, false, FONT); 291 - 292 - ink(...activeFill).box(w - layout.pillW - 10, layout.titleY, layout.pillW, layout.pillH); 293 - ink(10, 18, 12).write( 294 - active ? "ON" : "OFF", 295 - { x: w - layout.pillW + 2 - 10, y: layout.titleY + 1 }, 296 - undefined, 297 - undefined, 298 - false, 299 - FONT, 300 - ); 301 - 302 - ink(255, 190, 220).write(targetName, { x: 10, y: layout.targetY }, undefined, w - 20, true, FONT); 303 - ink(170, 145, 170).write( 304 - active ? "follows selected knob" : "device bypassed by ableton", 305 - { x: 10, y: layout.subY }, 306 - undefined, 307 - undefined, 308 - false, 309 - MINI_FONT, 310 - ); 311 - 312 - const statY = layout.statY; 313 - const statW = Math.floor((w - 34) / 3); 314 - const statXs = [13, 13 + statW + 4, 13 + (statW + 4) * 2]; 315 - const valueText = value === null ? "--" : value.toFixed(3); 316 - const noteText = currentNote === null ? "--" : `${noteLabel(currentNote)} ${currentNote}`; 317 - const rangeText = `${low}..${high}`; 150 + // Track arc 151 + const arcSteps = 40; 152 + const arcStep = (ARC_START - ARC_END) / arcSteps; 153 + for (let i = 0; i < arcSteps; i++) { 154 + const a1 = ARC_END + i * arcStep; 155 + const a2 = a1 + arcStep; 156 + const [x1, y1] = arcXY(cx, cy, trackR, a1); 157 + const [x2, y2] = arcXY(cx, cy, trackR, a2); 158 + ink(45, 40, 55).line(Math.round(x1), Math.round(y1), Math.round(x2), Math.round(y2)); 159 + } 318 160 319 - for (const x of statXs) { 320 - ink(24, 18, 28).box(x, statY, statW, layout.statH); 321 - ink(255, 255, 255, 24).box(x, statY, statW, 1); 161 + // Value arc 162 + if (value !== null) { 163 + const valAngle = angleForValue(value); 164 + const vr = active ? 110 : 255; 165 + const vg = active ? 255 : 110; 166 + const vb = active ? 150 : 170; 167 + const sweepStart = Math.min(valAngle, ARC_START); 168 + const sweepEnd = Math.max(valAngle, ARC_START); 169 + const valSteps = Math.max(4, Math.round(Math.abs(sweepEnd - sweepStart) / 6)); 170 + const valStep = (sweepEnd - sweepStart) / valSteps; 171 + for (let i = 0; i < valSteps; i++) { 172 + const a1 = sweepStart + i * valStep; 173 + const a2 = a1 + valStep; 174 + const [x1, y1] = arcXY(cx, cy, trackR, a1); 175 + const [x2, y2] = arcXY(cx, cy, trackR, a2); 176 + ink(vr, vg, vb).line(Math.round(x1), Math.round(y1), Math.round(x2), Math.round(y2)); 177 + } 178 + const [vx, vy] = arcXY(cx, cy, trackR, valAngle); 179 + ink(255, 255, 255).box(Math.round(vx) - 2, Math.round(vy) - 2, 5, 5); 322 180 } 323 181 324 - ink(150, 135, 150).write("value", { x: statXs[0] + 3, y: statY + 2 }, undefined, undefined, false, MINI_FONT); 325 - ink(255, 118, 180).write(valueText, { x: statXs[0] + 3, y: statY + 8 }, undefined, statW - 6, true, FONT); 182 + // Tick marks and key labels 183 + for (let i = 0; i < KEY_COUNT; i++) { 184 + const angle = angleForIndex(i); 185 + const [ox, oy] = arcXY(cx, cy, tickOuter, angle); 186 + const [ix, iy] = arcXY(cx, cy, tickInner, angle); 187 + const [lx, ly] = arcXY(cx, cy, labelR, angle); 326 188 327 - ink(150, 135, 150).write("note", { x: statXs[1] + 3, y: statY + 2 }, undefined, undefined, false, MINI_FONT); 328 - ink(130, 230, 255).write(noteText, { x: statXs[1] + 3, y: statY + 8 }, undefined, statW - 6, true, FONT); 329 - 330 - ink(150, 135, 150).write("range", { x: statXs[2] + 3, y: statY + 2 }, undefined, undefined, false, MINI_FONT); 331 - ink(210, 245, 160).write(rangeText, { x: statXs[2] + 3, y: statY + 8 }, undefined, statW - 6, true, FONT); 332 - 333 - ink(255, 135, 200).write( 334 - isDefaultRange() ? "ableton qwerty spread" : "custom key spread", 335 - { x: 10, y: layout.modeY }, 336 - undefined, 337 - undefined, 338 - false, 339 - MINI_FONT, 340 - ); 341 - 342 - ink(14, 14, 18).box(keyboardX, keyboardY, keyboardW, keyboardH); 343 - 344 - for (let i = 0; i < KEY_LABELS.length; i++) { 345 - const x = keyStartX + i * (keyW + gap); 346 - const inRange = i < count; 347 - const midi = low + i; 348 - const current = currentNote === midi; 349 - const edgeCurrent = 350 - (noteState === "below" && i === 0) || 351 - (noteState === "above" && i === count - 1); 352 - const black = inRange && isBlack(midi); 353 - const baseY = keyboardY + (black ? 4 : 0); 354 - const keyH = black ? keyboardH - 14 : keyboardH; 355 - const rgb = inRange ? noteColor(midi) : [42, 42, 46]; 356 - const fill = current || edgeCurrent 357 - ? [255, 105, 182] 358 - : black 359 - ? [rgb[0] * 0.45, rgb[1] * 0.45, rgb[2] * 0.45] 360 - : [rgb[0] * 0.7, rgb[1] * 0.7, rgb[2] * 0.7]; 189 + const isCurrent = hitIdx === i; 190 + const midi = midiForKey(i); 191 + const rgb = noteColor(midi); 361 192 362 - ink(...fill).box(x, baseY, keyW, keyH); 363 - ink( 364 - 255, 365 - current || edgeCurrent ? 240 : 255, 366 - current || edgeCurrent ? 245 : 255, 367 - current || edgeCurrent ? 150 : 24, 368 - ).box(x, baseY, keyW, 1); 193 + const tr = isCurrent ? 255 : Math.round(rgb[0] * 0.7); 194 + const tg = isCurrent ? 255 : Math.round(rgb[1] * 0.7); 195 + const tb = isCurrent ? 255 : Math.round(rgb[2] * 0.7); 196 + ink(tr, tg, tb).line(Math.round(ox), Math.round(oy), Math.round(ix), Math.round(iy)); 369 197 370 - if (inRange) { 371 - ink(250, 248, 252).write(KEY_LABELS[i], { x: x + 2, y: keyboardY + keyboardH - 11 }, undefined, undefined, false, MINI_FONT); 372 - ink(black ? 255 : 25, black ? 190 : 34, black ? 220 : 42).write(String(midi), { x: x + 1, y: baseY + 1 }, undefined, undefined, false, MINI_FONT); 373 - if (current || edgeCurrent) { 374 - ink(255, 245, 250).line(x, baseY + keyH, x + keyW, baseY + keyH); 375 - } 376 - } 198 + const lr = isCurrent ? 255 : Math.round(rgb[0] * 0.55); 199 + const lg = isCurrent ? 255 : Math.round(rgb[1] * 0.55); 200 + const lb = isCurrent ? 255 : Math.round(rgb[2] * 0.55); 201 + ink(lr, lg, lb).write( 202 + KEY_LABELS[i], 203 + { x: Math.round(lx) - 3, y: Math.round(ly) - 4 }, 204 + undefined, undefined, false, MINI_FONT, 205 + ); 377 206 } 378 207 379 - const statusY = noteState !== "inside" && currentNote !== null 380 - ? layout.statusBaseY + 14 381 - : layout.statusBaseY; 208 + // Center info 209 + const targetName = target || "click a knob"; 210 + ink(255, 190, 220).write(targetName, { x: cx - 28, y: cy - 12 }, undefined, 56, true, FONT); 382 211 383 - if (noteState !== "inside" && currentNote !== null) { 384 - const warning = noteState === "below" 385 - ? `${currentNote} below visible range` 386 - : `${currentNote} above visible range`; 387 - ink(255, 105, 182).box(12, layout.warningY, w - 24, 12); 388 - ink(20, 10, 18).write(warning, { x: 15, y: layout.warningY + 2 }, undefined, w - 30, true, MINI_FONT); 389 - } 390 - 391 - ink(160, 145, 165).write( 392 - currentNote === null 393 - ? "press a note to see the map move" 394 - : noteState === "inside" 395 - ? `${noteLabel(currentNote)} hit ${noteHits}` 396 - : `${noteLabel(currentNote)} hit ${noteHits} · shift range if needed`, 397 - { x: 12, y: statusY }, 398 - undefined, 399 - w - 24, 400 - true, 401 - MINI_FONT, 212 + const valueText = value === null ? "--" : value.toFixed(2); 213 + ink(active ? 130 : 255, active ? 255 : 130, active ? 170 : 190).write( 214 + valueText, { x: cx - 16, y: cy + 2 }, undefined, undefined, false, FONT, 402 215 ); 403 216 404 - ink(110, 230, 170).write(`${low} ${noteLabel(low)}`, { x: 8, y: layout.footerLabelY }, undefined, undefined, false, MINI_FONT); 405 - ink(110, 230, 170).write(`${high} ${noteLabel(high)}`, { right: 8, y: layout.footerLabelY }, undefined, undefined, false, MINI_FONT); 217 + // ON/OFF pill 218 + const pFill = active ? [90, 220, 100] : [220, 70, 80]; 219 + ink(...pFill).box(w - 36, 5, 30, 10); 220 + ink(10, 16, 12).write(active ? "ON" : "OFF", { x: w - 32, y: 6 }, undefined, undefined, false, MINI_FONT); 406 221 407 - paintButton({ button: lowDownBtn, label: "-", ink }); 408 - paintButton({ button: lowUpBtn, label: "+", ink }); 409 - paintButton({ 410 - button: resetBtn, 411 - label: isDefaultRange() ? "QWERTY" : "RESET", 412 - ink, 413 - fg: isDefaultRange() ? [230, 235, 240] : [220, 255, 180], 414 - bg: isDefaultRange() ? [40, 45, 50] : [34, 56, 30], 415 - }); 416 - paintButton({ button: highDownBtn, label: "-", ink }); 417 - paintButton({ button: highUpBtn, label: "+", ink }); 222 + // Title 223 + ink(200, 120, 170).write("spreadnob", { x: 6, y: 4 }, undefined, undefined, false, MINI_FONT); 418 224 } 419 225 420 226 function act({ event, screen, needsPaint }) { 421 - buildUi(screen); 422 - 423 - lowDownBtn?.act(event, () => setRange(low - 1, high, { emit: true })); 424 - lowUpBtn?.act(event, () => setRange(low + 1, high, { emit: true })); 425 - highDownBtn?.act(event, () => setRange(low, high - 1, { emit: true })); 426 - highUpBtn?.act(event, () => setRange(low, high + 1, { emit: true })); 427 - resetBtn?.act(event, () => setRange(DEFAULT_LOW, DEFAULT_HIGH, { emit: true })); 428 - 429 - if (event.is("keyboard:down:left")) setRange(low - 1, high, { emit: true }); 430 - if (event.is("keyboard:down:right")) setRange(low, high + 1, { emit: true }); 431 - if (event.is("keyboard:down:down")) setRange(low + 1, high, { emit: true }); 432 - if (event.is("keyboard:down:up")) setRange(low, high - 1, { emit: true }); 433 - if (event.is("keyboard:down:r")) setRange(DEFAULT_LOW, DEFAULT_HIGH, { emit: true }); 434 - 435 227 needsPaint(); 436 228 } 437 229 438 - function leave() { 439 - if (typeof window !== "undefined") { 440 - if (messageHandler) window.removeEventListener("message", messageHandler); 441 - delete window.acSpreadnobSetActive; 442 - delete window.acSpreadnobSetTarget; 443 - delete window.acSpreadnobSetValue; 444 - delete window.acSpreadnobSetRange; 445 - delete window.acSpreadnobNote; 446 - } 447 - } 230 + function leave() {} 448 231 449 232 function meta() { 450 233 return { 451 234 title: "Spreadnob", 452 - desc: "AC-native control surface for the Ableton spreadnob device.", 235 + desc: "Spread any Ableton knob across your QWERTY white keys.", 453 236 }; 454 237 } 455 238
+95 -55
system/public/aesthetic.computer/lib/disk.mjs
··· 67 67 68 68 import * as lisp from "./kidlisp.mjs"; 69 69 import { isKidlispSource, fetchCachedCode, fetchKidlispMetadata, getCachedCode, initPersistentCache, getCachedCodeMultiLevel, saveCodeToAllCaches, enableKidlispConsole, enableKidlispTrace, disableKidlispTrace, clearExecutionTrace, postExecutionTrace } from "./kidlisp.mjs"; // Add lisp evaluator. 70 - import * as l5 from "./l5.mjs?v=20260330-runtime-support"; 70 + import * as l5 from "./l5.mjs?v=20260330-runtime-support"; 71 71 72 72 import { qrcode as qr, ErrorCorrectLevel } from "../dep/@akamfoad/qr/qr.mjs"; 73 73 import { microtype, MatrixChunky8 } from "../disks/common/fonts.mjs"; ··· 193 193 } 194 194 195 195 // Helper function to get safe protocol and hostname for URL construction 196 - function getSafeUrlParts() { 196 + function getSafeUrlParts() { 197 197 try { 198 198 const sandboxed = isSandboxed(); 199 199 ··· 232 232 protocol: "https:", 233 233 hostname: "aesthetic.computer" 234 234 }; 235 - } 236 - } 237 - 238 - const SAME_ORIGIN_BUILT_IN_PIECE_HOSTS = new Set([ 239 - "aesthetic.computer", 240 - "www.aesthetic.computer", 241 - "kidlisp.com", 242 - "www.kidlisp.com", 243 - "notepat.com", 244 - "www.notepat.com", 245 - "p5.aesthetic.computer", 246 - "sitemap.aesthetic.computer", 247 - ]); 248 - 249 - function isLocalDevelopmentHost(hostname) { 250 - return ( 251 - hostname === "localhost" || 252 - hostname === "127.0.0.1" || 253 - /^192\.168\./.test(hostname) || 254 - /^10\./.test(hostname) || 255 - /^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname) 256 - ); 257 - } 258 - 259 - function getBuiltInPieceBaseUrl() { 260 - const { protocol, hostname } = getSafeUrlParts(); 261 - 262 - if (typeof window !== "undefined" && window.acSPIDER) { 263 - return "https://aesthetic.computer"; 264 - } 265 - 266 - if ( 267 - isLocalDevelopmentHost(hostname) && 268 - typeof location !== "undefined" && 269 - location.port 270 - ) { 271 - return `${protocol}//${hostname}:${location.port}`; 272 - } 273 - 274 - if (SAME_ORIGIN_BUILT_IN_PIECE_HOSTS.has(hostname)) { 275 - return `${protocol}//${hostname}`; 276 - } 277 - 278 - return "https://aesthetic.computer"; 279 - } 235 + } 236 + } 237 + 238 + const SAME_ORIGIN_BUILT_IN_PIECE_HOSTS = new Set([ 239 + "aesthetic.computer", 240 + "www.aesthetic.computer", 241 + "kidlisp.com", 242 + "www.kidlisp.com", 243 + "notepat.com", 244 + "www.notepat.com", 245 + "p5.aesthetic.computer", 246 + "sitemap.aesthetic.computer", 247 + ]); 248 + 249 + function isLocalDevelopmentHost(hostname) { 250 + return ( 251 + hostname === "localhost" || 252 + hostname === "127.0.0.1" || 253 + /^192\.168\./.test(hostname) || 254 + /^10\./.test(hostname) || 255 + /^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname) 256 + ); 257 + } 258 + 259 + function getBuiltInPieceBaseUrl() { 260 + const { protocol, hostname } = getSafeUrlParts(); 261 + 262 + if (typeof window !== "undefined" && window.acSPIDER) { 263 + return "https://aesthetic.computer"; 264 + } 265 + 266 + if ( 267 + isLocalDevelopmentHost(hostname) && 268 + typeof location !== "undefined" && 269 + location.port 270 + ) { 271 + return `${protocol}//${hostname}:${location.port}`; 272 + } 273 + 274 + if (SAME_ORIGIN_BUILT_IN_PIECE_HOSTS.has(hostname)) { 275 + return `${protocol}//${hostname}`; 276 + } 277 + 278 + return "https://aesthetic.computer"; 279 + } 280 280 281 281 let tf; // Active typeface global. 282 282 ··· 2713 2713 sampleRate: null, 2714 2714 }; 2715 2715 2716 + // 🎛️ Persistent spreadnob state 2717 + const persistentSpreadnobState = { 2718 + note: null, 2719 + target: null, 2720 + value: null, 2721 + active: null, 2722 + min: null, 2723 + max: null, 2724 + }; 2725 + 2716 2726 const $commonApi = { 2717 2727 lisp, // A global reference to the `kidlisp` evalurator. 2718 2728 undef: undefined, // A global api shorthand for undefined. ··· 2905 2915 }, 2906 2916 2907 2917 // 🎄 Preload piece modules for merry pipelines (prevents network latency during fast cycling) 2908 - preloadPieces: async function preloadPieces(pieceNames) { 2918 + preloadPieces: async function preloadPieces(pieceNames) { 2909 2919 if (!pieceNames || pieceNames.length === 0) return; 2910 2920 2911 - const baseUrl = getBuiltInPieceBaseUrl(); 2921 + const baseUrl = getBuiltInPieceBaseUrl(); 2912 2922 2913 2923 const fetchPromises = pieceNames.map(async (piece) => { 2914 2924 // Skip if already cached ··· 2919 2929 2920 2930 try { 2921 2931 // Try .mjs first, then .lua, then .lisp 2922 - const mjsUrl = `${baseUrl}/aesthetic.computer/disks/${piece}.mjs?v=${Date.now()}`; 2932 + const mjsUrl = `${baseUrl}/aesthetic.computer/disks/${piece}.mjs?v=${Date.now()}`; 2923 2933 let response = await fetch(mjsUrl, { cache: 'no-store' }); 2924 2934 2925 2935 if (response.ok) { ··· 2930 2940 } 2931 2941 2932 2942 // Try .lua 2933 - const luaUrl = `${baseUrl}/aesthetic.computer/disks/${piece}.lua?v=${Date.now()}`; 2943 + const luaUrl = `${baseUrl}/aesthetic.computer/disks/${piece}.lua?v=${Date.now()}`; 2934 2944 response = await fetch(luaUrl, { cache: 'no-store' }); 2935 2945 2936 2946 if (response.ok) { ··· 2941 2951 } 2942 2952 2943 2953 // Try .lisp 2944 - const lispUrl = `${baseUrl}/aesthetic.computer/disks/${piece}.lisp?v=${Date.now()}`; 2954 + const lispUrl = `${baseUrl}/aesthetic.computer/disks/${piece}.lisp?v=${Date.now()}`; 2945 2955 response = await fetch(lispUrl, { cache: 'no-store' }); 2946 2956 2947 2957 if (response.ok) { ··· 7440 7450 baseUrl = "."; 7441 7451 } else { 7442 7452 // Check if we're in a development environment (localhost with port) 7443 - baseUrl = getBuiltInPieceBaseUrl(); 7444 - } 7453 + baseUrl = getBuiltInPieceBaseUrl(); 7454 + } 7445 7455 } else { 7446 7456 baseUrl = `${protocol}//${hostname}`; 7447 7457 } ··· 8676 8686 let baseUrl; 8677 8687 if (path.startsWith('aesthetic.computer/')) { 8678 8688 // Check if we're in a development environment (localhost with port) 8679 - baseUrl = getBuiltInPieceBaseUrl(); 8689 + baseUrl = getBuiltInPieceBaseUrl(); 8680 8690 8681 8691 // Only strip "aesthetic.computer/" if we're using the main production domain 8682 8692 if (baseUrl === 'https://aesthetic.computer') { ··· 10065 10075 return; 10066 10076 } 10067 10077 10078 + // 🎛️ Spreadnob messages (from M4L via bios) — stored on persistentDawState 10079 + if (type === "spreadnob:note") { 10080 + persistentDawState.snNote = content.note; 10081 + return; 10082 + } 10083 + if (type === "spreadnob:target") { 10084 + persistentDawState.snTarget = content.name; 10085 + return; 10086 + } 10087 + if (type === "spreadnob:value") { 10088 + persistentDawState.snValue = content.value; 10089 + return; 10090 + } 10091 + if (type === "spreadnob:active") { 10092 + persistentDawState.snActive = content.active; 10093 + return; 10094 + } 10095 + if (type === "spreadnob:min") { 10096 + persistentDawState.snMin = content.min; 10097 + return; 10098 + } 10099 + if (type === "spreadnob:max") { 10100 + persistentDawState.snMax = content.max; 10101 + return; 10102 + } 10103 + 10068 10104 // 🎸 Pedal messages (for audio effect visualization) 10069 10105 if (type === "pedal:peak") { 10070 10106 // Forward to the piece's receive function if it exists ··· 11853 11889 // Uses persistentDawState which survives frame updates (unlike $commonApi.sound which is recreated) 11854 11890 get daw() { 11855 11891 return persistentDawState; 11892 + }, 11893 + // 🎛️ Spreadnob state (populated via spreadnob:* messages from M4L) 11894 + get spreadnob() { 11895 + return persistentSpreadnobState; 11856 11896 }, 11857 11897 // Get the bpm with bpm() or set the bpm with bpm(newBPM). 11858 11898 bpm: function (newBPM) {