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' : ''}`);