From b0357af7157dd7c3a8b97cabcd2bc27835d57699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:01:27 +0200 Subject: [PATCH] refactor: extract matrix state and palette module --- web_realart/public/control/js/main.js | 84 ++----------- web_realart/public/control/js/matrix-state.js | 116 ++++++++++++++++++ web_realart/test/matrix-state.test.mjs | 42 +++++++ 3 files changed, 167 insertions(+), 75 deletions(-) create mode 100644 web_realart/public/control/js/matrix-state.js create mode 100644 web_realart/test/matrix-state.test.mjs diff --git a/web_realart/public/control/js/main.js b/web_realart/public/control/js/main.js index 633087a..e4062b1 100644 --- a/web_realart/public/control/js/main.js +++ b/web_realart/public/control/js/main.js @@ -1,4 +1,10 @@ import { send, on, onOpen, onClose, onPrefix } from "./osc.js"; +import { + MATRIX_VOICES, MATRIX_BARS, MATRIX_INST_CHOICES, + MATRIX_MOD_SOURCES, MATRIX_MOD_TARGETS, + mkColor, matGrid, matInst, matColorDefs, + resetMatGrid, saveMatState, loadMatState, +} from "./matrix-state.js"; const dot = () => document.getElementById("status"); onOpen(() => { dot().className = "on"; send("/matrix/list"); }); @@ -264,87 +270,15 @@ on("/matrix/colorpose", (args) => { } }); -// --- Matrix (16-voice x 32-bar song arranger) --- -const MATRIX_STORAGE_KEY = "avlive.matrix"; -const MATRIX_VOICES = [ - "kick","hats","clap","perc","sub","acid","arp","lead", - "stab","pad","ride","rim","tom","reese","bells","sweep", - "melody","chord","fx","snare","crash","shaker" -]; -// per-voice curated instrument options (must mirror SC ~matInstChoices) -// note-terminating synths only — the continuous do_ drones never free per-note. -const MATRIX_INST_CHOICES = { - kick:["lp_kick","tr909_kick","tr808_kick","sp1200","do_kick","do_quake_sub"], hats:["lp_hat","tr909_hat","tr808_hat","sp1200","do_hat"], - clap:["lp_clap","tr909_clap","tr808_clap","sp1200","lp_rim"], perc:["lp_perc","tr808_cowbell","sp1200","akai_s","do_strike"], - sub:["lp_sub","sh101","minimoog","system100","lp_reese"], - acid:["lp_acid","tb303","ms20","system100","lp_pluck"], arp:["lp_pluck","tb303","supersaw","juno","do_strike"], - lead:["lp_pluck","ms20","supersaw","do_plane","sh101"], stab:["lp_pluck","ms20","supersaw","tb303","lp_acid"], - pad:["lp_pad","juno","supersaw","ms20","lp_reese"], ride:["lp_ride","tr909_ride","lp_hat","do_hat"], - rim:["lp_rim","tr808_cowbell","tr909_clap","sp1200"], tom:["lp_tom","sp1200","do_strike","do_quake_sub"], - reese:["lp_reese","ms20","minimoog","system100","supersaw"], - bells:["lp_bells","dx7","akai_s","do_plane"], sweep:["lp_sweep","akai_s","lp_pad","lp_reese"], - melody:["lp_pluck","supersaw","ms20","sh101","dx7"], chord:["lp_pad","juno","supersaw","ms20"], - fx:["lp_fx","akai_s","lp_sweep"], snare:["lp_snare","tr909_clap","tr808_clap","lp_clap"], - crash:["lp_crash","tr909_ride","akai_s"], shaker:["lp_shaker","tr909_hat","tr808_hat"] -}; -let matInst = new Array(MATRIX_VOICES.length).fill("default"); // per-voice selection (persisted) -const MATRIX_MOD_SOURCES = ["none","lHandY","rHandY","lHandX","rHandX","lOpen","rOpen","handSpeed","handDist","bodyX","bodyY","depth"]; -const MATRIX_MOD_TARGETS = { - kick:["none","amp","pan","rev"], hats:["none","amp","pan","rev"], clap:["none","amp","pan","rev"], perc:["none","amp","cutoff","pan","rev"], - sub:["none","amp","pan","rev"], acid:["none","amp","cutoff","pan","rev"], arp:["none","amp","cutoff","pan","rev"], - lead:["none","amp","cutoff","pan","rev"], stab:["none","amp","cutoff","pan","rev"], pad:["none","amp","cutoff","pan","rev"], - ride:["none","amp","pan","rev"], rim:["none","amp","pan","rev"], tom:["none","amp","pan","rev"], reese:["none","amp","cutoff","pan","rev"], - bells:["none","amp","pan","rev"], sweep:["none","amp","pan","rev"], - melody:["none","amp","cutoff","pan","rev"], chord:["none","amp","cutoff","pan","rev"], - fx:["none","amp","pan","rev"], snare:["none","amp","pan","rev"], - crash:["none","amp","pan","rev"], shaker:["none","amp","pan","rev"] -}; -const mkColor = (stretch, octave, amp, inst, cutoff, pan) => - ({stretch, octave, amp, inst, cutoff, pan, mod:null, pose:[], steps:new Array(16).fill(null)}); -let matColorDefs = Array.from({ length: MATRIX_VOICES.length }, () => - [null, - mkColor(1.0,0,1.0,"default",-1,-2), - mkColor(0.5,0,1.0,"default",-1,-2), - mkColor(1.0,1,1.0,"default",-1,-2), - mkColor(2.0,0,1.0,"default",-1,-2), - mkColor(1.0,-1,1.05,"default",-1,-2), - mkColor(0.5,0,1.2,"default",-1,-2)]); +// --- Matrix (22-voice x 64-bar song arranger) --- +// Data layer lives in matrix-state.js — constants/state/save/load imported above. let cdEditVoice = -1, cdColor = 1, cdAuditioning = false; -const MATRIX_BARS = 64; -let matGrid = Array.from({ length: MATRIX_VOICES.length }, () => new Array(MATRIX_BARS).fill(0)); // cellRefs[bar][vi] for O(16) playhead column toggle const cellRefs = Array.from({ length: MATRIX_BARS }, () => new Array(MATRIX_VOICES.length).fill(null)); let matPlayhead = -1; let matLoopStart = 0; let matLoopEnd = MATRIX_BARS - 1; -function saveMatState() { - try { localStorage.setItem(MATRIX_STORAGE_KEY, - JSON.stringify({ grid: matGrid, inst: matInst, cdef: matColorDefs })); } catch (_e) {} -} -function loadMatState() { - try { - const raw = JSON.parse(localStorage.getItem(MATRIX_STORAGE_KEY)); - if (raw && Array.isArray(raw.grid) && raw.grid.length === MATRIX_VOICES.length) { matGrid = raw.grid; - if (Array.isArray(raw.inst) && raw.inst.length === MATRIX_VOICES.length) matInst = raw.inst; - if (Array.isArray(raw.cdef) && raw.cdef.length === MATRIX_VOICES.length) { - matColorDefs = raw.cdef; - for (let vi = 0; vi < MATRIX_VOICES.length; vi++) { - for (let c = 1; c <= 6; c++) { - if (matColorDefs[vi][c]) { - if (!matColorDefs[vi][c].steps) matColorDefs[vi][c].steps = new Array(16).fill(null); - if (!matColorDefs[vi][c].pose) matColorDefs[vi][c].pose = []; - if (matColorDefs[vi][c].mod === undefined) matColorDefs[vi][c].mod = null; - } - } - } - } - return; } - if (Array.isArray(raw) && raw.length === MATRIX_VOICES.length) { matGrid = raw; return; } // legacy: bare grid - } catch (_e) {} - matGrid = Array.from({ length: MATRIX_VOICES.length }, () => new Array(MATRIX_BARS).fill(0)); -} - function applyMatCellColor(el, color) { for (let c = 0; c <= 6; c++) el.classList.remove("m" + c); el.classList.add("m" + color); @@ -1100,7 +1034,7 @@ document.addEventListener("DOMContentLoaded", () => { if (matStop) matStop.addEventListener("click", () => send("/matrix/stop")); const matClear = document.getElementById("matrix-clear"); if (matClear) matClear.addEventListener("click", () => { - matGrid = Array.from({ length: MATRIX_VOICES.length }, () => new Array(MATRIX_BARS).fill(0)); + resetMatGrid(); saveMatState(); renderMatrix(); send("/matrix/clear"); diff --git a/web_realart/public/control/js/matrix-state.js b/web_realart/public/control/js/matrix-state.js new file mode 100644 index 0000000..75dbb61 --- /dev/null +++ b/web_realart/public/control/js/matrix-state.js @@ -0,0 +1,116 @@ +// matrix-state.js — pure data layer for the AV-Live matrix arranger. +// No DOM access. Consumed by main.js and future modules. + +const MATRIX_STORAGE_KEY = "avlive.matrix"; + +export const MATRIX_VOICES = [ + "kick","hats","clap","perc","sub","acid","arp","lead", + "stab","pad","ride","rim","tom","reese","bells","sweep", + "melody","chord","fx","snare","crash","shaker" +]; + +export const MATRIX_BARS = 64; + +// Per-voice curated instrument options (must mirror SC ~matInstChoices). +// Note-terminating synths only — the continuous do_ drones never free per-note. +export const MATRIX_INST_CHOICES = { + kick:["lp_kick","tr909_kick","tr808_kick","sp1200","do_kick","do_quake_sub"], hats:["lp_hat","tr909_hat","tr808_hat","sp1200","do_hat"], + clap:["lp_clap","tr909_clap","tr808_clap","sp1200","lp_rim"], perc:["lp_perc","tr808_cowbell","sp1200","akai_s","do_strike"], + sub:["lp_sub","sh101","minimoog","system100","lp_reese"], + acid:["lp_acid","tb303","ms20","system100","lp_pluck"], arp:["lp_pluck","tb303","supersaw","juno","do_strike"], + lead:["lp_pluck","ms20","supersaw","do_plane","sh101"], stab:["lp_pluck","ms20","supersaw","tb303","lp_acid"], + pad:["lp_pad","juno","supersaw","ms20","lp_reese"], ride:["lp_ride","tr909_ride","lp_hat","do_hat"], + rim:["lp_rim","tr808_cowbell","tr909_clap","sp1200"], tom:["lp_tom","sp1200","do_strike","do_quake_sub"], + reese:["lp_reese","ms20","minimoog","system100","supersaw"], + bells:["lp_bells","dx7","akai_s","do_plane"], sweep:["lp_sweep","akai_s","lp_pad","lp_reese"], + melody:["lp_pluck","supersaw","ms20","sh101","dx7"], chord:["lp_pad","juno","supersaw","ms20"], + fx:["lp_fx","akai_s","lp_sweep"], snare:["lp_snare","tr909_clap","tr808_clap","lp_clap"], + crash:["lp_crash","tr909_ride","akai_s"], shaker:["lp_shaker","tr909_hat","tr808_hat"] +}; + +export const MATRIX_MOD_SOURCES = ["none","lHandY","rHandY","lHandX","rHandX","lOpen","rOpen","handSpeed","handDist","bodyX","bodyY","depth"]; + +export const MATRIX_MOD_TARGETS = { + kick:["none","amp","pan","rev"], hats:["none","amp","pan","rev"], clap:["none","amp","pan","rev"], perc:["none","amp","cutoff","pan","rev"], + sub:["none","amp","pan","rev"], acid:["none","amp","cutoff","pan","rev"], arp:["none","amp","cutoff","pan","rev"], + lead:["none","amp","cutoff","pan","rev"], stab:["none","amp","cutoff","pan","rev"], pad:["none","amp","cutoff","pan","rev"], + ride:["none","amp","pan","rev"], rim:["none","amp","pan","rev"], tom:["none","amp","pan","rev"], reese:["none","amp","cutoff","pan","rev"], + bells:["none","amp","pan","rev"], sweep:["none","amp","pan","rev"], + melody:["none","amp","cutoff","pan","rev"], chord:["none","amp","cutoff","pan","rev"], + fx:["none","amp","pan","rev"], snare:["none","amp","pan","rev"], + crash:["none","amp","pan","rev"], shaker:["none","amp","pan","rev"] +}; + +// Canonical 6-colour palette for the matrix grid (index 1-6, 0 = off). +export const PALETTE = ["#c22","#1a8","#25b","#990","#c60","#82a"]; + +// Voice classification: "drum" | "melodic" | "semi" +export const VOICE_CLASS = { + kick:"drum", hats:"drum", clap:"drum", perc:"semi", sub:"melodic", acid:"melodic", + arp:"melodic", lead:"melodic", stab:"melodic", pad:"melodic", ride:"drum", rim:"drum", + tom:"semi", reese:"melodic", bells:"melodic", sweep:"drum", melody:"melodic", + chord:"melodic", fx:"drum", snare:"drum", crash:"drum", shaker:"drum", +}; + +export function isMelodic(v) { return VOICE_CLASS[v] === "melodic"; } +export function defaultStepMode(v) { return VOICE_CLASS[v] === "melodic" ? "pitch" : "drum"; } + +// Color-definition factory. res/rev are reserved extension fields (future use). +export const mkColor = (stretch, octave, amp, inst, cutoff, pan) => + ({stretch, octave, amp, inst, cutoff, pan, mod:null, pose:[], steps:new Array(16).fill(null), res:null, rev:null}); + +// --- Mutable state --- +// Exported as named `let` bindings so consumers read live values via import. +// Reassignments happen only inside this module (loadMatState / resetMatGrid), +// so ES-module live-binding semantics are sufficient — main.js never rebinds. +// Exception: the matrix-clear button originally did `matGrid = newArray` in +// main.js; that is now delegated to resetMatGrid() exported below. + +export let matInst = new Array(MATRIX_VOICES.length).fill("default"); + +export let matColorDefs = Array.from({ length: MATRIX_VOICES.length }, () => + [null, + mkColor(1.0, 0, 1.0,"default",-1,-2), + mkColor(0.5, 0, 1.0,"default",-1,-2), + mkColor(1.0, 1, 1.0,"default",-1,-2), + mkColor(2.0, 0, 1.0,"default",-1,-2), + mkColor(1.0,-1, 1.05,"default",-1,-2), + mkColor(0.5, 0, 1.2,"default",-1,-2)]); + +export let matGrid = Array.from({ length: MATRIX_VOICES.length }, () => new Array(MATRIX_BARS).fill(0)); + +// Reset matGrid to all-zeros (used by the clear button in main.js instead of +// direct reassignment, which is forbidden on an imported binding). +export function resetMatGrid() { + matGrid = Array.from({ length: MATRIX_VOICES.length }, () => new Array(MATRIX_BARS).fill(0)); +} + +export function saveMatState() { + try { localStorage.setItem(MATRIX_STORAGE_KEY, + JSON.stringify({ grid: matGrid, inst: matInst, cdef: matColorDefs })); } catch (_e) {} +} + +export function loadMatState() { + try { + const raw = JSON.parse(localStorage.getItem(MATRIX_STORAGE_KEY)); + if (raw && Array.isArray(raw.grid) && raw.grid.length === MATRIX_VOICES.length) { matGrid = raw.grid; + if (Array.isArray(raw.inst) && raw.inst.length === MATRIX_VOICES.length) matInst = raw.inst; + if (Array.isArray(raw.cdef) && raw.cdef.length === MATRIX_VOICES.length) { + matColorDefs = raw.cdef; + for (let vi = 0; vi < MATRIX_VOICES.length; vi++) { + for (let c = 1; c <= 6; c++) { + if (matColorDefs[vi][c]) { + if (!matColorDefs[vi][c].steps) matColorDefs[vi][c].steps = new Array(16).fill(null); + if (!matColorDefs[vi][c].pose) matColorDefs[vi][c].pose = []; + if (matColorDefs[vi][c].mod === undefined) matColorDefs[vi][c].mod = null; + if (matColorDefs[vi][c].res === undefined) matColorDefs[vi][c].res = null; + if (matColorDefs[vi][c].rev === undefined) matColorDefs[vi][c].rev = null; + } + } + } + } + return; } + if (Array.isArray(raw) && raw.length === MATRIX_VOICES.length) { matGrid = raw; return; } // legacy: bare grid + } catch (_e) {} + matGrid = Array.from({ length: MATRIX_VOICES.length }, () => new Array(MATRIX_BARS).fill(0)); +} diff --git a/web_realart/test/matrix-state.test.mjs b/web_realart/test/matrix-state.test.mjs new file mode 100644 index 0000000..3a4de7c --- /dev/null +++ b/web_realart/test/matrix-state.test.mjs @@ -0,0 +1,42 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { PALETTE, VOICE_CLASS, defaultStepMode, MATRIX_VOICES, MATRIX_BARS, + mkColor, saveMatState, loadMatState, matGrid, matInst, matColorDefs } from "../public/control/js/matrix-state.js"; + +test("palette is the single 6-colour source", () => { + assert.deepEqual(PALETTE, ["#c22","#1a8","#25b","#990","#c60","#82a"]); +}); +test("dimensions are 22x64", () => { + assert.equal(MATRIX_VOICES.length, 22); + assert.equal(MATRIX_BARS, 64); +}); +test("step-mode defaults by voice class", () => { + assert.equal(defaultStepMode("acid"), "pitch"); + assert.equal(defaultStepMode("kick"), "drum"); + assert.equal(defaultStepMode("perc"), "drum"); // semi defaults to drum +}); +test("mkColor includes res and rev fields", () => { + const c = mkColor(1.0, 0, 1.0, "default", -1, -2); + assert.ok("res" in c, "mkColor result must have res field"); + assert.ok("rev" in c, "mkColor result must have rev field"); + assert.strictEqual(c.res, null); + assert.strictEqual(c.rev, null); +}); +test("matGrid is 22x64 zero-filled by default", () => { + assert.equal(matGrid.length, MATRIX_VOICES.length); + assert.equal(matGrid[0].length, MATRIX_BARS); + assert.equal(matGrid[0][0], 0); +}); +test("save/load round-trip preserves grid values (no localStorage in Node)", () => { + // In Node.js localStorage is absent — saveMatState must not throw, loadMatState must not throw + assert.doesNotThrow(() => saveMatState()); + assert.doesNotThrow(() => loadMatState()); +}); +test("loadMatState back-fills res and rev on color defs (simulated stale data)", () => { + // Simulate a stale cdef entry missing res/rev: check back-fill guard logic + // We can't easily test localStorage in Node, but we can verify that mkColor + // always produces res:null/rev:null so freshly created entries are always valid. + const fresh = mkColor(0.5, 1, 0.8, "tb303", -1, -2); + assert.strictEqual(fresh.res, null, "fresh mkColor has res:null"); + assert.strictEqual(fresh.rev, null, "fresh mkColor has rev:null"); +});