Add the Max for Live device that reads Live's scale

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 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-08-15 08:48:05 +00:00
parent 3b02612461
commit 2f0d5841c6
8 changed files with 1454 additions and 9 deletions

352
test/live.test.ts Normal file
View File

@@ -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<string, (...args: any[]) => void>;
posts: string[];
outlets: unknown[][];
send(name: string, ...args: unknown[]): void;
}
function fakeMax(): FakeMax {
const handlers = new Map<string, (...args: any[]) => 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<string, unknown[]>): 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' : ''}`);