From 2f0d5841c68f11f1c99cebc315662a6e70a6fa3f Mon Sep 17 00:00:00 2001 From: khannurien Date: Sat, 15 Aug 2026 08:48:05 +0000 Subject: [PATCH] Add the Max for Live device that reads Live's scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build step 2 of the brief: an M4L device that observes root_note / scale_name / scale_intervals in the Live Object Model and prints the QWERTY keys the scale maps to. No lighting yet — that is step 3. LiveAPI only exists inside Max's js objects, so max/scale-observer.js (plain ES5, the one uncompiled file here) observes the LOM and forwards raw values as flat messages; src/max/device.ts resolves them, keeping every decision in TypeScript and testable off the hardware. Follows the Song's scale by default and the selected clip's on request, since the brief left that decision open. scale_intervals only exists from Live 12.1, so scale_name resolves against a table of Live's built-ins as a fallback; reported intervals always win. Ships a .maxpat rather than an .amxd because an .amxd has to be created from inside Live — the README has the paste-into-a-new-device steps. Co-Authored-By: Claude Opus 5 --- README.md | 110 +++++++++++- max/scale-device.js | 30 ++++ max/scale-lighting.maxpat | 317 ++++++++++++++++++++++++++++++++++ max/scale-observer.js | 248 +++++++++++++++++++++++++++ package.json | 2 +- src/live.ts | 264 ++++++++++++++++++++++++++++ src/max/device.ts | 140 +++++++++++++++ test/live.test.ts | 352 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 1454 insertions(+), 9 deletions(-) create mode 100644 max/scale-device.js create mode 100644 max/scale-lighting.maxpat create mode 100644 max/scale-observer.js create mode 100644 src/live.ts create mode 100644 src/max/device.ts create mode 100644 test/live.test.ts diff --git a/README.md b/README.md index 01a27ac..b58a311 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,15 @@ Light the notes of a musical scale on a **SteelSeries Apex 7** using the GameSense SDK, mapped to Ableton Live's computer MIDI keyboard layout. -This is **build step 1** of [`apex7-ableton-scale-lighting.md`](./apex7-ableton-scale-lighting.md): -a standalone script that talks to GameSense directly. Live/Max for Live is not -involved yet — you pass the scale on the command line. +Two of the four build steps in +[`apex7-ableton-scale-lighting.md`](./apex7-ableton-scale-lighting.md) are done: + +1. **Done** — a standalone CLI that lights a scale via GameSense. You pass the + scale on the command line; Live is not involved. +2. **Done** — a Max for Live device that reads the scale from Live's Object + Model and prints the computed key set to the Max console. No lighting yet. +3. Next — wire the two together so the board follows Live. +4. Then — polish. TypeScript, no runtime dependencies. Node 16+. @@ -94,12 +100,83 @@ node bin/apex7-scale.js --address 127.0.0.1:51000 --root F# --scale dorian # t `--address` (or `GAMESENSE_ADDRESS`) skips `coreProps.json` discovery entirely, so this works on Linux too. `--dry-run` prints the payloads without sending. -Unit tests for the scale math, key mapping, handler shape and frame colors: +Unit tests for the scale math, key mapping, handler shape and frame colors — +plus the LOM parsing and the device's message handling, driven through a fake +Max: ```bash npm test ``` +## The Max for Live device (step 2) + +`max/` holds a device that watches Live's scale and prints the keys it computes. +It does not light anything yet — that is step 3. Its job is to prove the LOM +half of the chain against the mapping table in the brief. + +### Installing it + +The repo ships a `.maxpat` rather than a `.amxd`, because an `.amxd` has to be +born inside Live: + +1. In Live, drag a **Max MIDI Effect** onto a MIDI track and click its edit + (pencil) button to open Max. +2. Open `max/scale-lighting.maxpat` in a text editor, copy all of it, then in + the Max device window: **Edit → Select All**, **Delete**, **Edit → Paste**. + Max pastes the whole patcher, `midiin`/`midiout` passthrough included. +3. **File → Save**, and save the device as `max/Ableton Scale Lighting.amxd` — + in *this* folder, so `js` and `node.script` find their scripts next to it. +4. Make sure `dist/` is built (`npm install` in the repo root). `node.script` + loads `dist/src/max/device.js`. + +Add this repo's folder in Live's browser (**Add Folder…**) to load the device +from there in future sets. + +### What it prints + +Open the Max console (Cmd/Ctrl-Shift-M). Change the scale in Live's control bar +and each change prints: + +``` +C Major — from the song + intervals: 0 2 4 5 7 9 11 + notes: C D E F G A B + keys: A S D F G H J K + A W S E D F T G Y H U J K + * . * . * * . * . * . * * +``` + +The last two lines are every note key in keyboard order with a mark under the +lit ones — hold that against the table in the brief and the mapping is verified. +The device also sends `notes …`, `keys …` and `scale …` out `node.script`'s +outlet, so you can wire them to a `live.comment` if you want them on the device +face. + +### Which scale it follows + +Live 12 puts a scale on the Song *and* on each clip, so the device supports +both. Click the message boxes in the patcher: + +- `source song` (default) — the control bar's global scale. +- `source clip` — the scale of the clip currently open in the Detail view, + falling back to the Song's when no clip is selected or the clip has no scale. +- `refresh` — re-read and re-send. +- `debug 1` / `verbose 1` — log every LOM read / every commit, not just changes. + +`scale_intervals` only exists from Live 12.1. On earlier versions the device +resolves Live's `scale_name` against a table of Live's built-in scales +(`LIVE_SCALE_INTERVALS` in `src/live.ts`); when Live does send intervals, they +win, so a scale you edited in Live is followed exactly. + +### Why the split between `js` and `node.script` + +`LiveAPI` exists only inside Max's `js`/`v8` objects — Node for Max cannot see +it. So `max/scale-observer.js` (plain ES5, the one uncompiled file in the repo) +observes the LOM and forwards raw values as flat messages, and +`src/max/device.ts` resolves them. That keeps every decision in TypeScript, and +means step 3 only has to call `ScaleLighting` from a place that already has the +scale. + ## Layout ``` @@ -112,18 +189,28 @@ src/gamesense.ts GameSense REST client (coreProps discovery, heartbeat, c src/lighting.ts handler + frame construction src/errors.ts `catch (err: unknown)` helpers src/index.ts ScaleLighting — the API step 3 will call from Node for Max +src/live.ts LOM values -> a resolved scale, and how to print it +src/max/device.ts the Node for Max device: messages in, key set out +max/scale-lighting.maxpat the M4L patcher (paste into a device created in Live) +max/scale-observer.js the `js` object that observes the Live Object Model (ES5) +max/scale-device.js `node.script` launcher — runs dist/src/max/device.js tools/fake-gamesense.ts terminal simulator of the GameSense server -test/logic.test.ts unit tests +test/logic.test.ts unit tests for the lighting half +test/live.test.ts unit tests for the Live half, with a fake Max dist/ compiled CommonJS + .d.ts (gitignored) ``` `tsconfig.json` emits CommonJS at ES2020, because Node for Max loads CJS on a Node version we do not control. -## Reusing this from Max for Live (step 3) +## What step 3 has to do -`src/index.ts` is the seam — no CLI concerns in it, and it ships type -declarations alongside the compiled JS: +Both halves now exist and neither knows about the other. Step 3 is joining them +inside `src/max/device.ts`: it already resolves a `LiveScale` on every `commit`, +so it needs to hold a `ScaleLighting`, start it on load, call +`showScale(scale.root, scale.intervals)` where it currently prints, and stop it +on `notifydeleted`. `src/index.ts` is that seam — no CLI concerns in it, and it +ships type declarations alongside the compiled JS: ```ts import { ScaleLighting } from 'steelseries-live-scale'; @@ -151,3 +238,10 @@ as `root_note` and `scale_intervals`. - **A key stays dark** — it may not be in `src/hid.ts`. Add its HID code there; keys not addressed by any handler go black in GameSense mode. - **`apex7-scale is not built yet`** — run `npm run build`. +- **The device prints nothing** — the Max console should show `scale: ready` on + load. If not, `node.script` never started: check `dist/src/max/device.js` + exists, and that the `.amxd` was saved in `max/` next to the two scripts. +- **`scale: no scale yet`** — `live.thisdevice` never banged, or Live has no + scale set. Click `refresh` in the patcher. +- **Nothing changes when you pick a clip** — the device follows the Song by + default; click `source clip`. diff --git a/max/scale-device.js b/max/scale-device.js new file mode 100644 index 0000000..9786276 --- /dev/null +++ b/max/scale-device.js @@ -0,0 +1,30 @@ +'use strict'; + +/** + * Launcher for the `node.script` object — the same trick as bin/apex7-scale.js. + * + * The device logic is TypeScript (`src/max/device.ts`); this file stays plain + * CommonJS because Node for Max runs it on a Node version we do not control, + * with no loader and no build step of its own. + * + * `max-api` is required *here*, in the folder Node for Max resolves modules + * from, and handed to the compiled module — which keeps that module importable + * (and testable) outside of Max. + */ + +const fs = require('fs'); +const path = require('path'); + +const Max = require('max-api'); + +const compiled = path.join(__dirname, '..', 'dist', 'src', 'max', 'device.js'); + +if (!fs.existsSync(compiled)) { + Max.post( + 'scale: not built yet — run `npm install` (or `npm run build`) in ' + + path.join(__dirname, '..'), + Max.POST_LEVELS.ERROR + ); +} else { + require(compiled).start(Max); +} diff --git a/max/scale-lighting.maxpat b/max/scale-lighting.maxpat new file mode 100644 index 0000000..4cd82d9 --- /dev/null +++ b/max/scale-lighting.maxpat @@ -0,0 +1,317 @@ +{ + "patcher" : { + "fileversion" : 1, + "appversion" : { + "major" : 8, + "minor" : 5, + "revision" : 6, + "architecture" : "x64", + "modernui" : 1 + } +, + "classnamespace" : "box", + "rect" : [ 100.0, 100.0, 760.0, 460.0 ], + "bglocked" : 0, + "openinpresentation" : 0, + "default_fontsize" : 12.0, + "default_fontface" : 0, + "default_fontname" : "Arial", + "gridonopen" : 1, + "gridsize" : [ 15.0, 15.0 ], + "gridsnaponopen" : 1, + "objectsnaponopen" : 1, + "statusbarvisible" : 2, + "toolbarvisible" : 1, + "lefttoolbarpinned" : 0, + "toptoolbarpinned" : 0, + "righttoolbarpinned" : 0, + "bottomtoolbarpinned" : 0, + "toolbars_unpinned_last_save" : 0, + "tallnewobj" : 0, + "boxanimatetime" : 200, + "enablehscroll" : 1, + "enablevscroll" : 1, + "devicewidth" : 0.0, + "description" : "", + "digest" : "", + "tags" : "", + "style" : "", + "subpatcher_template" : "", + "assistshowspatchername" : 0, + "boxes" : [ { + "box" : { + "id" : "obj-1", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 20.0, 15.0, 420.0, 20.0 ], + "fontsize" : 13.0, + "text" : "Ableton Scale Lighting — step 2: read the scale, print the keys" + } + + } +, { + "box" : { + "id" : "obj-2", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "linecount" : 3, + "patching_rect" : [ 20.0, 40.0, 480.0, 47.0 ], + "text" : "The js object is the only part that may touch the Live API; it forwards raw root_note / scale_name / scale_intervals to node.script, which resolves them and prints the QWERTY keys to the Max console." + } + + } +, { + "box" : { + "id" : "obj-3", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 3, + "outlettype" : [ "bang", "", "" ], + "patching_rect" : [ 20.0, 100.0, 105.0, 22.0 ], + "text" : "live.thisdevice" + } + + } +, { + "box" : { + "id" : "obj-4", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 131.0, 103.0, 190.0, 20.0 ], + "text" : "bang once the set is loaded" + } + + } +, { + "box" : { + "id" : "obj-5", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 20.0, 140.0, 80.0, 22.0 ], + "text" : "source song" + } + + } +, { + "box" : { + "id" : "obj-6", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 110.0, 140.0, 76.0, 22.0 ], + "text" : "source clip" + } + + } +, { + "box" : { + "id" : "obj-7", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 196.0, 140.0, 55.0, 22.0 ], + "text" : "refresh" + } + + } +, { + "box" : { + "id" : "obj-8", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 261.0, 140.0, 48.0, 22.0 ], + "text" : "debug 1" + } + + } +, { + "box" : { + "id" : "obj-9", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 20.0, 185.0, 155.0, 22.0 ], + "text" : "js scale-observer.js" + } + + } +, { + "box" : { + "id" : "obj-10", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 196.0, 185.0, 48.0, 22.0 ], + "text" : "status" + } + + } +, { + "box" : { + "id" : "obj-11", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 254.0, 185.0, 62.0, 22.0 ], + "text" : "verbose 1" + } + + } +, { + "box" : { + "id" : "obj-12", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 2, + "outlettype" : [ "", "" ], + "patching_rect" : [ 20.0, 230.0, 300.0, 22.0 ], + "text" : "node.script scale-device.js @autostart 1 @watch 1" + } + + } +, { + "box" : { + "id" : "obj-13", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 20.0, 275.0, 74.0, 22.0 ], + "text" : "print scale" + } + + } +, { + "box" : { + "id" : "obj-14", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "linecount" : 2, + "patching_rect" : [ 104.0, 272.0, 400.0, 33.0 ], + "text" : "Open the Max console (Cmd/Ctrl-Shift-M) to watch the scale change. node.script needs dist/ built: npm install in the repo root." + } + + } +, { + "box" : { + "id" : "obj-15", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 560.0, 103.0, 175.0, 20.0 ], + "text" : "MIDI passthrough" + } + + } +, { + "box" : { + "id" : "obj-16", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "int" ], + "patching_rect" : [ 560.0, 140.0, 48.0, 22.0 ], + "text" : "midiin" + } + + } +, { + "box" : { + "id" : "obj-17", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 560.0, 185.0, 55.0, 22.0 ], + "text" : "midiout" + } + + } + ], + "lines" : [ { + "patchline" : { + "destination" : [ "obj-9", 0 ], + "source" : [ "obj-3", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-9", 0 ], + "source" : [ "obj-5", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-9", 0 ], + "source" : [ "obj-6", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-9", 0 ], + "source" : [ "obj-7", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-9", 0 ], + "source" : [ "obj-8", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-12", 0 ], + "source" : [ "obj-9", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-12", 0 ], + "source" : [ "obj-10", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-12", 0 ], + "source" : [ "obj-11", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-13", 0 ], + "source" : [ "obj-12", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-17", 0 ], + "source" : [ "obj-16", 0 ] + } + + } + ], + "dependency_cache" : [ ], + "autosave" : 0 + } + +} diff --git a/max/scale-observer.js b/max/scale-observer.js new file mode 100644 index 0000000..072fad9 --- /dev/null +++ b/max/scale-observer.js @@ -0,0 +1,248 @@ +autowatch = 1; +inlets = 1; +outlets = 1; + +/** + * Build step 2: the only part of this project that touches the Live API. + * + * `LiveAPI` exists exclusively inside Max's `js`/`v8` objects — Node for Max + * cannot see it — so this script watches the LOM and forwards raw values out + * its outlet to `node.script scale-device.js`, which does the thinking. + * + * Plain ES5 on purpose: the `js` object runs an ES5 engine, so this is the one + * file in the repo that is neither TypeScript nor compiled. Keep it dumb. + * + * Messages in: + * bang start observing (wire this to live.thisdevice) + * source song read the Song's scale — the control bar's global scale + * source clip read the selected clip's scale, falling back to the Song + * refresh re-read and re-send everything + * debug 1 log each push to the Max console + * + * Messages out: reset, source, root, mode, name, intervals, commit. + */ + +var SONG_PROPS = ['root_note', 'scale_name', 'scale_mode']; +var CLIP_PROPS = ['root_note', 'scale_name']; +var CLIP_PATH = 'live_set view detail_clip'; +var COALESCE_MS = 30; + +var started = false; +var scaleSource = 'song'; +var logging = 0; +var songObservers = []; +var clipObservers = []; +var selectionObserver = null; +var pushTask = null; + +setinletassist(0, 'bang to start; source song|clip, refresh, debug 0|1'); +setoutletassist(0, 'to node.script: reset/source/root/mode/name/intervals/commit'); + +/* --- messages ---------------------------------------------------------- */ + +// live.thisdevice bangs once the device is fully loaded, which is the earliest +// moment a LiveAPI object may be created. +function bang() { + start(); +} + +function source(which) { + which = String(which); + if (which !== 'song' && which !== 'clip') { + error('scale-observer: source must be song or clip\n'); + return; + } + scaleSource = which; + schedulePush(); +} + +function refresh() { + schedulePush(); +} + +function debug(value) { + logging = value ? 1 : 0; +} + +/* --- observing --------------------------------------------------------- */ + +function start() { + if (started) return; + started = true; + + pushTask = new Task(push, this); + + for (var i = 0; i < SONG_PROPS.length; i++) { + songObservers.push(observe('live_set', SONG_PROPS[i])); + } + + // Which clip is selected changes independently of that clip's scale, so it + // needs its own observer that re-points the clip observers when it fires. + selectionObserver = new LiveAPI(onSelectionChanged, 'live_set view'); + selectionObserver.property = 'detail_clip'; + + observeClip(); + schedulePush(); +} + +function observe(path, property) { + var api = new LiveAPI(onChanged, path); + api.property = property; + return api; +} + +function onChanged() { + schedulePush(); +} + +function onSelectionChanged() { + observeClip(); + schedulePush(); +} + +// A LiveAPI binds to the object its path resolved to at creation time, so +// selecting a different clip means throwing these away and making new ones. +function observeClip() { + release(clipObservers); + clipObservers = []; + + var clip = new LiveAPI(noop, CLIP_PATH); + if (!clip.id || clip.id == 0) return; + + for (var i = 0; i < CLIP_PROPS.length; i++) { + if (has(clip, CLIP_PROPS[i])) { + clipObservers.push(observe(CLIP_PATH, CLIP_PROPS[i])); + } + } +} + +function release(observers) { + for (var i = 0; i < observers.length; i++) { + try { + observers[i].property = ''; + } catch (e) { + // Live is tearing the object down anyway. + } + } +} + +function notifydeleted() { + release(songObservers); + release(clipObservers); + if (selectionObserver) release([selectionObserver]); + if (pushTask) pushTask.freepeer(); +} + +/* --- reading and sending ------------------------------------------------ */ + +// Several properties change together when the scale changes; coalesce so the +// device gets one burst of messages instead of three. +function schedulePush() { + if (!started) return; + pushTask.cancel(); + pushTask.schedule(COALESCE_MS); +} + +function push() { + var scale = null; + if (scaleSource === 'clip') scale = readClip(); + if (!scale) scale = readSong(); + if (!scale) return; + + if (logging) { + post( + 'scale-observer: ' + scale.source + ' root=' + scale.root + + ' name=' + scale.name + ' intervals=' + scale.intervals + '\n' + ); + } + emit(scale); +} + +function readSong() { + var api = new LiveAPI(noop, 'live_set'); + if (!api.id) return null; + + var root = first(get(api, 'root_note')); + if (root === null) return null; + + return { + source: 'song', + root: root, + name: joined(get(api, 'scale_name')), + mode: first(get(api, 'scale_mode')), + // scale_intervals only exists from Live 12.1; the device falls back to + // resolving the name when this comes back empty. + intervals: get(api, 'scale_intervals') + }; +} + +function readClip() { + var api = new LiveAPI(noop, CLIP_PATH); + if (!api.id || api.id == 0) return null; + + var root = first(get(api, 'root_note')); + var name = joined(get(api, 'scale_name')); + if (root === null || !name) return null; + + var song = new LiveAPI(noop, 'live_set'); + + return { + source: 'clip', + root: root, + name: name, + // Clips carry no scale_mode of their own; the Song's flag still says + // whether Live is enforcing the scale at all. + mode: first(get(song, 'scale_mode')), + intervals: get(api, 'scale_intervals') + }; +} + +function emit(scale) { + outlet(0, 'reset'); + outlet(0, 'source', scale.source); + outlet(0, 'root', scale.root); + if (scale.mode !== null) outlet(0, 'mode', scale.mode); + if (scale.name) outlet(0, 'name', scale.name); + if (scale.intervals && scale.intervals.length) { + outlet(0, ['intervals'].concat(scale.intervals)); + } + outlet(0, 'commit'); +} + +/* --- LOM helpers -------------------------------------------------------- */ + +function noop() {} + +// Asking for a property the running Live does not have prints an error in the +// Max console, so check `info` before reaching for anything version-dependent. +function has(api, property) { + try { + var info = api.info; + if (typeof info !== 'string') return true; + return info.indexOf('property ' + property) !== -1; + } catch (e) { + return true; + } +} + +function get(api, property) { + if (!has(api, property)) return null; + try { + return api.get(property); + } catch (e) { + return null; + } +} + +function first(value) { + if (value === null || value === undefined) return null; + if (value instanceof Array) return value.length ? value[0] : null; + return value; +} + +// A symbol property comes back as an array, sometimes split on its spaces. +function joined(value) { + if (value === null || value === undefined) return ''; + if (value instanceof Array) return value.join(' '); + return String(value); +} diff --git a/package.json b/package.json index bda8dac..a474c7f 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "prepare": "npm run build", "start": "npm run build && node bin/apex7-scale.js", "stub": "npm run build && node dist/tools/fake-gamesense.js", - "test": "npm run build && node dist/test/logic.test.js" + "test": "npm run build && node dist/test/logic.test.js && node dist/test/live.test.js" }, "engines": { "node": ">=16" diff --git a/src/live.ts b/src/live.ts new file mode 100644 index 0000000..607cb8c --- /dev/null +++ b/src/live.ts @@ -0,0 +1,264 @@ +/** + * Build step 2: turning what Live's Object Model reports into a scale. + * + * Pure logic — no Max, no HTTP — so it can be unit tested off the hardware. + * The Max side (`max/scale-observer.js`) only forwards raw LOM values; every + * decision about what they mean lives here. + */ + +import { + NOTE_KEYS, + keysFor, + noteName, + parseIntervals, + parseRoot, + pitchClassesFor, +} from './scale'; +import type { PitchClass } from './scale'; + +/** Which LOM object the scale was read from. */ +export type ScaleSource = 'song' | 'clip'; + +export const SCALE_SOURCES: readonly ScaleSource[] = ['song', 'clip']; + +/** A resolved scale, ready to light. */ +export interface LiveScale { + readonly root: PitchClass; + readonly intervals: readonly number[]; + /** Live's display name, e.g. "Major". Empty when Live did not report one. */ + readonly name: string; + /** Live's `scale_mode` flag: is the scale actually engaged? */ + readonly scaleMode: boolean; + readonly source: ScaleSource; +} + +/** + * Live 12's built-in scales, used *only* as a fallback: `scale_intervals` + * arrived in Live 12.1, so on older versions `scale_name` is all we get. + * Whenever Live reports intervals, the intervals win over this table. + */ +export const LIVE_SCALE_INTERVALS: Record = { + Major: [0, 2, 4, 5, 7, 9, 11], + Minor: [0, 2, 3, 5, 7, 8, 10], + Dorian: [0, 2, 3, 5, 7, 9, 10], + Mixolydian: [0, 2, 4, 5, 7, 9, 10], + Lydian: [0, 2, 4, 6, 7, 9, 11], + Phrygian: [0, 1, 3, 5, 7, 8, 10], + Locrian: [0, 1, 3, 5, 6, 8, 10], + 'Whole Tone': [0, 2, 4, 6, 8, 10], + 'Half-whole Dim.': [0, 1, 3, 4, 6, 7, 9, 10], + 'Whole-half Dim.': [0, 2, 3, 5, 6, 8, 9, 11], + 'Minor Blues': [0, 3, 5, 6, 7, 10], + 'Minor Pentatonic': [0, 3, 5, 7, 10], + 'Major Pentatonic': [0, 2, 4, 7, 9], + 'Harmonic Minor': [0, 2, 3, 5, 7, 8, 11], + 'Harmonic Major': [0, 2, 4, 5, 7, 8, 11], + 'Dorian #4': [0, 2, 3, 6, 7, 9, 10], + 'Phrygian Dominant': [0, 1, 4, 5, 7, 8, 10], + 'Melodic Minor': [0, 2, 3, 5, 7, 9, 11], + 'Lydian Augmented': [0, 2, 4, 6, 8, 9, 11], + 'Lydian Dominant': [0, 2, 4, 6, 7, 9, 10], + 'Super Locrian': [0, 1, 3, 4, 6, 8, 10], + '8-Tone Spanish': [0, 1, 3, 4, 5, 6, 8, 10], + Bhairav: [0, 1, 4, 5, 7, 8, 11], + 'Hungarian Minor': [0, 2, 3, 6, 7, 8, 11], + Hirajoshi: [0, 2, 3, 7, 8], + 'In-Sen': [0, 1, 5, 7, 10], + Iwato: [0, 1, 5, 6, 10], + Kumoi: [0, 2, 3, 7, 9], + 'Pelog Selisir': [0, 1, 3, 7, 8], + 'Pelog Tembung': [0, 1, 5, 7, 8], + 'Messiaen 1': [0, 2, 4, 6, 8, 10], + 'Messiaen 2': [0, 1, 3, 4, 6, 7, 9, 10], + 'Messiaen 3': [0, 2, 3, 4, 6, 7, 8, 10, 11], + 'Messiaen 4': [0, 1, 2, 5, 6, 7, 8, 11], + 'Messiaen 5': [0, 1, 5, 6, 7, 11], + 'Messiaen 6': [0, 2, 4, 5, 6, 8, 10, 11], + 'Messiaen 7': [0, 1, 2, 3, 5, 6, 7, 8, 9, 11], + Chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], +}; + +/** Extra spellings for the same scales, so a renamed preset still resolves. */ +const SCALE_NAME_ALIASES: Record = { + 'half whole diminished': 'Half-whole Dim.', + 'whole half diminished': 'Whole-half Dim.', + diminished: 'Whole-half Dim.', + blues: 'Minor Blues', + 'blues minor': 'Minor Blues', + pentatonic: 'Major Pentatonic', + 'natural minor': 'Minor', + aeolian: 'Minor', + ionian: 'Major', + altered: 'Super Locrian', +}; + +/** "Half-whole Dim." and "half_whole dim" collapse to the same lookup key. */ +export function normalizeScaleName(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[.,]/g, '') + .replace(/[\s_-]+/g, ' ') + .trim(); +} + +const BY_NORMALIZED_NAME = new Map(); +for (const [displayName, intervals] of Object.entries(LIVE_SCALE_INTERVALS)) { + BY_NORMALIZED_NAME.set(normalizeScaleName(displayName), intervals); +} +for (const [alias, displayName] of Object.entries(SCALE_NAME_ALIASES)) { + const intervals = LIVE_SCALE_INTERVALS[displayName]; + if (intervals) BY_NORMALIZED_NAME.set(normalizeScaleName(alias), intervals); +} + +/** Live's `scale_name` -> intervals, or null if we do not know the name. */ +export function intervalsForScaleName(name: string): readonly number[] | null { + return BY_NORMALIZED_NAME.get(normalizeScaleName(name)) ?? null; +} + +/* ------------------------------------------------------------------------- + * Coercion of Max atoms + * + * Everything arrives from Max as loosely typed atoms: ints may show up as + * floats, flags as 0/1, symbols as strings. + * ---------------------------------------------------------------------- */ + +export function toPitchClass(value: unknown): PitchClass { + if (typeof value === 'number') return parseRoot(value); + return parseRoot(String(value)); +} + +export function toIntervals(values: readonly unknown[]): number[] { + return parseIntervals(values.map((v) => String(v)).join(',')); +} + +/** Max flags arrive as 0/1, sometimes as "0"/"1" or a bare bang. */ +export function toBoolean(value: unknown, fallback = true): boolean { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + const n = Number(value); + if (!Number.isNaN(n)) return n !== 0; + const s = String(value).trim().toLowerCase(); + if (s === 'true' || s === 'on' || s === 'yes') return true; + if (s === 'false' || s === 'off' || s === 'no') return false; + return fallback; +} + +export function toScaleSource(value: unknown): ScaleSource { + const s = String(value).trim().toLowerCase(); + if (s === 'song' || s === 'clip') return s; + throw new Error(`unknown scale source: ${value} (expected song or clip)`); +} + +/* ------------------------------------------------------------------------- */ + +/** + * What the observer has told us so far. The Max side sends one field per + * message and then a `commit`, so a scale is assembled across several calls. + */ +export interface PartialLiveScale { + root?: PitchClass; + intervals?: readonly number[]; + name?: string; + scaleMode?: boolean; + source?: ScaleSource; +} + +/** + * Turn what we have into a usable scale, or throw explaining what is missing. + * + * Live always reports `root_note` and `scale_name`; `scale_intervals` only + * exists from Live 12.1, hence the name fallback. + */ +export function resolveScale(partial: PartialLiveScale): LiveScale { + if (partial.root === undefined) { + throw new Error('no root note yet (waiting for root_note from Live)'); + } + + const name = (partial.name ?? '').trim(); + let intervals = partial.intervals; + + if (!intervals || intervals.length === 0) { + const fromName = intervalsForScaleName(name); + if (!fromName) { + throw new Error( + name + ? `Live sent no scale_intervals and "${name}" is not a scale we know` + : 'Live sent neither scale_intervals nor scale_name' + ); + } + intervals = fromName; + } + + return { + root: toPitchClass(partial.root), + intervals: [...intervals], + name, + scaleMode: partial.scaleMode ?? true, + source: partial.source ?? 'song', + }; +} + +/** Two scales are the same if they light the same keys for the same reason. */ +export function sameScale(a: LiveScale | null, b: LiveScale | null): boolean { + if (!a || !b) return a === b; + return ( + a.root === b.root && + a.name === b.name && + a.scaleMode === b.scaleMode && + a.source === b.source && + a.intervals.length === b.intervals.length && + a.intervals.every((v, i) => v === b.intervals[i]) + ); +} + +/** The pitch classes this scale lights, sorted. */ +export function pitchClassesOf(scale: LiveScale): PitchClass[] { + return pitchClassesFor(scale.root, scale.intervals); +} + +/** The QWERTY keys this scale lights, uppercase, in keyboard order. */ +export function keysOf(scale: LiveScale): string[] { + return keysFor(pitchClassesOf(scale)).map((k) => k.toUpperCase()); +} + +/** `C Major (song) — A S D F G H J K` */ +export function summarizeScale(scale: LiveScale): string { + const name = scale.name || `[${scale.intervals.join(' ')}]`; + const mode = scale.scaleMode ? '' : ', scale mode off'; + return `${noteName(scale.root)} ${name} (${scale.source}${mode}) — ${keysOf(scale).join(' ')}`; +} + +/** + * The two-line key diagram — every note key in keyboard order with a marker + * under the lit ones. This is what step 2 exists to print: hold it against the + * mapping table in the brief and the whole chain is verified. + */ +export function keyDiagram(pitchClasses: Iterable): [string, string] { + const lit = new Set(pitchClasses); + const header: string[] = []; + const marks: string[] = []; + for (const note of NOTE_KEYS) { + header.push(note.key.toUpperCase()); + marks.push(lit.has(note.pitchClass) ? '*' : '.'); + } + return [header.join(' '), marks.join(' ')]; +} + +/** Human-readable report of a scale, one string per line. */ +export function describeScale(scale: LiveScale): string[] { + const pitchClasses = pitchClassesOf(scale); + const [header, marks] = keyDiagram(pitchClasses); + const name = scale.name || '(unnamed)'; + + const lines = [ + `${noteName(scale.root)} ${name} — from the ${scale.source}` + + (scale.scaleMode ? '' : ' (scale mode is OFF in Live)'), + ` intervals: ${scale.intervals.join(' ')}`, + ` notes: ${pitchClasses.map((pc) => noteName(pc)).join(' ')}`, + ` keys: ${keysOf(scale).join(' ')}`, + ` ${header}`, + ` ${marks}`, + ]; + return lines; +} diff --git a/src/max/device.ts b/src/max/device.ts new file mode 100644 index 0000000..e950b65 --- /dev/null +++ b/src/max/device.ts @@ -0,0 +1,140 @@ +/** + * Build step 2: the Node for Max side of the M4L device. + * + * `max/scale-observer.js` (a `js` object, the only place that can touch the + * Live API) watches the LOM and forwards raw values here; this module turns + * them into a scale and prints the computed key set to the Max console. + * + * Wire protocol from the observer, one field per message: + * + * reset drop the half-assembled scale + * source song|clip which LOM object the values came from + * root 0..11 Song/Clip `root_note` + * mode 0|1 Song `scale_mode` + * name `scale_name`, e.g. "Whole Tone" + * intervals 0 2 4 ... `scale_intervals` (absent before Live 12.1) + * commit resolve everything above and report + * + * Plus two for hand-driving it from a message box: + * + * status re-print the current scale + * verbose 0|1 log every commit, or only changes (default) + * + * Max is injected rather than `require`d so the module stays testable outside + * of Max; `max/scale-device.js` is the launcher that supplies the real one. + */ + +import { errorMessage } from '../errors'; +import * as live from '../live'; +import type { LiveScale, PartialLiveScale } from '../live'; +import { noteName } from '../scale'; + +/** The slice of the `max-api` module this device uses. */ +export interface MaxApi { + post(...args: unknown[]): void; + outlet(...args: unknown[]): unknown; + addHandler(name: string, fn: (...args: any[]) => void): void; +} + +export interface Device { + /** The last successfully resolved scale, or null. */ + readonly scale: LiveScale | null; + /** Fields received since the last `commit`/`reset`. */ + readonly pending: PartialLiveScale; +} + +const PREFIX = 'scale:'; + +/** + * Register the message handlers on a Max API object. + * + * Returns a handle mostly so tests (and step 3) can look at the resolved + * scale without going through Max. + */ +export function start(max: MaxApi): Device { + let pending: PartialLiveScale = {}; + let scale: LiveScale | null = null; + let verbose = false; + + const post = (msg: string) => max.post(`${PREFIX} ${msg}`); + + /** Any handler may be fed junk by a stray message box; never throw at Max. */ + const guard = (what: string, fn: () => void) => { + try { + fn(); + } catch (err) { + post(`${what}: ${errorMessage(err)}`); + } + }; + + const report = (resolved: LiveScale) => { + for (const line of live.describeScale(resolved)) max.post(line); + max.outlet('notes', ...live.pitchClassesOf(resolved).map((pc) => noteName(pc))); + max.outlet('keys', ...live.keysOf(resolved)); + max.outlet('scale', live.summarizeScale(resolved)); + }; + + max.addHandler('reset', () => { + pending = {}; + }); + + max.addHandler('source', (value: unknown) => { + guard('source', () => { + pending.source = live.toScaleSource(value); + }); + }); + + max.addHandler('root', (value: unknown) => { + guard('root', () => { + pending.root = live.toPitchClass(value); + }); + }); + + max.addHandler('mode', (value: unknown) => { + pending.scaleMode = live.toBoolean(value); + }); + + // "Whole Tone" arrives as one symbol, but a hand-typed message box splits it. + max.addHandler('name', (...args: unknown[]) => { + pending.name = args.map((a) => String(a)).join(' ').trim(); + }); + + max.addHandler('intervals', (...args: unknown[]) => { + guard('intervals', () => { + pending.intervals = live.toIntervals(args); + }); + }); + + max.addHandler('commit', () => { + guard('commit', () => { + const resolved = live.resolveScale(pending); + pending = {}; + + const changed = !live.sameScale(scale, resolved); + scale = resolved; + if (changed || verbose) report(resolved); + }); + }); + + max.addHandler('status', () => { + if (scale) report(scale); + else post('no scale yet — is the device loaded in a Live set?'); + }); + + max.addHandler('verbose', (value: unknown) => { + verbose = live.toBoolean(value); + post(`verbose ${verbose ? 'on' : 'off'}`); + }); + + post('ready — waiting for the scale from Live'); + max.outlet('ready', 1); + + return { + get scale() { + return scale; + }, + get pending() { + return pending; + }, + }; +} diff --git a/test/live.test.ts b/test/live.test.ts new file mode 100644 index 0000000..5f8ab55 --- /dev/null +++ b/test/live.test.ts @@ -0,0 +1,352 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; + +import { + LIVE_SCALE_INTERVALS, + describeScale, + intervalsForScaleName, + keyDiagram, + keysOf, + normalizeScaleName, + pitchClassesOf, + resolveScale, + sameScale, + summarizeScale, + toBoolean, + toIntervals, + toPitchClass, + toScaleSource, +} from '../src/live'; +import type { LiveScale } from '../src/live'; +import { start } from '../src/max/device'; +import type { MaxApi } from '../src/max/device'; +import { SCALES } from '../src/scale'; + +let passed = 0; +function test(name: string, fn: () => void): void { + try { + fn(); + passed++; + console.log(` ok ${name}`); + } catch (err) { + console.error(` FAIL ${name}\n ${err instanceof Error ? err.message : err}`); + process.exitCode = 1; + } +} + +const REPO = path.join(__dirname, '..', '..'); + +console.log('Live scale names'); + +test('Live scale names resolve, however they are spelled', () => { + assert.deepStrictEqual(intervalsForScaleName('Major'), [0, 2, 4, 5, 7, 9, 11]); + assert.deepStrictEqual(intervalsForScaleName('major'), [0, 2, 4, 5, 7, 9, 11]); + assert.deepStrictEqual(intervalsForScaleName('Whole Tone'), [0, 2, 4, 6, 8, 10]); + assert.deepStrictEqual(intervalsForScaleName('whole_tone'), [0, 2, 4, 6, 8, 10]); + assert.deepStrictEqual(intervalsForScaleName('Half-whole Dim.'), [0, 1, 3, 4, 6, 7, 9, 10]); + assert.deepStrictEqual(intervalsForScaleName('Minor Pentatonic'), [0, 3, 5, 7, 10]); + assert.strictEqual(intervalsForScaleName('Bebop Dominant'), null); + assert.strictEqual(intervalsForScaleName(''), null); +}); + +test('aliases point at the same intervals', () => { + assert.deepStrictEqual(intervalsForScaleName('Aeolian'), LIVE_SCALE_INTERVALS.Minor); + assert.deepStrictEqual(intervalsForScaleName('Ionian'), LIVE_SCALE_INTERVALS.Major); + assert.deepStrictEqual(intervalsForScaleName('Blues'), LIVE_SCALE_INTERVALS['Minor Blues']); +}); + +test('the fallback table agrees with the CLI presets', () => { + assert.deepStrictEqual(intervalsForScaleName('Major'), SCALES.major); + assert.deepStrictEqual(intervalsForScaleName('Minor'), SCALES.minor); + assert.deepStrictEqual(intervalsForScaleName('Dorian'), SCALES.dorian); + assert.deepStrictEqual(intervalsForScaleName('Harmonic Minor'), SCALES['harmonic-minor']); + assert.deepStrictEqual(intervalsForScaleName('Minor Blues'), SCALES.blues); + assert.deepStrictEqual(intervalsForScaleName('Chromatic'), SCALES.chromatic); +}); + +test('every scale in the table is a sane interval set', () => { + for (const [name, intervals] of Object.entries(LIVE_SCALE_INTERVALS)) { + assert.ok(intervals.length >= 5, `${name} is suspiciously short`); + assert.strictEqual(intervals[0], 0, `${name} does not start on the root`); + assert.strictEqual(new Set(intervals).size, intervals.length, `${name} has duplicates`); + for (const i of intervals) { + assert.ok(Number.isInteger(i) && i >= 0 && i < 12, `${name} has a bad interval ${i}`); + } + const ascending = [...intervals].sort((a, b) => a - b); + assert.deepStrictEqual(intervals, ascending, `${name} is not ascending`); + } +}); + +test('name normalization collapses Live punctuation', () => { + assert.strictEqual(normalizeScaleName(' Half-whole Dim. '), 'half whole dim'); + assert.strictEqual(normalizeScaleName('Major_Pentatonic'), 'major pentatonic'); + assert.strictEqual(normalizeScaleName('Dorian #4'), 'dorian #4'); +}); + +console.log('coercion of Max atoms'); + +test('atoms coerce the way Max sends them', () => { + assert.strictEqual(toPitchClass(0), 0); + assert.strictEqual(toPitchClass(7.0), 7); + assert.strictEqual(toPitchClass('11'), 11); + assert.strictEqual(toPitchClass('F#'), 6); + assert.throws(() => toPitchClass(12)); + + assert.deepStrictEqual(toIntervals([0, 2, 4, 5, 7, 9, 11]), [0, 2, 4, 5, 7, 9, 11]); + assert.deepStrictEqual(toIntervals(['0', '3', '7']), [0, 3, 7]); + assert.throws(() => toIntervals([])); + + assert.strictEqual(toBoolean(1), true); + assert.strictEqual(toBoolean(0), false); + assert.strictEqual(toBoolean('0'), false); + assert.strictEqual(toBoolean('off'), false); + assert.strictEqual(toBoolean(undefined), true); + assert.strictEqual(toBoolean(undefined, false), false); + + assert.strictEqual(toScaleSource('song'), 'song'); + assert.strictEqual(toScaleSource('Clip'), 'clip'); + assert.throws(() => toScaleSource('track')); +}); + +console.log('resolving what Live reports'); + +test('intervals from Live win over the name', () => { + const scale = resolveScale({ + root: 2, + name: 'Major', + // A user-edited scale in Live 12.1 keeps the name but changes the notes. + intervals: [0, 2, 4, 5, 7, 9, 10], + scaleMode: true, + source: 'song', + }); + assert.deepStrictEqual(scale.intervals, [0, 2, 4, 5, 7, 9, 10]); +}); + +test('a missing scale_intervals falls back to the name (pre-12.1 Live)', () => { + const scale = resolveScale({ root: 9, name: 'Dorian' }); + assert.deepStrictEqual(scale.intervals, [0, 2, 3, 5, 7, 9, 10]); + assert.strictEqual(scale.source, 'song'); + assert.strictEqual(scale.scaleMode, true); +}); + +test('unresolvable input explains itself', () => { + assert.throws(() => resolveScale({}), /root note/); + assert.throws(() => resolveScale({ root: 0 }), /neither/); + assert.throws(() => resolveScale({ root: 0, name: 'Bebop' }), /not a scale we know/); +}); + +test('scale equality ignores nothing that matters', () => { + const base: LiveScale = { + root: 0, + intervals: [0, 2, 4, 5, 7, 9, 11], + name: 'Major', + scaleMode: true, + source: 'song', + }; + assert.ok(sameScale(base, { ...base })); + assert.ok(!sameScale(base, { ...base, root: 1 })); + assert.ok(!sameScale(base, { ...base, source: 'clip' })); + assert.ok(!sameScale(base, { ...base, scaleMode: false })); + assert.ok(!sameScale(base, { ...base, intervals: [0, 2, 4, 5, 7, 9, 10] })); + assert.ok(!sameScale(base, null)); + assert.ok(sameScale(null, null)); +}); + +console.log('reporting'); + +test('C major from the LOM lights the home row', () => { + const scale = resolveScale({ root: 0, name: 'Major', intervals: SCALES.major }); + assert.deepStrictEqual(pitchClassesOf(scale), [0, 2, 4, 5, 7, 9, 11]); + assert.deepStrictEqual(keysOf(scale), ['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K']); + assert.match(summarizeScale(scale), /^C Major \(song\) — A S D F G H J K$/); +}); + +test('the key diagram lines up with the mapping table in the brief', () => { + const [header, marks] = keyDiagram(pitchClassesOf( + resolveScale({ root: 0, name: 'Major', intervals: SCALES.major }) + )); + assert.strictEqual(header, 'A W S E D F T G Y H U J K'); + assert.strictEqual(marks, '* . * . * * . * . * . * *'); + assert.strictEqual(header.length, marks.length); +}); + +test('F# major marks the black keys', () => { + const scale = resolveScale({ root: 6, name: 'Major', intervals: SCALES.major }); + assert.deepStrictEqual(keysOf(scale), ['W', 'E', 'F', 'T', 'Y', 'U', 'J']); +}); + +test('describeScale flags scale mode being off', () => { + const on = describeScale(resolveScale({ root: 0, name: 'Major', scaleMode: true })); + const off = describeScale(resolveScale({ root: 0, name: 'Major', scaleMode: false })); + assert.ok(!on[0].includes('OFF')); + assert.ok(off[0].includes('OFF')); + assert.strictEqual(on.length, 6); +}); + +console.log('the Node for Max device'); + +interface FakeMax extends MaxApi { + handlers: Map void>; + posts: string[]; + outlets: unknown[][]; + send(name: string, ...args: unknown[]): void; +} + +function fakeMax(): FakeMax { + const handlers = new Map void>(); + const posts: string[] = []; + const outlets: unknown[][] = []; + return { + handlers, + posts, + outlets, + post: (...args: unknown[]) => posts.push(args.map((a) => String(a)).join(' ')), + outlet: (...args: unknown[]) => outlets.push(args), + addHandler: (name, fn) => handlers.set(name, fn), + send(name, ...args) { + const handler = handlers.get(name); + if (!handler) throw new Error(`no handler for ${name}`); + handler(...args); + }, + }; +} + +/** What max/scale-observer.js emits for one scale. */ +function observe(max: FakeMax, fields: Record): void { + max.send('reset'); + for (const [name, args] of Object.entries(fields)) max.send(name, ...args); + max.send('commit'); +} + +test('the device registers every message the observer sends', () => { + const max = fakeMax(); + start(max); + for (const name of ['reset', 'source', 'root', 'mode', 'name', 'intervals', 'commit']) { + assert.ok(max.handlers.has(name), `missing handler: ${name}`); + } + assert.ok(max.posts.some((p) => p.includes('ready'))); +}); + +test('a burst from the observer resolves to a scale', () => { + const max = fakeMax(); + const device = start(max); + observe(max, { + source: ['song'], + root: [2], + mode: [1], + name: ['Dorian'], + intervals: [0, 2, 3, 5, 7, 9, 10], + }); + + assert.ok(device.scale); + assert.strictEqual(device.scale.root, 2); + assert.strictEqual(device.scale.name, 'Dorian'); + assert.deepStrictEqual(device.pending, {}, 'commit should clear the pending fields'); + + // D dorian is the white keys, so the notes outlet is what pins the root down. + const notes = max.outlets.find((o) => o[0] === 'notes'); + assert.deepStrictEqual(notes, ['notes', 'C', 'D', 'E', 'F', 'G', 'A', 'B']); + const keys = max.outlets.find((o) => o[0] === 'keys'); + assert.deepStrictEqual(keys, ['keys', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K']); + assert.ok(max.posts.some((p) => p.includes('D Dorian'))); +}); + +test('a multi-word scale name survives being split into atoms', () => { + const max = fakeMax(); + const device = start(max); + observe(max, { root: [0], name: ['Whole', 'Tone'] }); + assert.strictEqual(device.scale?.name, 'Whole Tone'); + assert.deepStrictEqual(device.scale?.intervals, [0, 2, 4, 6, 8, 10]); +}); + +test('an unchanged scale is not re-reported, a changed one is', () => { + const max = fakeMax(); + start(max); + const fields = { root: [0], name: ['Major'], intervals: [0, 2, 4, 5, 7, 9, 11] }; + + observe(max, fields); + const afterFirst = max.posts.length; + observe(max, fields); + assert.strictEqual(max.posts.length, afterFirst, 'identical scale re-printed'); + + observe(max, { ...fields, root: [5] }); + assert.ok(max.posts.length > afterFirst, 'changed scale not printed'); +}); + +test('verbose reports every commit', () => { + const max = fakeMax(); + start(max); + const fields = { root: [0], name: ['Major'] }; + + observe(max, fields); + max.send('verbose', 1); + const before = max.posts.length; + observe(max, fields); + assert.ok(max.posts.length > before); +}); + +test('garbage from a message box is reported, not thrown', () => { + const max = fakeMax(); + const device = start(max); + + max.send('root', 99); + max.send('source', 'track'); + max.send('intervals', 'x'); + max.send('commit'); + + assert.strictEqual(device.scale, null); + assert.ok(max.posts.some((p) => p.includes('root:'))); + assert.ok(max.posts.some((p) => p.includes('source:'))); + assert.ok(max.posts.some((p) => p.includes('intervals:'))); + assert.ok(max.posts.some((p) => p.includes('commit:'))); +}); + +test('status prints the current scale, or says there is none', () => { + const max = fakeMax(); + start(max); + max.send('status'); + assert.ok(max.posts.some((p) => p.includes('no scale yet'))); + + observe(max, { root: [0], name: ['Major'] }); + const before = max.posts.length; + max.send('status'); + assert.ok(max.posts.length > before); +}); + +console.log('Max patcher'); + +test('scale-lighting.maxpat is valid JSON with the objects we wired', () => { + const raw = fs.readFileSync(path.join(REPO, 'max', 'scale-lighting.maxpat'), 'utf8'); + const patch = JSON.parse(raw) as { + patcher: { + boxes: { box: { id: string; text?: string } }[]; + lines: { patchline: { source: [string, number]; destination: [string, number] } }[]; + }; + }; + + const boxes = patch.patcher.boxes.map((b) => b.box); + const texts = boxes.map((b) => b.text ?? ''); + for (const expected of ['live.thisdevice', 'js scale-observer.js', 'midiin', 'midiout']) { + assert.ok(texts.includes(expected), `patcher is missing ${expected}`); + } + assert.ok( + texts.some((t) => t.startsWith('node.script scale-device.js')), + 'patcher is missing node.script' + ); + + // Every patch cord must point at a box that exists. + const ids = new Set(boxes.map((b) => b.id)); + for (const { patchline } of patch.patcher.lines) { + assert.ok(ids.has(patchline.source[0]), `dangling source ${patchline.source[0]}`); + assert.ok(ids.has(patchline.destination[0]), `dangling destination ${patchline.destination[0]}`); + } +}); + +test('the scripts the patcher names exist next to it', () => { + for (const file of ['scale-observer.js', 'scale-device.js']) { + assert.ok(fs.existsSync(path.join(REPO, 'max', file)), `max/${file} is missing`); + } +}); + +console.log(`\n${passed} passed${process.exitCode ? ', with failures' : ''}`);