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 { Device, Lights, MaxApi } from '../src/max/device'; import { SCALES } from '../src/scale'; import type { PitchClass } from '../src/scale'; /** * Tests run in order on one promise chain: the device's lighting work is async, * so a test may need to `await device.idle()` before asserting. */ let passed = 0; let chain: Promise = Promise.resolve(); function test(name: string, fn: () => void | Promise): void { chain = chain.then(async () => { try { await fn(); passed++; console.log(` ok ${name}`); } catch (err) { console.error(` FAIL ${name}\n ${err instanceof Error ? err.message : err}`); process.exitCode = 1; } }); } function section(name: string): void { chain = chain.then(() => console.log(name)); } const REPO = path.join(__dirname, '..', '..'); section('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'); }); section('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')); }); section('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)); }); section('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); }); section('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); }, }; } /** A stand-in for ScaleLighting: records what the keyboard was asked to do. */ interface FakeLights extends Omit { isStarted: boolean; starts: number; stops: number; frames: { root: PitchClass; intervals: readonly number[] }[]; /** When set, every call rejects with it — a GameSense that is not there. */ fail: string | null; } function fakeLights(): FakeLights { const lights: FakeLights = { isStarted: false, starts: 0, stops: 0, frames: [], fail: null, async start() { if (lights.fail) throw new Error(lights.fail); lights.starts++; lights.isStarted = true; }, async showScale(root, intervals) { if (lights.fail) throw new Error(lights.fail); lights.frames.push({ root, intervals }); return {}; }, async stop() { lights.stops++; lights.isStarted = false; }, }; return lights; } /** The device, never touching real hardware. */ function startDevice(max: FakeMax, lights: FakeLights = fakeLights()): Device { return start(max, { createLights: () => lights }); } /** 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(); startDevice(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 = startDevice(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 = startDevice(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(); startDevice(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(); startDevice(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 = startDevice(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(); startDevice(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); }); section('driving the keyboard'); const C_MAJOR = { root: [0], name: ['Major'], intervals: [0, 2, 4, 5, 7, 9, 11] }; test('a commit from Live reaches the keyboard', async () => { const max = fakeMax(); const lights = fakeLights(); const device = startDevice(max, lights); observe(max, { source: ['song'], root: [2], name: ['Dorian'], intervals: [0, 2, 3, 5, 7, 9, 10] }); await device.idle(); assert.strictEqual(lights.starts, 1, 'GameSense should be started once, lazily'); assert.deepStrictEqual(lights.frames, [{ root: 2, intervals: [0, 2, 3, 5, 7, 9, 10] }]); assert.ok(max.posts.some((p) => p.includes('lighting on'))); assert.ok(max.outlets.some((o) => o[0] === 'lighting' && o[1] === 1)); }); test('an unchanged scale is not re-sent, a changed one is', async () => { const max = fakeMax(); const lights = fakeLights(); const device = startDevice(max, lights); observe(max, C_MAJOR); observe(max, C_MAJOR); await device.idle(); assert.strictEqual(lights.frames.length, 1, 'the same scale was pushed twice'); assert.strictEqual(lights.starts, 1, 'GameSense was re-registered'); observe(max, { ...C_MAJOR, root: [5] }); await device.idle(); assert.deepStrictEqual(lights.frames.map((f) => f.root), [0, 5]); }); test('lights 0 hands the board back, lights 1 takes it again', async () => { const max = fakeMax(); const lights = fakeLights(); const device = startDevice(max, lights); observe(max, C_MAJOR); await device.idle(); max.send('lights', 0); await device.idle(); assert.strictEqual(lights.stops, 1); assert.strictEqual(lights.isStarted, false); assert.strictEqual(device.lightsEnabled, false); assert.ok(max.outlets.some((o) => o[0] === 'lighting' && o[1] === 0)); // Live keeps changing scale while the lighting is off; nothing is sent... observe(max, { ...C_MAJOR, root: [5] }); await device.idle(); assert.strictEqual(lights.frames.length, 1); assert.ok(device.scale, 'the scale should still be tracked'); // ...but turning it back on catches the board up with the current scale. max.send('lights', 1); await device.idle(); assert.strictEqual(lights.starts, 2); assert.deepStrictEqual(lights.frames.map((f) => f.root), [0, 5]); }); test('a GameSense failure is reported once, and retry recovers', async () => { const max = fakeMax(); const lights = fakeLights(); lights.fail = 'coreProps.json not found. Is SteelSeries GG running?'; const device = startDevice(max, lights); observe(max, C_MAJOR); await device.idle(); const complaints = () => max.posts.filter((p) => p.includes('coreProps.json')).length; assert.strictEqual(complaints(), 1); assert.ok(max.posts.some((p) => p.includes('retry')), 'no hint about how to recover'); // The same failure on every commit must not fill the Max console. observe(max, { ...C_MAJOR, root: [5] }); observe(max, C_MAJOR); await device.idle(); assert.strictEqual(complaints(), 1); assert.ok(device.scale, 'a dead GameSense must not stop the device tracking Live'); lights.fail = null; max.send('retry'); await device.idle(); assert.deepStrictEqual(lights.frames, [{ root: 0, intervals: C_MAJOR.intervals }]); assert.ok(max.posts.some((p) => p.includes('recovered'))); }); test('address re-points the controller and re-sends', async () => { const max = fakeMax(); const lights = fakeLights(); const addresses: (string | undefined)[] = []; const device = start(max, { createLights: (opts) => { addresses.push(opts.address); return lights; }, }); observe(max, C_MAJOR); await device.idle(); max.send('address', '127.0.0.1:51000'); await device.idle(); assert.deepStrictEqual(addresses, [undefined, '127.0.0.1:51000']); assert.strictEqual(lights.stops, 1, 'the old registration should be removed'); assert.strictEqual(lights.frames.length, 2, 'the scale should be re-sent'); }); test('shutdown blanks the board', async () => { const max = fakeMax(); const lights = fakeLights(); const device = startDevice(max, lights); observe(max, C_MAJOR); await device.idle(); await device.shutdown(); assert.strictEqual(lights.stops, 1); assert.strictEqual(lights.isStarted, false); assert.strictEqual(device.lights, null); }); test('status says whether the board is being driven', async () => { const max = fakeMax(); const device = startDevice(max); observe(max, C_MAJOR); await device.idle(); max.send('status'); assert.ok(max.posts.some((p) => p.endsWith('lighting on'))); max.send('lights', 0); await device.idle(); max.send('status'); assert.ok(max.posts.some((p) => p.endsWith('lighting off'))); }); section('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 ?? ''); const expectedTexts = [ 'live.thisdevice', 'js scale-observer.js', 'midiin', 'midiout', // Step 3: the lighting controls, and blanking the board on device deletion. 'lights 1', 'lights 0', 'retry', 'closebang', 'shutdown', ]; for (const expected of expectedTexts) { 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`); } }); void chain.then(() => { console.log(`\n${passed} passed${process.exitCode ? ', with failures' : ''}`); });