From 0a28ffd372b735d0901504c273746472f2c65011 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 2 Apr 2026 01:28:47 -0700 Subject: [PATCH] feat: improve spreadnob octave mapping and ableton downloads --- ac-m4l/AC-KnobMap.amxd.json | 63 +- ac-m4l/AC-SpreadnobClean.amxd.json | 946 ++++++++++++++++++ ac-m4l/devices.json | 12 +- ac-m4l/spreadnob-note-normalizer.js | 185 ++++ system/netlify/functions/index.mjs | 19 +- .../aesthetic.computer/disks/ableton.mjs | 73 ++ .../aesthetic.computer/disks/spreadnob.mjs | 384 ++++--- system/public/aesthetic.computer/lib/disk.mjs | 27 +- 8 files changed, 1558 insertions(+), 151 deletions(-) create mode 100644 ac-m4l/AC-SpreadnobClean.amxd.json create mode 100644 ac-m4l/spreadnob-note-normalizer.js diff --git a/ac-m4l/AC-KnobMap.amxd.json b/ac-m4l/AC-KnobMap.amxd.json index 2a78fb68ef..36dc799220 100644 --- a/ac-m4l/AC-KnobMap.amxd.json +++ b/ac-m4l/AC-KnobMap.amxd.json @@ -983,7 +983,7 @@ 230.0, 22.0 ], - "text": "script window.acSnNote&&window.acSnNote($1)" + "text": "script window.acSnQwertyRange&&window.acSnQwertyRange($1,$2)" } }, { @@ -1004,6 +1004,24 @@ "text": "script window.acSnNote&&window.acSnNote($1)" } }, + { + "box": { + "id": "obj-ui-state-msg", + "maxclass": "message", + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 760.0, + 356.0, + 255.0, + 22.0 + ], + "text": "script window.acSnState&&window.acSnState($1,$2,$3,$4,$5)" + } + }, { "box": { "id": "obj-ui-value-msg", @@ -1760,20 +1778,21 @@ }, { "box": { - "id": "obj-note-range-clip", + "id": "obj-note-normalize", "maxclass": "newobj", "numinlets": 3, - "numoutlets": 1, + "numoutlets": 2, "outlettype": [ + "", "" ], "patching_rect": [ 500.0, 130.0, - 90.0, + 170.0, 22.0 ], - "text": "clip 48. 62." + "text": "js spreadnob-note-normalizer.js" } }, { @@ -2700,7 +2719,7 @@ { "patchline": { "destination": [ - "obj-note-range-clip", + "obj-note-normalize", 1 ], "source": [ @@ -2712,7 +2731,7 @@ { "patchline": { "destination": [ - "obj-note-range-clip", + "obj-note-normalize", 2 ], "source": [ @@ -3516,7 +3535,7 @@ { "patchline": { "destination": [ - "obj-note-range-clip", + "obj-note-normalize", 0 ], "source": [ @@ -3532,7 +3551,7 @@ 0 ], "source": [ - "obj-note-range-clip", + "obj-note-normalize", 0 ] } @@ -3556,11 +3575,23 @@ 0 ], "source": [ - "obj-stripnote", + "obj-note-normalize", 0 ] } }, + { + "patchline": { + "destination": [ + "obj-ui-state-msg", + 0 + ], + "source": [ + "obj-note-normalize", + 1 + ] + } + }, { "patchline": { "destination": [ @@ -3573,6 +3604,18 @@ ] } }, + { + "patchline": { + "destination": [ + "obj-jweb", + 0 + ], + "source": [ + "obj-ui-state-msg", + 0 + ] + } + }, { "patchline": { "destination": [ diff --git a/ac-m4l/AC-SpreadnobClean.amxd.json b/ac-m4l/AC-SpreadnobClean.amxd.json new file mode 100644 index 0000000000..d749e95ab2 --- /dev/null +++ b/ac-m4l/AC-SpreadnobClean.amxd.json @@ -0,0 +1,946 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { "major": 9, "minor": 0, "revision": 7, "architecture": "x64", "modernui": 1 }, + "classnamespace": "box", + "rect": [100, 100, 900, 600], + "openrect": [0, 0, 220, 110], + "openinpresentation": 1, + "gridsize": [15, 15], + "enablehscroll": 0, + "enablevscroll": 0, + "devicewidth": 220, + "description": "Spreadnob — spread any knob across MIDI white keys A-L", + "boxes": [ + { + "box": { + "id": "obj-key-a", + "maxclass": "live.text", + "numinlets": 1, "numoutlets": 2, "outlettype": ["", ""], + "patching_rect": [10, 460, 40, 20], + "presentation": 1, + "presentation_rect": [67, 73, 16, 14], + "mode": 0, + "text": "A", "texton": "A", + "fontsize": 8, + "parameter_enable": 1, + "saved_attribute_attributes": { + "valueof": { + "parameter_longname": "Key A", + "parameter_shortname": "A", + "parameter_type": 2, + "parameter_mmax": 1 + } + }, + "varname": "KeyA", + "bgcolor": [0.2, 0.2, 0.22, 1.0], + "bgoncolor": [1.0, 0.47, 0.72, 1.0], + "textcolor": [0.7, 0.65, 0.75, 1.0], + "textoncolor": [1, 1, 1, 1] + } + }, + { + "box": { + "id": "obj-key-s", + "maxclass": "live.text", + "numinlets": 1, "numoutlets": 2, "outlettype": ["", ""], + "patching_rect": [60, 460, 40, 20], + "presentation": 1, + "presentation_rect": [54, 52, 16, 14], + "mode": 0, + "text": "S", "texton": "S", + "fontsize": 8, + "parameter_enable": 1, + "saved_attribute_attributes": { + "valueof": { + "parameter_longname": "Key S", + "parameter_shortname": "S", + "parameter_type": 2, + "parameter_mmax": 1 + } + }, + "varname": "KeyS", + "bgcolor": [0.2, 0.2, 0.22, 1.0], + "bgoncolor": [1.0, 0.47, 0.72, 1.0], + "textcolor": [0.7, 0.65, 0.75, 1.0], + "textoncolor": [1, 1, 1, 1] + } + }, + { + "box": { + "id": "obj-key-d", + "maxclass": "live.text", + "numinlets": 1, "numoutlets": 2, "outlettype": ["", ""], + "patching_rect": [110, 460, 40, 20], + "presentation": 1, + "presentation_rect": [61, 31, 16, 14], + "mode": 0, + "text": "D", "texton": "D", + "fontsize": 8, + "parameter_enable": 1, + "saved_attribute_attributes": { + "valueof": { + "parameter_longname": "Key D", + "parameter_shortname": "D", + "parameter_type": 2, + "parameter_mmax": 1 + } + }, + "varname": "KeyD", + "bgcolor": [0.2, 0.2, 0.22, 1.0], + "bgoncolor": [1.0, 0.47, 0.72, 1.0], + "textcolor": [0.7, 0.65, 0.75, 1.0], + "textoncolor": [1, 1, 1, 1] + } + }, + { + "box": { + "id": "obj-key-f", + "maxclass": "live.text", + "numinlets": 1, "numoutlets": 2, "outlettype": ["", ""], + "patching_rect": [160, 460, 40, 20], + "presentation": 1, + "presentation_rect": [78, 16, 16, 14], + "mode": 0, + "text": "F", "texton": "F", + "fontsize": 8, + "parameter_enable": 1, + "saved_attribute_attributes": { + "valueof": { + "parameter_longname": "Key F", + "parameter_shortname": "F", + "parameter_type": 2, + "parameter_mmax": 1 + } + }, + "varname": "KeyF", + "bgcolor": [0.2, 0.2, 0.22, 1.0], + "bgoncolor": [1.0, 0.47, 0.72, 1.0], + "textcolor": [0.7, 0.65, 0.75, 1.0], + "textoncolor": [1, 1, 1, 1] + } + }, + { + "box": { + "id": "obj-key-g", + "maxclass": "live.text", + "numinlets": 1, "numoutlets": 2, "outlettype": ["", ""], + "patching_rect": [210, 460, 40, 20], + "presentation": 1, + "presentation_rect": [102, 10, 16, 14], + "mode": 0, + "text": "G", "texton": "G", + "fontsize": 8, + "parameter_enable": 1, + "saved_attribute_attributes": { + "valueof": { + "parameter_longname": "Key G", + "parameter_shortname": "G", + "parameter_type": 2, + "parameter_mmax": 1 + } + }, + "varname": "KeyG", + "bgcolor": [0.2, 0.2, 0.22, 1.0], + "bgoncolor": [1.0, 0.47, 0.72, 1.0], + "textcolor": [0.7, 0.65, 0.75, 1.0], + "textoncolor": [1, 1, 1, 1] + } + }, + { + "box": { + "id": "obj-key-h", + "maxclass": "live.text", + "numinlets": 1, "numoutlets": 2, "outlettype": ["", ""], + "patching_rect": [260, 460, 40, 20], + "presentation": 1, + "presentation_rect": [126, 16, 16, 14], + "mode": 0, + "text": "H", "texton": "H", + "fontsize": 8, + "parameter_enable": 1, + "saved_attribute_attributes": { + "valueof": { + "parameter_longname": "Key H", + "parameter_shortname": "H", + "parameter_type": 2, + "parameter_mmax": 1 + } + }, + "varname": "KeyH", + "bgcolor": [0.2, 0.2, 0.22, 1.0], + "bgoncolor": [1.0, 0.47, 0.72, 1.0], + "textcolor": [0.7, 0.65, 0.75, 1.0], + "textoncolor": [1, 1, 1, 1] + } + }, + { + "box": { + "id": "obj-key-j", + "maxclass": "live.text", + "numinlets": 1, "numoutlets": 2, "outlettype": ["", ""], + "patching_rect": [310, 460, 40, 20], + "presentation": 1, + "presentation_rect": [143, 31, 16, 14], + "mode": 0, + "text": "J", "texton": "J", + "fontsize": 8, + "parameter_enable": 1, + "saved_attribute_attributes": { + "valueof": { + "parameter_longname": "Key J", + "parameter_shortname": "J", + "parameter_type": 2, + "parameter_mmax": 1 + } + }, + "varname": "KeyJ", + "bgcolor": [0.2, 0.2, 0.22, 1.0], + "bgoncolor": [1.0, 0.47, 0.72, 1.0], + "textcolor": [0.7, 0.65, 0.75, 1.0], + "textoncolor": [1, 1, 1, 1] + } + }, + { + "box": { + "id": "obj-key-k", + "maxclass": "live.text", + "numinlets": 1, "numoutlets": 2, "outlettype": ["", ""], + "patching_rect": [360, 460, 40, 20], + "presentation": 1, + "presentation_rect": [150, 52, 16, 14], + "mode": 0, + "text": "K", "texton": "K", + "fontsize": 8, + "parameter_enable": 1, + "saved_attribute_attributes": { + "valueof": { + "parameter_longname": "Key K", + "parameter_shortname": "K", + "parameter_type": 2, + "parameter_mmax": 1 + } + }, + "varname": "KeyK", + "bgcolor": [0.2, 0.2, 0.22, 1.0], + "bgoncolor": [1.0, 0.47, 0.72, 1.0], + "textcolor": [0.7, 0.65, 0.75, 1.0], + "textoncolor": [1, 1, 1, 1] + } + }, + { + "box": { + "id": "obj-key-l", + "maxclass": "live.text", + "numinlets": 1, "numoutlets": 2, "outlettype": ["", ""], + "patching_rect": [410, 460, 40, 20], + "presentation": 1, + "presentation_rect": [143, 73, 16, 14], + "mode": 0, + "text": "L", "texton": "L", + "fontsize": 8, + "parameter_enable": 1, + "saved_attribute_attributes": { + "valueof": { + "parameter_longname": "Key L", + "parameter_shortname": "L", + "parameter_type": 2, + "parameter_mmax": 1 + } + }, + "varname": "KeyL", + "bgcolor": [0.2, 0.2, 0.22, 1.0], + "bgoncolor": [1.0, 0.47, 0.72, 1.0], + "textcolor": [0.7, 0.65, 0.75, 1.0], + "textoncolor": [1, 1, 1, 1] + } + }, + { + "box": { + "id": "obj-dial", + "maxclass": "dial", + "numinlets": 1, "numoutlets": 1, "outlettype": ["float"], + "patching_rect": [460, 460, 56, 56], + "presentation": 1, + "presentation_rect": [88, 35, 44, 48], + "floatoutput": 1, + "degrees": 270, + "thickness": 14.0, + "varname": "SpreadDial" + } + }, + { + "box": { + "id": "obj-value-display", + "maxclass": "live.comment", + "numinlets": 1, "numoutlets": 0, + "patching_rect": [465, 478, 48, 14], + "presentation": 1, + "presentation_rect": [89, 52, 42, 12], + "text": "--", + "fontsize": 8, "fontface": 1, + "textjustification": 1 + } + }, + { + "box": { + "id": "obj-target-display", + "maxclass": "live.comment", + "numinlets": 1, "numoutlets": 0, + "patching_rect": [120, 90, 160, 14], + "presentation": 1, + "presentation_rect": [25, 84, 170, 14], + "text": "click a knob", + "fontsize": 8, "fontface": 1, + "textjustification": 1 + } + }, + { + "box": { + "id": "obj-note-display", + "maxclass": "live.comment", + "numinlets": 1, "numoutlets": 0, + "patching_rect": [10, 520, 50, 12], + "text": "--", + "fontsize": 8, + "textjustification": 0 + } + }, + { + "box": { + "id": "obj-thisdevice", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 3, "outlettype": ["bang", "int", "int"], + "patching_rect": [30, 120, 85, 22], + "text": "live.thisdevice" + } + }, + { + "box": { + "id": "obj-msg-getsel", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [200, 120, 165, 22], + "text": "path live_set view selected_parameter" + } + }, + { + "box": { + "id": "obj-songpath", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 3, "outlettype": ["", "", ""], + "patching_rect": [30, 150, 120, 22], + "text": "live.path" + } + }, + { + "box": { + "id": "obj-route-target-id", + "maxclass": "newobj", + "numinlets": 2, "numoutlets": 2, "outlettype": ["", ""], + "patching_rect": [30, 180, 60, 22], + "text": "route id" + } + }, + { + "box": { + "id": "obj-sel-target-zero", + "maxclass": "newobj", + "numinlets": 2, "numoutlets": 2, "outlettype": ["bang", ""], + "patching_rect": [30, 210, 40, 22], + "text": "sel 0" + } + }, + { + "box": { + "id": "obj-target-trigger", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 4, "outlettype": ["bang", "bang", "bang", "int"], + "patching_rect": [100, 240, 65, 22], + "text": "t b b b i" + } + }, + { + "box": { + "id": "obj-prepend-target-id", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 1, "outlettype": [""], + "patching_rect": [200, 240, 70, 22], + "text": "prepend id" + } + }, + { + "box": { + "id": "obj-msg-getmin", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [100, 265, 70, 22], + "text": "get min" + } + }, + { + "box": { + "id": "obj-msg-getmax", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [220, 265, 75, 22], + "text": "get max" + } + }, + { + "box": { + "id": "obj-msg-getname", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [350, 265, 80, 22], + "text": "get name" + } + }, + { + "box": { + "id": "obj-reader-min", + "maxclass": "newobj", + "numinlets": 2, "numoutlets": 3, "outlettype": ["", "", ""], + "patching_rect": [100, 290, 80, 22], + "text": "live.object" + } + }, + { + "box": { + "id": "obj-reader-max", + "maxclass": "newobj", + "numinlets": 2, "numoutlets": 3, "outlettype": ["", "", ""], + "patching_rect": [220, 290, 80, 22], + "text": "live.object" + } + }, + { + "box": { + "id": "obj-reader-name", + "maxclass": "newobj", + "numinlets": 2, "numoutlets": 3, "outlettype": ["", "", ""], + "patching_rect": [350, 290, 80, 22], + "text": "live.object" + } + }, + { + "box": { + "id": "obj-route-min", + "maxclass": "newobj", + "numinlets": 2, "numoutlets": 2, "outlettype": ["", ""], + "patching_rect": [100, 320, 65, 22], + "text": "route min" + } + }, + { + "box": { + "id": "obj-route-max", + "maxclass": "newobj", + "numinlets": 2, "numoutlets": 2, "outlettype": ["", ""], + "patching_rect": [220, 320, 70, 22], + "text": "route max" + } + }, + { + "box": { + "id": "obj-route-name", + "maxclass": "newobj", + "numinlets": 2, "numoutlets": 2, "outlettype": ["", ""], + "patching_rect": [350, 320, 75, 22], + "text": "route name" + } + }, + { + "box": { + "id": "obj-store-min", + "maxclass": "newobj", + "numinlets": 2, "numoutlets": 1, "outlettype": ["float"], + "patching_rect": [100, 350, 35, 22], + "text": "float" + } + }, + { + "box": { + "id": "obj-store-max", + "maxclass": "newobj", + "numinlets": 2, "numoutlets": 1, "outlettype": ["float"], + "patching_rect": [220, 350, 35, 22], + "text": "float" + } + }, + { + "box": { + "id": "obj-tosymbol", + "maxclass": "newobj", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [350, 350, 60, 22], + "text": "tosymbol" + } + }, + { + "box": { + "id": "obj-prepend-set-target", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 1, "outlettype": [""], + "patching_rect": [420, 350, 85, 22], + "text": "prepend set" + } + }, + { + "box": { + "id": "obj-open-gate", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [450, 350, 18, 22], + "text": "1" + } + }, + { + "box": { + "id": "obj-writer", + "maxclass": "newobj", + "numinlets": 2, "numoutlets": 3, "outlettype": ["", "", ""], + "patching_rect": [500, 440, 80, 22], + "text": "live.object" + } + }, + { + "box": { + "id": "obj-midiin", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 1, "outlettype": ["int"], + "patching_rect": [600, 120, 45, 22], + "text": "midiin" + } + }, + { + "box": { + "id": "obj-midiout", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 0, + "patching_rect": [660, 150, 50, 22], + "text": "midiout" + } + }, + { + "box": { + "id": "obj-midiparse", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 8, + "outlettype": ["", "", "", "int", "int", "int", "int", ""], + "patching_rect": [600, 180, 70, 22], + "text": "midiparse" + } + }, + { + "box": { + "id": "obj-unpack-note", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 2, "outlettype": ["int", "int"], + "patching_rect": [600, 210, 65, 22], + "text": "unpack 0 0" + } + }, + { + "box": { + "id": "obj-stripnote", + "maxclass": "newobj", + "numinlets": 2, "numoutlets": 2, "outlettype": ["int", "int"], + "patching_rect": [600, 240, 60, 22], + "text": "stripnote" + } + }, + { + "box": { + "id": "obj-expr", + "maxclass": "newobj", + "numinlets": 3, "numoutlets": 1, "outlettype": [""], + "patching_rect": [500, 300, 190, 22], + "text": "expr ($f1 / 8.) * ($f2 - $f3) + $f3" + } + }, + { + "box": { + "id": "obj-tofloat", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 1, "outlettype": ["float"], + "patching_rect": [500, 330, 35, 22], + "text": "t f" + } + }, + { + "box": { + "id": "obj-master-gate", + "maxclass": "newobj", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [500, 370, 55, 22], + "text": "gate 1 0" + } + }, + { + "box": { + "id": "obj-msg-setval", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [500, 410, 85, 22], + "text": "set value $1" + } + }, + { + "box": { + "id": "obj-sprintf-val", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 1, "outlettype": [""], + "patching_rect": [580, 410, 90, 22], + "text": "sprintf set %.3f" + } + }, + { + "box": { + "id": "obj-scale-dial", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 1, "outlettype": [""], + "patching_rect": [660, 410, 105, 22], + "text": "scale 0 8 0 127" + } + }, + { + "box": { + "id": "obj-prepend-set-dial", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 1, "outlettype": [""], + "patching_rect": [580, 440, 85, 22], + "text": "prepend set" + } + }, + { + "box": { + "id": "obj-loadmess-low", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 1, "outlettype": ["int"], + "patching_rect": [700, 120, 65, 22], + "text": "loadmess 48" + } + }, + { + "box": { + "id": "obj-loadmess-high", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 1, "outlettype": ["int"], + "patching_rect": [770, 120, 65, 22], + "text": "loadmess 62" + } + }, + { + "box": { + "id": "obj-midi-low", + "maxclass": "number", + "numinlets": 1, "numoutlets": 2, "outlettype": ["int", "bang"], + "patching_rect": [700, 150, 38, 22], + "minimum": 0, "maximum": 127 + } + }, + { + "box": { + "id": "obj-midi-high", + "maxclass": "number", + "numinlets": 1, "numoutlets": 2, "outlettype": ["int", "bang"], + "patching_rect": [770, 150, 38, 22], + "minimum": 0, "maximum": 127 + } + }, + { + "box": { + "id": "obj-prepend-set-note", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 1, "outlettype": [""], + "patching_rect": [700, 270, 85, 22], + "text": "prepend set" + } + }, + { + "box": { + "id": "obj-sub-base", + "maxclass": "newobj", + "numinlets": 1, "numoutlets": 1, "outlettype": ["int"], + "patching_rect": [600, 330, 120, 22], + "text": "expr (((($i1 - 48) % 24) + 24) % 24)" + } + }, + { + "box": { + "id": "obj-select-keys", + "maxclass": "newobj", + "numinlets": 10, "numoutlets": 10, "outlettype": ["bang", "bang", "bang", "bang", "bang", "bang", "bang", "bang", "bang", ""], + "patching_rect": [600, 360, 200, 22], + "text": "select 0 2 4 5 7 9 11 12 14" + } + }, + { + "box": { + "id": "obj-note-a", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [600, 385, 24, 22], + "text": "0" + } + }, + { + "box": { + "id": "obj-note-s", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [625, 385, 24, 22], + "text": "1" + } + }, + { + "box": { + "id": "obj-note-d", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [650, 385, 24, 22], + "text": "2" + } + }, + { + "box": { + "id": "obj-note-f", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [675, 385, 24, 22], + "text": "3" + } + }, + { + "box": { + "id": "obj-note-g", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [700, 385, 24, 22], + "text": "4" + } + }, + { + "box": { + "id": "obj-note-h", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [725, 385, 24, 22], + "text": "5" + } + }, + { + "box": { + "id": "obj-note-j", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [750, 385, 24, 22], + "text": "6" + } + }, + { + "box": { + "id": "obj-note-k", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [775, 385, 24, 22], + "text": "7" + } + }, + { + "box": { + "id": "obj-note-l", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [800, 385, 24, 22], + "text": "8" + } + }, + { + "box": { + "id": "obj-flash-a", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [600, 400, 18, 22], + "text": "1" + } + }, + { + "box": { + "id": "obj-flash-s", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [625, 400, 18, 22], + "text": "1" + } + }, + { + "box": { + "id": "obj-flash-d", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [650, 400, 18, 22], + "text": "1" + } + }, + { + "box": { + "id": "obj-flash-f", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [675, 400, 18, 22], + "text": "1" + } + }, + { + "box": { + "id": "obj-flash-g", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [700, 400, 18, 22], + "text": "1" + } + }, + { + "box": { + "id": "obj-flash-h", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [725, 400, 18, 22], + "text": "1" + } + }, + { + "box": { + "id": "obj-flash-j", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [750, 400, 18, 22], + "text": "1" + } + }, + { + "box": { + "id": "obj-flash-k", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [775, 400, 18, 22], + "text": "1" + } + }, + { + "box": { + "id": "obj-flash-l", + "maxclass": "message", + "numinlets": 2, "numoutlets": 1, "outlettype": [""], + "patching_rect": [800, 400, 18, 22], + "text": "1" + } + } + ], + "lines": [ + {"patchline": {"destination": ["obj-msg-getsel", 0], "source": ["obj-thisdevice", 0]}}, + {"patchline": {"destination": ["obj-songpath", 0], "source": ["obj-msg-getsel", 0]}}, + {"patchline": {"destination": ["obj-route-target-id", 0], "source": ["obj-songpath", 0]}}, + {"patchline": {"destination": ["obj-route-target-id", 0], "source": ["obj-songpath", 1]}}, + {"patchline": {"destination": ["obj-sel-target-zero", 0], "source": ["obj-route-target-id", 0]}}, + {"patchline": {"destination": ["obj-target-trigger", 0], "source": ["obj-sel-target-zero", 1]}}, + {"patchline": {"destination": ["obj-prepend-target-id", 0], "source": ["obj-target-trigger", 3]}}, + {"patchline": {"destination": ["obj-msg-getname", 0], "source": ["obj-target-trigger", 2]}}, + {"patchline": {"destination": ["obj-msg-getmax", 0], "source": ["obj-target-trigger", 1]}}, + {"patchline": {"destination": ["obj-msg-getmin", 0], "source": ["obj-target-trigger", 0]}}, + {"patchline": {"destination": ["obj-reader-min", 1], "source": ["obj-prepend-target-id", 0]}}, + {"patchline": {"destination": ["obj-reader-max", 1], "source": ["obj-prepend-target-id", 0]}}, + {"patchline": {"destination": ["obj-reader-name", 1], "source": ["obj-prepend-target-id", 0]}}, + {"patchline": {"destination": ["obj-writer", 1], "source": ["obj-prepend-target-id", 0]}}, + {"patchline": {"destination": ["obj-reader-min", 0], "source": ["obj-msg-getmin", 0]}}, + {"patchline": {"destination": ["obj-reader-max", 0], "source": ["obj-msg-getmax", 0]}}, + {"patchline": {"destination": ["obj-reader-name", 0], "source": ["obj-msg-getname", 0]}}, + {"patchline": {"destination": ["obj-route-min", 0], "source": ["obj-reader-min", 0]}}, + {"patchline": {"destination": ["obj-route-max", 0], "source": ["obj-reader-max", 0]}}, + {"patchline": {"destination": ["obj-route-name", 0], "source": ["obj-reader-name", 0]}}, + {"patchline": {"destination": ["obj-store-min", 0], "source": ["obj-route-min", 0]}}, + {"patchline": {"destination": ["obj-store-max", 0], "source": ["obj-route-max", 0]}}, + {"patchline": {"destination": ["obj-expr", 2], "source": ["obj-store-min", 0]}}, + {"patchline": {"destination": ["obj-expr", 1], "source": ["obj-store-max", 0]}}, + {"patchline": {"destination": ["obj-tosymbol", 0], "source": ["obj-route-name", 0]}}, + {"patchline": {"destination": ["obj-prepend-set-target", 0], "source": ["obj-tosymbol", 0]}}, + {"patchline": {"destination": ["obj-target-display", 0], "source": ["obj-prepend-set-target", 0]}}, + {"patchline": {"destination": ["obj-open-gate", 0], "source": ["obj-route-name", 0]}}, + {"patchline": {"destination": ["obj-master-gate", 0], "source": ["obj-open-gate", 0]}}, + {"patchline": {"destination": ["obj-midiout", 0], "source": ["obj-midiin", 0]}}, + {"patchline": {"destination": ["obj-midiparse", 0], "source": ["obj-midiin", 0]}}, + {"patchline": {"destination": ["obj-unpack-note", 0], "source": ["obj-midiparse", 0]}}, + {"patchline": {"destination": ["obj-stripnote", 0], "source": ["obj-unpack-note", 0]}}, + {"patchline": {"destination": ["obj-stripnote", 1], "source": ["obj-unpack-note", 1]}}, + {"patchline": {"destination": ["obj-prepend-set-note", 0], "source": ["obj-stripnote", 0]}}, + {"patchline": {"destination": ["obj-sub-base", 0], "source": ["obj-stripnote", 0]}}, + {"patchline": {"destination": ["obj-note-display", 0], "source": ["obj-prepend-set-note", 0]}}, + {"patchline": {"destination": ["obj-tofloat", 0], "source": ["obj-expr", 0]}}, + {"patchline": {"destination": ["obj-master-gate", 1], "source": ["obj-tofloat", 0]}}, + {"patchline": {"destination": ["obj-msg-setval", 0], "source": ["obj-master-gate", 0]}}, + {"patchline": {"destination": ["obj-writer", 0], "source": ["obj-msg-setval", 0]}}, + {"patchline": {"destination": ["obj-sprintf-val", 0], "source": ["obj-master-gate", 0]}}, + {"patchline": {"destination": ["obj-value-display", 0], "source": ["obj-sprintf-val", 0]}}, + {"patchline": {"destination": ["obj-midi-low", 0], "source": ["obj-loadmess-low", 0]}}, + {"patchline": {"destination": ["obj-midi-high", 0], "source": ["obj-loadmess-high", 0]}}, + {"patchline": {"destination": ["obj-select-keys", 0], "source": ["obj-sub-base", 0]}}, + {"patchline": {"destination": ["obj-note-a", 0], "source": ["obj-select-keys", 0]}}, + {"patchline": {"destination": ["obj-note-s", 0], "source": ["obj-select-keys", 1]}}, + {"patchline": {"destination": ["obj-note-d", 0], "source": ["obj-select-keys", 2]}}, + {"patchline": {"destination": ["obj-note-f", 0], "source": ["obj-select-keys", 3]}}, + {"patchline": {"destination": ["obj-note-g", 0], "source": ["obj-select-keys", 4]}}, + {"patchline": {"destination": ["obj-note-h", 0], "source": ["obj-select-keys", 5]}}, + {"patchline": {"destination": ["obj-note-j", 0], "source": ["obj-select-keys", 6]}}, + {"patchline": {"destination": ["obj-note-k", 0], "source": ["obj-select-keys", 7]}}, + {"patchline": {"destination": ["obj-note-l", 0], "source": ["obj-select-keys", 8]}}, + {"patchline": {"destination": ["obj-scale-dial", 0], "source": ["obj-note-a", 0]}}, + {"patchline": {"destination": ["obj-scale-dial", 0], "source": ["obj-note-s", 0]}}, + {"patchline": {"destination": ["obj-scale-dial", 0], "source": ["obj-note-d", 0]}}, + {"patchline": {"destination": ["obj-scale-dial", 0], "source": ["obj-note-f", 0]}}, + {"patchline": {"destination": ["obj-scale-dial", 0], "source": ["obj-note-g", 0]}}, + {"patchline": {"destination": ["obj-scale-dial", 0], "source": ["obj-note-h", 0]}}, + {"patchline": {"destination": ["obj-scale-dial", 0], "source": ["obj-note-j", 0]}}, + {"patchline": {"destination": ["obj-scale-dial", 0], "source": ["obj-note-k", 0]}}, + {"patchline": {"destination": ["obj-scale-dial", 0], "source": ["obj-note-l", 0]}}, + {"patchline": {"destination": ["obj-prepend-set-dial", 0], "source": ["obj-scale-dial", 0]}}, + {"patchline": {"destination": ["obj-dial", 0], "source": ["obj-prepend-set-dial", 0]}}, + {"patchline": {"destination": ["obj-expr", 0], "source": ["obj-note-a", 0]}}, + {"patchline": {"destination": ["obj-expr", 0], "source": ["obj-note-s", 0]}}, + {"patchline": {"destination": ["obj-expr", 0], "source": ["obj-note-d", 0]}}, + {"patchline": {"destination": ["obj-expr", 0], "source": ["obj-note-f", 0]}}, + {"patchline": {"destination": ["obj-expr", 0], "source": ["obj-note-g", 0]}}, + {"patchline": {"destination": ["obj-expr", 0], "source": ["obj-note-h", 0]}}, + {"patchline": {"destination": ["obj-expr", 0], "source": ["obj-note-j", 0]}}, + {"patchline": {"destination": ["obj-expr", 0], "source": ["obj-note-k", 0]}}, + {"patchline": {"destination": ["obj-expr", 0], "source": ["obj-note-l", 0]}}, + {"patchline": {"destination": ["obj-flash-a", 0], "source": ["obj-select-keys", 0]}}, + {"patchline": {"destination": ["obj-flash-s", 0], "source": ["obj-select-keys", 1]}}, + {"patchline": {"destination": ["obj-flash-d", 0], "source": ["obj-select-keys", 2]}}, + {"patchline": {"destination": ["obj-flash-f", 0], "source": ["obj-select-keys", 3]}}, + {"patchline": {"destination": ["obj-flash-g", 0], "source": ["obj-select-keys", 4]}}, + {"patchline": {"destination": ["obj-flash-h", 0], "source": ["obj-select-keys", 5]}}, + {"patchline": {"destination": ["obj-flash-j", 0], "source": ["obj-select-keys", 6]}}, + {"patchline": {"destination": ["obj-flash-k", 0], "source": ["obj-select-keys", 7]}}, + {"patchline": {"destination": ["obj-flash-l", 0], "source": ["obj-select-keys", 8]}}, + {"patchline": {"destination": ["obj-key-a", 0], "source": ["obj-flash-a", 0]}}, + {"patchline": {"destination": ["obj-key-s", 0], "source": ["obj-flash-s", 0]}}, + {"patchline": {"destination": ["obj-key-d", 0], "source": ["obj-flash-d", 0]}}, + {"patchline": {"destination": ["obj-key-f", 0], "source": ["obj-flash-f", 0]}}, + {"patchline": {"destination": ["obj-key-g", 0], "source": ["obj-flash-g", 0]}}, + {"patchline": {"destination": ["obj-key-h", 0], "source": ["obj-flash-h", 0]}}, + {"patchline": {"destination": ["obj-key-j", 0], "source": ["obj-flash-j", 0]}}, + {"patchline": {"destination": ["obj-key-k", 0], "source": ["obj-flash-k", 0]}}, + {"patchline": {"destination": ["obj-key-l", 0], "source": ["obj-flash-l", 0]}} + ], + "dependency_cache": [], + "latency": 0, + "is_mpe": 0, + "external_mpe_tuning_enabled": 0, + "minimum_live_version": "", + "minimum_max_version": "", + "platform_compatibility": 0, + "autosave": 0 + } +} diff --git a/ac-m4l/devices.json b/ac-m4l/devices.json index 321a69cd7c..70e82592b5 100644 --- a/ac-m4l/devices.json +++ b/ac-m4l/devices.json @@ -38,10 +38,20 @@ "height": 250, "type": "effect" }, + { + "name": "AC 🎹 spreadnob-clean (aesthetic.computer)", + "piece": "spreadnob-clean", + "description": "Spread any knob across your MIDI keys - compact clean build", + "width": 220, + "height": 110, + "type": "midi", + "source": "AC-SpreadnobClean.amxd.json", + "version": "2.0.0" + }, { "name": "AC 🎹 spreadnob (aesthetic.computer)", "piece": "spreadnob", - "description": "Spread any knob across your MIDI keys", + "description": "Spread any knob across your MIDI keys - expanded rack module", "width": 400, "height": 170, "type": "midi", diff --git a/ac-m4l/spreadnob-note-normalizer.js b/ac-m4l/spreadnob-note-normalizer.js new file mode 100644 index 0000000000..df8cd2f2d9 --- /dev/null +++ b/ac-m4l/spreadnob-note-normalizer.js @@ -0,0 +1,185 @@ +// spreadnob-note-normalizer.js +// Normalizes octave-shifted QWERTY notes back into the spreadnob rack range. + +autowatch = 1; +inlets = 3; +outlets = 2; + +var low = 48; +var high = 62; +var lockedShift = 0; +var shiftKnown = false; +var lastRaw = null; +var lastNormalized = null; + +function clamp(n, lo, hi) { + return Math.max(lo, Math.min(hi, n)); +} + +function rounded(n) { + return Math.round(Number(n) || 0); +} + +function duplicateWidth() { + return Math.max(0, (high - low) - 11); +} + +function isAmbiguousNormalized(note) { + var off = rounded(note) - low; + var dup = duplicateWidth(); + if (dup <= 0) return false; + return off < dup || off >= 12; +} + +function getCandidates(raw) { + var candidates = []; + for (var shift = -8; shift <= 8; shift++) { + var normalized = raw - shift * 12; + if (normalized >= low && normalized <= high) { + candidates.push({ + shift: shift, + normalized: normalized, + ambiguous: isAmbiguousNormalized(normalized), + }); + } + } + return candidates; +} + +function chooseClosestShift(candidates, preferredShift) { + var best = candidates[0]; + var bestShiftDistance = Math.abs(best.shift - preferredShift); + var bestNormalizedDistance = lastNormalized === null + ? Math.abs(best.normalized - low) + : Math.abs(best.normalized - lastNormalized); + + for (var i = 1; i < candidates.length; i++) { + var candidate = candidates[i]; + var shiftDistance = Math.abs(candidate.shift - preferredShift); + var normalizedDistance = lastNormalized === null + ? Math.abs(candidate.normalized - low) + : Math.abs(candidate.normalized - lastNormalized); + + if (shiftDistance < bestShiftDistance) { + best = candidate; + bestShiftDistance = shiftDistance; + bestNormalizedDistance = normalizedDistance; + continue; + } + + if (shiftDistance === bestShiftDistance && normalizedDistance < bestNormalizedDistance) { + best = candidate; + bestShiftDistance = shiftDistance; + bestNormalizedDistance = normalizedDistance; + } + } + + return best; +} + +function resolve(raw) { + var candidates = getCandidates(raw); + if (!candidates.length) { + var fallbackShift = shiftKnown ? lockedShift : rounded((raw - low) / 12); + return { + raw: raw, + normalized: clamp(raw - fallbackShift * 12, low, high), + shift: fallbackShift, + locked: shiftKnown, + ambiguous: 1, + }; + } + + var selected = null; + + if (shiftKnown) { + for (var i = 0; i < candidates.length; i++) { + if (candidates[i].shift === lockedShift) { + selected = candidates[i]; + break; + } + } + } + + if (!selected) { + var unambiguous = []; + for (var j = 0; j < candidates.length; j++) { + if (!candidates[j].ambiguous) unambiguous.push(candidates[j]); + } + + if (unambiguous.length === 1) { + selected = unambiguous[0]; + lockedShift = selected.shift; + shiftKnown = true; + } else if (candidates.length === 1) { + selected = candidates[0]; + } else { + selected = chooseClosestShift(candidates, shiftKnown ? lockedShift : 0); + } + } + + if (!selected.ambiguous) { + lockedShift = selected.shift; + shiftKnown = true; + } + + return { + raw: raw, + normalized: selected.normalized, + shift: selected.shift, + locked: shiftKnown ? 1 : 0, + ambiguous: candidates.length > 1 && selected.ambiguous ? 1 : 0, + }; +} + +function emitState(state) { + outlet(0, state.normalized); + outlet(1, state.raw, state.normalized, state.shift, state.locked, state.ambiguous); +} + +function note(value) { + var raw = rounded(value); + var state = resolve(raw); + lastRaw = raw; + lastNormalized = state.normalized; + emitState(state); +} + +function setlow(value) { + low = rounded(value); + if (high < low) high = low; +} + +function sethigh(value) { + high = rounded(value); + if (high < low) low = high; +} + +function clearlock() { + lockedShift = 0; + shiftKnown = false; +} + +function loadbang() { + clearlock(); +} + +function msg_int(value) { + if (inlet === 0) note(value); + if (inlet === 1) setlow(value); + if (inlet === 2) sethigh(value); +} + +function list() { + var args = arrayfromargs(arguments); + if (!args.length) return; + msg_int(args[0]); +} + +function anything() { + var args = arrayfromargs(arguments); + if (messagename === "note" && args.length) note(args[0]); + if (messagename === "low" && args.length) setlow(args[0]); + if (messagename === "high" && args.length) sethigh(args[0]); + if (messagename === "reset" || messagename === "clearlock") clearlock(); +} diff --git a/system/netlify/functions/index.mjs b/system/netlify/functions/index.mjs index 4ed74ec3ac..554e2a8bcd 100644 --- a/system/netlify/functions/index.mjs +++ b/system/netlify/functions/index.mjs @@ -939,11 +939,20 @@ async function fun(event, context) { // 🎛️ Spreadnob bridge — called directly by M4L script commands window.acSnNote = function(n) { send({ type: "spreadnob:note", content: { note: n } }); }; window.acSnTarget = function(name) { send({ type: "spreadnob:target", content: { name: name } }); }; - window.acSnValue = function(v) { send({ type: "spreadnob:value", content: { value: v } }); }; - window.acSnActive = function(v) { send({ type: "spreadnob:active", content: { active: v } }); }; - window.acSnMin = function(v) { send({ type: "spreadnob:min", content: { min: v } }); }; - window.acSnMax = function(v) { send({ type: "spreadnob:max", content: { max: v } }); }; - window.acSnReady = function() { send({ type: "spreadnob:ready", content: {} }); }; + window.acSnValue = function(v) { send({ type: "spreadnob:value", content: { value: v } }); }; + window.acSnActive = function(v) { send({ type: "spreadnob:active", content: { active: v } }); }; + window.acSnMin = function(v) { send({ type: "spreadnob:min", content: { min: v } }); }; + window.acSnMax = function(v) { send({ type: "spreadnob:max", content: { max: v } }); }; + window.acSnState = function(raw, note, shift, locked, ambiguous) { + send({ + type: "spreadnob:state", + content: { raw: raw, note: note, shift: shift, locked: locked, ambiguous: ambiguous } + }); + }; + window.acSnQwertyRange = function(low, high) { + send({ type: "spreadnob:qwerty-range", content: { low: low, high: high } }); + }; + window.acSnReady = function() { send({ type: "spreadnob:ready", content: {} }); }; // Signal to M4L that we're ready if (window.max) { diff --git a/system/public/aesthetic.computer/disks/ableton.mjs b/system/public/aesthetic.computer/disks/ableton.mjs index 9a72b50651..186a34f5db 100644 --- a/system/public/aesthetic.computer/disks/ableton.mjs +++ b/system/public/aesthetic.computer/disks/ableton.mjs @@ -15,6 +15,23 @@ let customPieceIndex = 0; let customBusy = null; let customMessage = ""; +const FEATURED_DOWNLOADS = [ + { + id: "featured-spreadnob-clean", + label: "spreadnob clean", + piece: "spreadnob-clean", + fileName: "AC 🎹 spreadnob-clean (aesthetic.computer).amxd", + blurb: "main version - compact and octave-aware", + }, + { + id: "featured-spreadnob", + label: "spreadnob rack", + piece: "spreadnob", + fileName: "AC 🎹 spreadnob (aesthetic.computer).amxd", + blurb: "expanded rack view with the full module layout", + }, +]; + const { sin, cos, floor, abs, max } = Math; function stripEmoji(str = "") { @@ -273,6 +290,14 @@ function drawButton({ ink, box }, region, label, colors, hovered) { ink(...text).write(label, { x: x + 4, y: y + 3 }); } +async function downloadFeaturedDevice(featured, download) { + const assetUrl = `https://assets.aesthetic.computer/m4l/${encodeURIComponent(featured.fileName)}`; + const res = await fetch(assetUrl); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const buf = await res.arrayBuffer(); + download(featured.fileName, new Uint8Array(buf), { sharing: true }); +} + export function boot({ params, needsPaint }) { needsPaintRef = needsPaint; if (params?.[0]) setCustomPiece(params[0]); @@ -372,6 +397,33 @@ function paint({ wipe, ink, screen, box, line, circle, plot, dark }) { y += 8; + ink(...(dark ? [24, 19, 33] : [247, 236, 244])).box(0, y - 4, width, 50); + ink(255, 118, 184).write("Featured Downloads", { x: margin, y }); + y += 12; + + for (let i = 0; i < FEATURED_DOWNLOADS.length; i++) { + const featured = FEATURED_DOWNLOADS[i]; + const rowY = y + i * 16; + const openFeaturedBtn = { id: `${featured.id}-open`, x: margin, y: rowY, w: 58, h: 14 }; + const downloadFeaturedBtn = { id: `${featured.id}-download`, x: margin + 64, y: rowY, w: 84, h: 14 }; + + registerRegion(openFeaturedBtn.id, openFeaturedBtn.x, openFeaturedBtn.y, openFeaturedBtn.w, openFeaturedBtn.h, { + type: "open-piece", + piece: featured.piece, + }); + registerRegion(downloadFeaturedBtn.id, downloadFeaturedBtn.x, downloadFeaturedBtn.y, downloadFeaturedBtn.w, downloadFeaturedBtn.h, { + type: "download-featured", + featured, + }); + + drawButton({ ink, box }, openFeaturedBtn, "piece", btn.alt, hoverId === openFeaturedBtn.id); + drawButton({ ink, box }, downloadFeaturedBtn, "download", btn.primary, hoverId === downloadFeaturedBtn.id); + ink(fg).write(featured.label, { x: margin + 154, y: rowY + 1 }); + ink(dim).write(featured.blurb, { x: margin + 154, y: rowY + 8 }); + } + + y += FEATURED_DOWNLOADS.length * 16 + 8; + if (loading) { const dots = ".".repeat((floor(frame / 15) % 3) + 1); ink(dim).write("Loading published devices" + dots, { x: margin, y }); @@ -521,6 +573,27 @@ export async function act({ event: e, pen, sound, download, jump, needsPaint }) return; } + if (action.type === "download-featured") { + const featured = action.featured; + playClick(sound); + customBusy = `Downloading ${featured.label}...`; + customMessage = ""; + needsPaint?.(); + + try { + await downloadFeaturedDevice(featured, download); + customMessage = `Downloaded ${featured.fileName}`; + } catch (err) { + console.error("Featured download failed:", err); + playError(sound); + customMessage = `Download failed: ${err.message}`; + } finally { + customBusy = null; + needsPaint?.(); + } + return; + } + if (action.type === "build-offline" || action.type === "build-online") { if (customBusy) return; diff --git a/system/public/aesthetic.computer/disks/spreadnob.mjs b/system/public/aesthetic.computer/disks/spreadnob.mjs index 661c65afc2..bf040787e0 100644 --- a/system/public/aesthetic.computer/disks/spreadnob.mjs +++ b/system/public/aesthetic.computer/disks/spreadnob.mjs @@ -1,6 +1,5 @@ // Spreadnob, 2026.03.31 // AC-native UI for the Ableton spreadnob device. -// Big knob display — white keys A..L map linearly across the parameter range. // M4L → HTML bridge (acSn*) → bios send → disk → sound.spreadnob import { getNoteColorWithOctave } from "../lib/note-colors.mjs"; @@ -8,39 +7,33 @@ import { getNoteColorWithOctave } from "../lib/note-colors.mjs"; const FONT = "YWFTProcessing-Regular"; const MINI_FONT = "MatrixChunky8"; -const KEY_LABELS = ["A", "S", "D", "F", "G", "H", "J", "K", "L"]; -const KEY_COUNT = KEY_LABELS.length; -const WHITE_OFFSETS = [0, 2, 4, 5, 7, 9, 11, 12, 14]; -const PITCH_CLASSES = ["c","c#","d","d#","e","f","f#","g","g#","a","a#","b"]; -const DEFAULT_BASE = 48; +const QWERTY_KEYS = ["A", "W", "S", "E", "D", "F", "T", "G", "Y", "H", "U", "J", "K", "O", "L"]; +const BLACK_KEY_INDICES = new Set([1, 3, 6, 8, 10, 13]); +const PITCH_CLASSES = ["c", "c#", "d", "d#", "e", "f", "f#", "g", "g#", "a", "a#", "b"]; -const ARC_START = 210; -const ARC_END = -30; +const DEFAULT_QWERTY_LOW = 48; +const DEFAULT_QWERTY_HIGH = 62; +const ARC_START = 214; +const ARC_END = -34; const ARC_SWEEP = ARC_START - ARC_END; let active = true; let target = ""; let value = null; let currentNote = null; -let base = DEFAULT_BASE; +let rawNote = null; +let qwertyLow = DEFAULT_QWERTY_LOW; +let qwertyHigh = DEFAULT_QWERTY_HIGH; +let qwertyShift = 0; +let qwertyLocked = false; +let qwertyAmbiguous = false; let paramMin = 0; let paramMax = 1; let flash = 0; let noteHits = 0; -let lastSnNote = null; function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); } function deg2rad(d) { return (d * Math.PI) / 180; } -function midiForKey(i) { return base + WHITE_OFFSETS[i]; } - -function whiteKeyIndex(midi) { - if (!Number.isFinite(midi)) return -1; - return WHITE_OFFSETS.indexOf(midi - base); -} - -function valueForIndex(i) { - return (i / (KEY_COUNT - 1)) * (paramMax - paramMin) + paramMin; -} function noteLabel(midi) { if (!Number.isFinite(midi)) return "--"; @@ -49,18 +42,28 @@ function noteLabel(midi) { } function noteColor(midi) { - if (!Number.isFinite(midi)) return [190, 190, 190]; + if (!Number.isFinite(midi)) return [180, 180, 185]; const pitch = PITCH_CLASSES[((midi % 12) + 12) % 12]; return getNoteColorWithOctave(pitch, Math.floor(midi / 12) - 1); } -function angleForIndex(i) { - return ARC_START - (i / (KEY_COUNT - 1)) * ARC_SWEEP; +function qwertyMidiForIndex(i) { + return qwertyLow + i; +} + +function qwertyIndex(note) { + if (!Number.isFinite(note)) return -1; + const idx = Math.round(note - qwertyLow); + return idx >= 0 && idx < QWERTY_KEYS.length ? idx : -1; +} + +function angleForProgress(t) { + return ARC_START - clamp(t, 0, 1) * ARC_SWEEP; } function angleForValue(v) { - if (paramMax === paramMin) return ARC_START; - return ARC_START - clamp((v - paramMin) / (paramMax - paramMin), 0, 1) * ARC_SWEEP; + if (!Number.isFinite(v) || paramMax === paramMin) return ARC_START; + return angleForProgress((v - paramMin) / (paramMax - paramMin)); } function arcXY(cx, cy, r, deg) { @@ -68,18 +71,62 @@ function arcXY(cx, cy, r, deg) { return [cx + r * Math.cos(rad), cy - r * Math.sin(rad)]; } -let simCount = 0; -let dawExists = false; -let dawKeys = ""; +function formatShift(shift) { + if (!Number.isFinite(shift) || shift === 0) return "DEFAULT"; + return `${shift > 0 ? "+" : ""}${shift} OCT`; +} -function boot({ needsPaint }) { needsPaint(); } +function getLayout(w, h) { + const pad = clamp(Math.round(Math.min(w, h) * 0.045), 6, 12); + const headerH = clamp(Math.round(h * 0.18), 22, 30); + const footerH = clamp(Math.round(h * 0.34), 48, 64); + const mainY = headerH + 4; + const mainH = Math.max(42, h - headerH - footerH - pad - 2); + const leftW = Math.max(140, Math.round(w * 0.46)); + const rightX = pad + leftW + pad; + const rightW = Math.max(92, w - rightX - pad); + const valueH = clamp(Math.round(mainH * 0.42), 22, 34); + const statusH = clamp(Math.round(mainH * 0.34), 18, 30); + + return { + pad, + headerH, + footerH, + mainY, + mainH, + leftW, + rightX, + rightW, + dialCx: pad + Math.round(leftW * 0.5), + dialCy: mainY + Math.round(mainH * 0.58), + outerR: Math.max(28, Math.round(Math.min(leftW * 0.31, mainH * 0.56))), + targetX: rightX, + targetY: mainY, + targetH: Math.max(20, mainH - valueH - statusH - 10), + valueX: rightX, + valueY: mainY + Math.max(20, mainH - valueH - statusH - 10) + 4, + valueH, + metaGap: 4, + statusH, + footerY: h - footerH, + }; +} + +function handleNote(normalizedNote) { + currentNote = Number.isFinite(normalizedNote) ? normalizedNote : null; + flash = active ? 1 : 0.45; + noteHits++; +} + +function boot({ needsPaint }) { + needsPaint(); +} function sim({ sound, needsPaint }) { - simCount++; let dirty = false; - // Read spreadnob data from sound.daw (stored as sn* props on persistentDawState) const daw = sound?.daw; - dawExists = !!daw; + let normalizedHandled = false; + if (daw) { if (daw.snActive !== undefined && daw.snActive !== null) { active = !!Number(daw.snActive); @@ -109,134 +156,215 @@ function sim({ sound, needsPaint }) { daw.snValue = null; dirty = true; } - if (daw.snNote !== undefined && daw.snNote !== null) { - const n = Number(daw.snNote); - if (n !== lastSnNote) { - currentNote = Number.isFinite(n) ? n : null; - lastSnNote = n; - const idx = whiteKeyIndex(currentNote); - if (idx >= 0) { - value = valueForIndex(idx); - noteHits++; - flash = active ? 1 : 0.45; - } - dirty = true; + if (daw.snQwertyLow !== undefined && daw.snQwertyLow !== null) { + const n = Number(daw.snQwertyLow); + if (Number.isFinite(n)) qwertyLow = n; + daw.snQwertyLow = null; + dirty = true; + } + if (daw.snQwertyHigh !== undefined && daw.snQwertyHigh !== null) { + const n = Number(daw.snQwertyHigh); + if (Number.isFinite(n)) qwertyHigh = n; + daw.snQwertyHigh = null; + dirty = true; + } + if ( + daw.snRawNote !== undefined || daw.snNormalizedNote !== undefined || + daw.snShift !== undefined || daw.snLocked !== undefined || daw.snAmbiguous !== undefined + ) { + const raw = Number(daw.snRawNote); + const normalized = Number(daw.snNormalizedNote); + const shift = Number(daw.snShift); + rawNote = Number.isFinite(raw) ? raw : rawNote; + if (Number.isFinite(normalized)) { + handleNote(normalized); + normalizedHandled = true; } + if (Number.isFinite(shift)) qwertyShift = shift; + if (daw.snLocked !== undefined && daw.snLocked !== null) qwertyLocked = !!Number(daw.snLocked); + if (daw.snAmbiguous !== undefined && daw.snAmbiguous !== null) qwertyAmbiguous = !!Number(daw.snAmbiguous); + daw.snRawNote = null; + daw.snNormalizedNote = null; + daw.snShift = null; + daw.snLocked = null; + daw.snAmbiguous = null; + dirty = true; } - if (simCount % 60 === 0) { - dawKeys = Object.keys(daw).filter(k => k.startsWith("sn") && daw[k] !== null).join(","); + if (daw.snNote !== undefined && daw.snNote !== null) { + const normalized = Number(daw.snNote); + if (Number.isFinite(normalized) && !normalizedHandled) handleNote(normalized); + daw.snNote = null; + dirty = true; } } if (flash > 0) { - flash *= 0.82; + flash *= 0.84; if (flash < 0.025) flash = 0; dirty = true; } - needsPaint(); // Always repaint for debug + + if (dirty) needsPaint(); } function paint({ wipe, ink, screen }) { const w = screen.width; const h = screen.height; - const flashAlpha = Math.round(100 * flash); - const hitIdx = whiteKeyIndex(currentNote); - - const cx = Math.round(w / 2); - const cy = Math.round(h * 0.54); - const outerR = Math.min(w * 0.14, h * 0.32); - const trackR = outerR - 2; - const tickOuter = outerR + 4; - const tickInner = outerR; - const labelR = outerR + 14; - - wipe(8, 10, 12); - ink(active ? 16 : 30, active ? 28 : 14, active ? 18 : 20).box(0, 0, w, h); + const layout = getLayout(w, h); + const qIndex = qwertyIndex(currentNote); + const valAngle = angleForValue(value); + const targetName = target || "click a knob"; + const actualLow = qwertyLow + qwertyShift * 12; + const actualHigh = qwertyHigh + qwertyShift * 12; + const octaveWindow = `${noteLabel(actualLow)}..${noteLabel(actualHigh)}`; + const mappedText = currentNote === null ? "--" : noteLabel(currentNote); + const rawText = rawNote === null ? "--" : noteLabel(rawNote); + const valueText = value === null ? "--" : value.toFixed(3); + const flashAlpha = Math.round(95 * flash); + const statusText = rawNote === null + ? "click a knob, then play qwerty" + : !qwertyLocked && qwertyAmbiguous + ? "octave unclear - tap E, F, G, A, or B to lock it in" + : rawNote !== null && currentNote !== null && rawNote !== currentNote + ? `${formatShift(qwertyShift)} normalized - any qwerty octave works now` + : "qwerty octave locked and mapped"; + const statusTint = !qwertyLocked && qwertyAmbiguous + ? [255, 214, 98] + : rawNote !== null && currentNote !== null && rawNote !== currentNote + ? [102, 255, 212] + : [255, 118, 182]; + + wipe(6, 8, 12); + ink(14, 16, 22).box(0, 0, w, h); + ink(255, 94, 164, 26).box(0, 0, w, layout.headerH); + ink(70, 255, 220, 14).box(0, layout.headerH - 2, w, h - layout.headerH + 2); + ink(255, 212, 96, 12).box(0, h - layout.footerH, w, layout.footerH); if (flashAlpha > 0) { - ink(active ? 80 : 200, active ? 200 : 80, 120, flashAlpha).box(0, 0, w, h); + ink(statusTint[0], statusTint[1], statusTint[2], flashAlpha).box(0, 0, w, h); } - // Track arc - const arcSteps = 40; - const arcStep = (ARC_START - ARC_END) / arcSteps; + ink(255, 126, 188).box(layout.pad, layout.pad, w - layout.pad * 2, h - layout.pad * 2); + ink(40, 20, 36).box(layout.pad + 1, layout.pad + 1, w - layout.pad * 2 - 2, h - layout.pad * 2 - 2); + ink(255, 120, 184, 92).line(layout.pad + 4, layout.headerH, w - layout.pad - 4, layout.headerH); + ink(102, 255, 212, 64).line(layout.pad + 4, h - layout.footerH - 2, w - layout.pad - 4, h - layout.footerH - 2); + + ink(255, 126, 188).write("spreadnob", { x: layout.pad + 6, y: layout.pad + 2 }, undefined, undefined, false, FONT); + ink(114, 242, 224).write("aesthetic.computer", { x: layout.pad + 7, y: layout.pad + 12 }, undefined, undefined, false, MINI_FONT); + + const pillFill = active ? [92, 255, 136] : [255, 92, 112]; + const pillW = 46; + const pillH = 14; + ink(...pillFill).box(w - layout.pad - pillW - 4, layout.pad + 4, pillW, pillH); + ink(8, 12, 10).write(active ? "ON" : "OFF", { x: w - layout.pad - pillW + 7, y: layout.pad + 6 }, undefined, undefined, false, MINI_FONT); + + ink(24, 16, 30).box(layout.pad + 8, layout.mainY + 2, layout.leftW - 14, layout.mainH - 4); + ink(255, 118, 182, 36).box(layout.pad + 8, layout.mainY + 2, layout.leftW - 14, 1); + + const arcSteps = 64; + const trackR = layout.outerR; for (let i = 0; i < arcSteps; i++) { - const a1 = ARC_END + i * arcStep; - const a2 = a1 + arcStep; - const [x1, y1] = arcXY(cx, cy, trackR, a1); - const [x2, y2] = arcXY(cx, cy, trackR, a2); - ink(45, 40, 55).line(Math.round(x1), Math.round(y1), Math.round(x2), Math.round(y2)); + const a1 = ARC_END + (i / arcSteps) * (ARC_START - ARC_END); + const a2 = ARC_END + ((i + 1) / arcSteps) * (ARC_START - ARC_END); + const [x1, y1] = arcXY(layout.dialCx, layout.dialCy, trackR, a1); + const [x2, y2] = arcXY(layout.dialCx, layout.dialCy, trackR, a2); + ink(56, 52, 70).line(Math.round(x1), Math.round(y1), Math.round(x2), Math.round(y2)); } - // Value arc if (value !== null) { - const valAngle = angleForValue(value); - const vr = active ? 110 : 255; - const vg = active ? 255 : 110; - const vb = active ? 150 : 170; const sweepStart = Math.min(valAngle, ARC_START); const sweepEnd = Math.max(valAngle, ARC_START); - const valSteps = Math.max(4, Math.round(Math.abs(sweepEnd - sweepStart) / 6)); - const valStep = (sweepEnd - sweepStart) / valSteps; - for (let i = 0; i < valSteps; i++) { - const a1 = sweepStart + i * valStep; - const a2 = a1 + valStep; - const [x1, y1] = arcXY(cx, cy, trackR, a1); - const [x2, y2] = arcXY(cx, cy, trackR, a2); - ink(vr, vg, vb).line(Math.round(x1), Math.round(y1), Math.round(x2), Math.round(y2)); + const valueSteps = Math.max(4, Math.round(Math.abs(sweepEnd - sweepStart) / 5)); + for (let i = 0; i < valueSteps; i++) { + const a1 = sweepStart + (i / valueSteps) * (sweepEnd - sweepStart); + const a2 = sweepStart + ((i + 1) / valueSteps) * (sweepEnd - sweepStart); + const [x1, y1] = arcXY(layout.dialCx, layout.dialCy, trackR, a1); + const [x2, y2] = arcXY(layout.dialCx, layout.dialCy, trackR, a2); + ink(active ? 94 : 255, active ? 255 : 124, active ? 210 : 176).line(Math.round(x1), Math.round(y1), Math.round(x2), Math.round(y2)); } - const [vx, vy] = arcXY(cx, cy, trackR, valAngle); - ink(255, 255, 255).box(Math.round(vx) - 2, Math.round(vy) - 2, 5, 5); + const [vx, vy] = arcXY(layout.dialCx, layout.dialCy, trackR, valAngle); + ink(255, 250, 255).box(Math.round(vx) - 3, Math.round(vy) - 3, 7, 7); } - // Tick marks and key labels - for (let i = 0; i < KEY_COUNT; i++) { - const angle = angleForIndex(i); - const [ox, oy] = arcXY(cx, cy, tickOuter, angle); - const [ix, iy] = arcXY(cx, cy, tickInner, angle); - const [lx, ly] = arcXY(cx, cy, labelR, angle); - - const isCurrent = hitIdx === i; - const midi = midiForKey(i); - const rgb = noteColor(midi); - - const tr = isCurrent ? 255 : Math.round(rgb[0] * 0.7); - const tg = isCurrent ? 255 : Math.round(rgb[1] * 0.7); - const tb = isCurrent ? 255 : Math.round(rgb[2] * 0.7); - ink(tr, tg, tb).line(Math.round(ox), Math.round(oy), Math.round(ix), Math.round(iy)); - - const lr = isCurrent ? 255 : Math.round(rgb[0] * 0.55); - const lg = isCurrent ? 255 : Math.round(rgb[1] * 0.55); - const lb = isCurrent ? 255 : Math.round(rgb[2] * 0.55); - ink(lr, lg, lb).write( - KEY_LABELS[i], - { x: Math.round(lx) - 3, y: Math.round(ly) - 4 }, - undefined, undefined, false, MINI_FONT, - ); + for (let i = 0; i < QWERTY_KEYS.length; i++) { + const angle = angleForProgress(i / (QWERTY_KEYS.length - 1)); + const [mx, my] = arcXY(layout.dialCx, layout.dialCy, layout.outerR + 8, angle); + const rgb = noteColor(qwertyMidiForIndex(i)); + const hit = qIndex === i; + ink( + hit ? 255 : Math.round(rgb[0] * 0.85), + hit ? 255 : Math.round(rgb[1] * 0.85), + hit ? 255 : Math.round(rgb[2] * 0.85), + ).box(Math.round(mx) - (hit ? 2 : 1), Math.round(my) - (hit ? 2 : 1), hit ? 5 : 3, hit ? 5 : 3); } - // Center info - const targetName = target || "click a knob"; - ink(255, 190, 220).write(targetName, { x: cx - 28, y: cy - 12 }, undefined, 56, true, FONT); - - const valueText = value === null ? "--" : value.toFixed(2); - ink(active ? 130 : 255, active ? 255 : 130, active ? 170 : 190).write( - valueText, { x: cx - 16, y: cy + 2 }, undefined, undefined, false, FONT, + ink(255, 188, 228).write(targetName, { x: layout.targetX + 4, y: layout.targetY + 3 }, undefined, layout.rightW - 8, true, FONT); + ink(155, 150, 170).write("selected ableton knob", { x: layout.targetX + 4, y: layout.targetY + 13 }, undefined, layout.rightW - 8, true, MINI_FONT); + + ink(28, 16, 30).box(layout.valueX, layout.valueY, layout.rightW, layout.valueH); + ink(255, 110, 178, 80).box(layout.valueX, layout.valueY, layout.rightW, 1); + ink(active ? 102 : 255, active ? 255 : 130, active ? 212 : 170).write( + valueText, + { x: layout.valueX + 4, y: layout.valueY + 6 }, + undefined, + layout.rightW - 8, + true, + FONT, ); - // ON/OFF pill - const pFill = active ? [90, 220, 100] : [220, 70, 80]; - ink(...pFill).box(w - 36, 5, 30, 10); - ink(10, 16, 12).write(active ? "ON" : "OFF", { x: w - 32, y: 6 }, undefined, undefined, false, MINI_FONT); - - // Title - ink(200, 120, 170).write("spreadnob", { x: 6, y: 4 }, undefined, undefined, false, MINI_FONT); - - // Debug info - ink(255, 255, 0).write(`sim:${simCount} daw:${dawExists} keys:${dawKeys || "none"}`, { x: 6, y: h - 8 }, undefined, undefined, false, MINI_FONT); + const chipY = layout.valueY + layout.valueH + layout.metaGap; + const chipW = Math.floor((layout.rightW - 4) / 2); + const chipH = 14; + + ink(28, 18, 38).box(layout.rightX, chipY, chipW, chipH); + ink(28, 18, 38).box(layout.rightX + chipW + 4, chipY, chipW, chipH); + ink(255, 208, 108).write(rawText === mappedText ? mappedText : `${rawText}->${mappedText}`, { x: layout.rightX + 2, y: chipY + 3 }, undefined, chipW - 4, true, MINI_FONT); + ink(102, 255, 212).write(formatShift(qwertyShift), { x: layout.rightX + chipW + 6, y: chipY + 3 }, undefined, chipW - 8, true, MINI_FONT); + + const statusY = chipY + chipH + layout.metaGap; + ink(20, 26, 28).box(layout.rightX, statusY, layout.rightW, layout.statusH); + ink(statusTint[0], statusTint[1], statusTint[2], 88).box(layout.rightX, statusY, layout.rightW, 1); + ink(...statusTint).write(statusText, { x: layout.rightX + 4, y: statusY + 4 }, undefined, layout.rightW - 8, true, MINI_FONT); + + const footerX = layout.pad + 8; + const footerY = layout.footerY + 7; + const footerW = w - layout.pad * 2 - 16; + const footerH = layout.footerH - 14; + const gap = 2; + const keyW = Math.max(10, Math.floor((footerW - gap * (QWERTY_KEYS.length - 1)) / QWERTY_KEYS.length)); + const totalW = keyW * QWERTY_KEYS.length + gap * (QWERTY_KEYS.length - 1); + const keyX0 = footerX + Math.round((footerW - totalW) / 2); + + ink(18, 18, 24).box(footerX, footerY, footerW, footerH); + ink(255, 122, 186, 48).box(footerX, footerY, footerW, 1); + ink(255, 212, 108).write(`QWERTY ${octaveWindow}`, { x: footerX + 4, y: footerY - 7 }, undefined, undefined, false, MINI_FONT); + ink(114, 242, 224).write(`hits ${noteHits}`, { x: footerX + footerW - 48, y: footerY - 7 }, undefined, undefined, false, MINI_FONT); + + for (let i = 0; i < QWERTY_KEYS.length; i++) { + const keyX = keyX0 + i * (keyW + gap); + const black = BLACK_KEY_INDICES.has(i); + const keyY = footerY + (black ? 3 : 0); + const keyH = black ? footerH - 18 : footerH - 6; + const hit = qIndex === i; + const rgb = noteColor(qwertyMidiForIndex(i)); + const fill = hit + ? [255, 112, 182] + : black + ? [Math.round(rgb[0] * 0.42), Math.round(rgb[1] * 0.42), Math.round(rgb[2] * 0.42)] + : [Math.round(rgb[0] * 0.72), Math.round(rgb[1] * 0.72), Math.round(rgb[2] * 0.72)]; + + ink(...fill).box(keyX, keyY, keyW, keyH); + ink(255, hit ? 248 : 255, hit ? 252 : 255, hit ? 160 : 28).box(keyX, keyY, keyW, 1); + ink(black ? 255 : 30, black ? 212 : 34, black ? 230 : 44).write(noteLabel(qwertyMidiForIndex(i)), { x: keyX + 1, y: keyY + 1 }, undefined, keyW - 2, true, MINI_FONT); + ink(248, 244, 250).write(QWERTY_KEYS[i], { x: keyX + 1, y: keyY + keyH - 12 }, undefined, keyW - 2, true, FONT); + + if (hit) { + ink(255, 246, 252).line(keyX, keyY + keyH, keyX + keyW, keyY + keyH); + } + } } -function act({ event, screen, needsPaint }) { +function act({ needsPaint }) { needsPaint(); } @@ -245,7 +373,7 @@ function leave() {} function meta() { return { title: "Spreadnob", - desc: "Spread any Ableton knob across your QWERTY white keys.", + desc: "Spread any Ableton knob across your QWERTY keys.", }; } diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs index 0546a5f117..61887cb146 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -10096,13 +10096,26 @@ async function makeFrame({ data: { type, content } }) { persistentDawState.snMin = content.min; return; } - if (type === "spreadnob:max") { - persistentDawState.snMax = content.max; - return; - } - - // 🎸 Pedal messages (for audio effect visualization) - if (type === "pedal:peak") { + if (type === "spreadnob:max") { + persistentDawState.snMax = content.max; + return; + } + if (type === "spreadnob:state") { + persistentDawState.snRawNote = content.raw; + persistentDawState.snNormalizedNote = content.note; + persistentDawState.snShift = content.shift; + persistentDawState.snLocked = content.locked; + persistentDawState.snAmbiguous = content.ambiguous; + return; + } + if (type === "spreadnob:qwerty-range") { + persistentDawState.snQwertyLow = content.low; + persistentDawState.snQwertyHigh = content.high; + return; + } + + // 🎸 Pedal messages (for audio effect visualization) + if (type === "pedal:peak") { // Forward to the piece's receive function if it exists if (typeof receive === "function") { receive({ type: "pedal:peak", peak: content.peak }); -- 2.51.2