feat: matrix v2 - 22 voices x 64 bars
CI build oscope-of / build-check (push) Has been cancelled

Wire 6 new tracks (melody, chord, fx, snare, crash, shaker) into
~matVoices and extend the grid to 64 bars. Per-voice dicts (mod
targets, neutral cut, instrument choices) gain the new voices.
~matLoadFile auto-migrates any legacy grid (tile bars, pad voices)
so the old 16x32 presets still load. Web grid/mixer/timeline +
handlers de-hardcode 16/32/512 to MATRIX_VOICES.length/MATRIX_BARS.
Headless test assertions made dimension-dynamic.
This commit is contained in:
L'électron rare
2026-06-29 00:42:28 +02:00
parent 7b458b8b58
commit 042da7faa0
3 changed files with 73 additions and 42 deletions
+29 -5
View File
@@ -14,9 +14,10 @@
\kick, \hats, \clap, \perc,
\sub, \acid, \arp, \lead,
\stab, \pad, \ride, \rim,
\tom, \reese, \bells, \sweep
\tom, \reese, \bells, \sweep,
\melody, \chord, \fx, \snare, \crash, \shaker
];
~matBars = ~matBars ? 32;
~matBars = ~matBars ? 64;
~lp[\matrix] = ~lp[\matrix] ? Array.fill(~matVoices.size, { Array.fill(~matBars, 0) });
~lp[\matBar] = ~lp[\matBar] ? 0;
~lp[\matPlaying] = ~lp[\matPlaying] ? false;
@@ -33,11 +34,14 @@
\lead->[\amp,\cutoff,\pan,\rev], \stab->[\amp,\cutoff,\pan,\rev], \pad->[\amp,\cutoff,\pan,\rev],
\ride->[\amp,\pan,\rev], \rim->[\amp,\pan,\rev], \tom->[\amp,\pan,\rev],
\reese->[\amp,\cutoff,\pan,\rev],
\bells->[\amp,\pan,\rev], \sweep->[\amp,\pan,\rev]
\bells->[\amp,\pan,\rev], \sweep->[\amp,\pan,\rev],
\melody->[\amp,\cutoff,\pan,\rev], \chord->[\amp,\cutoff,\pan,\rev],
\fx->[\amp,\pan,\rev], \snare->[\amp,\pan,\rev],
\crash->[\amp,\pan,\rev], \shaker->[\amp,\pan,\rev]
];
~matModNeutralCut = ~matModNeutralCut ? IdentityDictionary[
\perc->1260, \acid->700, \arp->3000, \lead->4000, \stab->2200,
\pad->1500, \reese->1100
\pad->1500, \reese->1100, \melody->3500, \chord->2000
];
// -- default color defs reproduce the historical global variation table --
@@ -96,7 +100,13 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd });
\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]
\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]
];
// -- fixed kits: kitName -> (voiceName -> instrument). Unlisted voices = default --
@@ -396,6 +406,19 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd });
}
};
// -- ~matMigrateGrid : coerce any grid to current ~matVoices.size x ~matBars --
// Legacy presets (e.g. 16 x 32) get padded to the current voice count (new
// voices silent) and their bars tiled (wrapAt) to fill ~matBars; cells clipped.
~matMigrateGrid = { |g|
Array.fill(~matVoices.size, { |vi|
var src = (g.isArray and: { vi < g.size } and: {
g[vi].isArray and: { g[vi].size > 0 } }).if({ g[vi] }, { nil });
Array.fill(~matBars, { |bar|
src.notNil.if({ (src.wrapAt(bar) ? 0).asInteger.clip(0, 6) }, { 0 })
})
})
};
// -- ~matNames : list base names of .matrix files found in a directory --
// User dir: served from in-memory ~matSavedNames (populated by ~matSave) —
// avoids filesystem-scan quirks that can fail headless in some SC contexts.
@@ -576,6 +599,7 @@ OSCdef(\mat_steps_get, { |msg, time, addr|
// legacy: bare grid Array; new: Event (grid:, instruments:)
grid = raw.isArray.if({ raw }, { raw[\grid] });
insts = raw.isArray.if({ nil }, { raw[\instruments] });
grid = grid.isArray.if({ ~matMigrateGrid.(grid) }, { grid }); // pad/tile to current dims
~matGridValid.(grid).if({
~lp[\matrix] = grid;
// restore instruments (default nil; validate against choices)
+5 -5
View File
@@ -144,7 +144,7 @@ pass = pass and: { ~trigLog[1] == 5 };
pass = pass and: { ~trigLog[2] == 1.0 }; // color 2 spec[\amp]=1.0; base has no \amp
// --- Instrument selection (Task 1) ---
pass = pass and: { ~matInstruments.notNil and: { ~matInstruments.size == 16 } };
pass = pass and: { ~matInstruments.notNil and: { ~matInstruments.size == ~matVoices.size } };
pass = pass and: { ~matInstruments.every({ |x| x.isNil }) }; // default nil
pass = pass and: { ~matInstChoices.notNil and: { ~matKits.notNil } };
@@ -222,7 +222,7 @@ pass = pass and: { ~matModSourceVal.(\handSpeed) == 0.6 };
pass = pass and: { ~matModSourceVal.(\unknownKey) == 0.0 }; // safe default
// --- Mod bindings (Task 2) ---
pass = pass and: { ~matMod.notNil and: { ~matMod.size == 16 } };
pass = pass and: { ~matMod.notNil and: { ~matMod.size == ~matVoices.size } };
pass = pass and: { ~matMod.every({ |x| x.isNil }) };
pass = pass and: { ~matModTargets.notNil };
@@ -277,14 +277,14 @@ pass = pass and: { ~matNextBar.value == 2 }; // below ls -> snap to loop start
~matSeek.(7);
pass = pass and: { ~lp[\matBar] == 7 };
~matSeek.(99);
pass = pass and: { ~lp[\matBar] == 31 }; // clamped to ~matBars - 1
pass = pass and: { ~lp[\matBar] == (~matBars - 1) }; // clamped to ~matBars - 1
// Reset loop to full range
~matSetLoop.(0, 31);
// --- Color editor: per-voice color defs (Task 1) ---
~matVoices.size.do { |i| ~matMod[i] = nil }; // isolate from prior mod-persistence state
pass = pass and: { ~matColorDefs.notNil and: { ~matColorDefs.size == 16 } };
pass = pass and: { ~matColorDefs.notNil and: { ~matColorDefs.size == ~matVoices.size } };
// defaults reproduce the current table: color 2 = stretch 0.5, octave 0, amp 1.0
pass = pass and: { ~matColorDefs[5][2][\stretch] == 0.5 };
pass = pass and: { ~matColorDefs[5][2][\octave] == 0 };
@@ -417,7 +417,7 @@ File.delete(~matDir +/+ "sleg.matrix");
// --- Pose bindings (Task 4) ---
pass = pass and: { ~matPoseBindings.notNil and: { ~matSetPoseBind.notNil } };
pass = pass and: { ~matPoseBindings.size == 16 };
pass = pass and: { ~matPoseBindings.size == ~matVoices.size };
~trigLog = nil;
~toscSend = { |path ...args| ~trigLog = ([path] ++ args) };
~matSetPoseBind.(5, \poseA, \trigger, 3);
+39 -32
View File
@@ -164,7 +164,7 @@ ws.addEventListener("message", (ev) => {
const vi = Math.round(Number(args[0]));
const bar = Math.round(Number(args[1]));
const color = Math.round(Number(args[2]));
if (vi < 0 || vi >= 16 || bar < 0 || bar >= MATRIX_BARS || color < 0 || color > 6) return;
if (vi < 0 || vi >= MATRIX_VOICES.length || bar < 0 || bar >= MATRIX_BARS || color < 0 || color > 6) return;
matGrid[vi][bar] = color;
saveMatState();
const el = cellRefs[bar][vi];
@@ -194,10 +194,10 @@ ws.addEventListener("message", (ev) => {
// /matrix/grid <512 ints row-major vi*32+bar> — rebuild grid from loaded preset
if (address === "/matrix/grid") {
if (!Array.isArray(args) || args.length < 512) return;
for (let vi = 0; vi < 16; vi++) {
if (!Array.isArray(args) || args.length < (MATRIX_VOICES.length * MATRIX_BARS)) return;
for (let vi = 0; vi < MATRIX_VOICES.length; vi++) {
for (let bar = 0; bar < MATRIX_BARS; bar++) {
const v = Math.round(Number(args[vi * 32 + bar]));
const v = Math.round(Number(args[vi * MATRIX_BARS + bar]));
matGrid[vi][bar] = (Number.isFinite(v) && v >= 0 && v <= 6) ? v : 0;
}
}
@@ -215,7 +215,7 @@ ws.addEventListener("message", (ev) => {
// /matrix/instrument <vi> <inst> — single-voice instrument echo
if (address === "/matrix/instrument") {
const vi = Math.round(Number(args[0])); const v = String(args[1] ?? "default");
if (vi >= 0 && vi < 16) {
if (vi >= 0 && vi < MATRIX_VOICES.length) {
matInst[vi] = v; saveMatState();
if (cdEditVoice === vi && !document.getElementById("colordef-modal").hidden) {
const sel = document.getElementById("cd-inst-sel");
@@ -226,7 +226,7 @@ ws.addEventListener("message", (ev) => {
}
// /matrix/instruments <16 strings> — bulk (preset load / kit)
if (address === "/matrix/instruments") {
for (let vi = 0; vi < 16 && vi < args.length; vi++) {
for (let vi = 0; vi < MATRIX_VOICES.length && vi < args.length; vi++) {
const v = String(args[vi] ?? "default"); matInst[vi] = v;
}
if (cdEditVoice >= 0 && !document.getElementById("colordef-modal").hidden) {
@@ -238,7 +238,7 @@ ws.addEventListener("message", (ev) => {
}
if (address === "/matrix/mod") {
const vi = Math.round(Number(args[0]));
if (vi >= 0 && vi < 16) {
if (vi >= 0 && vi < MATRIX_VOICES.length) {
matMod[vi] = { source:String(args[1]??"none"), target:String(args[2]??"none"), depth:Number(args[3])||0 };
if (cdEditVoice === vi && !document.getElementById("colordef-modal").hidden) {
const r = document.querySelector(".cd-mod-src");
@@ -251,7 +251,7 @@ ws.addEventListener("message", (ev) => {
return;
}
if (address === "/matrix/mods") { // 16 triples flattened = 48 values
for (let vi = 0; vi < 16; vi++) {
for (let vi = 0; vi < MATRIX_VOICES.length; vi++) {
const b = vi*3;
matMod[vi] = { source:String(args[b]??"none"), target:String(args[b+1]??"none"), depth:Number(args[b+2])||0 };
}
@@ -266,7 +266,7 @@ ws.addEventListener("message", (ev) => {
}
if (address === "/matrix/colordefs") {
const vi = Math.round(Number(args[0]));
if (vi >= 0 && vi < 16) {
if (vi >= 0 && vi < MATRIX_VOICES.length) {
for (let c = 1; c <= 6; c++) {
const b = 1 + (c - 1) * 6;
matColorDefs[vi][c] = { stretch:Number(args[b]), octave:Math.round(Number(args[b+1])),
@@ -280,7 +280,7 @@ ws.addEventListener("message", (ev) => {
}
if (address === "/matrix/steps") {
const vi = Math.round(Number(args[0])), c = Math.round(Number(args[1]));
if (vi>=0 && vi<16 && c>=1 && c<=6) {
if (vi>=0 && vi<MATRIX_VOICES.length && c>=1 && c<=6) {
for (let s=0; s<16; s++) {
const deg = Number(args[2 + s*2]), vel = Number(args[3 + s*2]);
matSteps[vi][c][s] = (deg === -99) ? null : {degree:deg, vel:vel};
@@ -292,7 +292,7 @@ ws.addEventListener("message", (ev) => {
}
if (address === "/matrix/poses") {
const vi = Math.round(Number(args[0])), n = Math.round(Number(args[1]));
if (vi>=0 && vi<16) {
if (vi>=0 && vi<MATRIX_VOICES.length) {
matPoses[vi] = [];
for (let k=0; k<n; k++) { const b=2+k*3;
matPoses[vi].push({poseId:String(args[b]), action:String(args[b+1]), color:Math.round(Number(args[b+2]))}); }
@@ -307,7 +307,8 @@ ws.addEventListener("message", (ev) => {
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"
"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.
@@ -320,19 +321,25 @@ const MATRIX_INST_CHOICES = {
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"]
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(16).fill("default"); // per-voice selection (persisted)
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"]
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"]
};
let matMod = Array.from({length:16}, () => ({source:"none", target:"none", depth:0.5}));
let matColorDefs = Array.from({ length: 16 }, () =>
let matMod = Array.from({length: MATRIX_VOICES.length}, () => ({source:"none", target:"none", depth:0.5}));
let matColorDefs = Array.from({ length: MATRIX_VOICES.length }, () =>
[null,
{stretch:1.0,octave:0,amp:1.0,inst:"default",cutoff:-1,pan:-2},
{stretch:0.5,octave:0,amp:1.0,inst:"default",cutoff:-1,pan:-2},
@@ -340,13 +347,13 @@ let matColorDefs = Array.from({ length: 16 }, () =>
{stretch:2.0,octave:0,amp:1.0,inst:"default",cutoff:-1,pan:-2},
{stretch:1.0,octave:-1,amp:1.05,inst:"default",cutoff:-1,pan:-2},
{stretch:0.5,octave:0,amp:1.2,inst:"default",cutoff:-1,pan:-2}]);
let matSteps = Array.from({length:16}, () => Array.from({length:7}, () => new Array(16).fill(null)));
let matPoses = Array.from({length:16}, () => []);
let matSteps = Array.from({length: MATRIX_VOICES.length}, () => Array.from({length:7}, () => new Array(16).fill(null)));
let matPoses = Array.from({length: MATRIX_VOICES.length}, () => []);
let cdEditVoice = -1, cdAuditioning = false;
const MATRIX_BARS = 32;
let matGrid = Array.from({ length: 16 }, () => new Array(32).fill(0));
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: 32 }, () => new Array(16).fill(null));
const cellRefs = Array.from({ length: MATRIX_BARS }, () => new Array(MATRIX_VOICES.length).fill(null));
let matPlayhead = -1;
let matLoopStart = 0;
let matLoopEnd = 31;
@@ -358,16 +365,16 @@ function saveMatState() {
function loadMatState() {
try {
const raw = JSON.parse(localStorage.getItem(MATRIX_STORAGE_KEY));
if (raw && Array.isArray(raw.grid) && raw.grid.length === 16) { matGrid = raw.grid;
if (Array.isArray(raw.inst) && raw.inst.length === 16) matInst = raw.inst;
if (Array.isArray(raw.mod) && raw.mod.length === 16) matMod = raw.mod;
if (Array.isArray(raw.cdef) && raw.cdef.length === 16) matColorDefs = raw.cdef;
if (Array.isArray(raw.steps) && raw.steps.length === 16) matSteps = raw.steps;
if (Array.isArray(raw.pose) && raw.pose.length === 16) matPoses = raw.pose;
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.mod) && raw.mod.length === MATRIX_VOICES.length) matMod = raw.mod;
if (Array.isArray(raw.cdef) && raw.cdef.length === MATRIX_VOICES.length) matColorDefs = raw.cdef;
if (Array.isArray(raw.steps) && raw.steps.length === MATRIX_VOICES.length) matSteps = raw.steps;
if (Array.isArray(raw.pose) && raw.pose.length === MATRIX_VOICES.length) matPoses = raw.pose;
return; }
if (Array.isArray(raw) && raw.length === 16) { matGrid = raw; return; } // legacy: bare grid
if (Array.isArray(raw) && raw.length === MATRIX_VOICES.length) { matGrid = raw; return; } // legacy: bare grid
} catch (_e) {}
matGrid = Array.from({ length: 16 }, () => new Array(32).fill(0));
matGrid = Array.from({ length: MATRIX_VOICES.length }, () => new Array(MATRIX_BARS).fill(0));
}
function applyMatCellColor(el, color) {
@@ -873,7 +880,7 @@ function renderRhyGrid() {
container.innerHTML = voices.map(v =>
`<div class="step-row">` +
`<span class="step-row-label">${v.label}</span>` +
Array.from({ length: 16 }, (_, i) => {
Array.from({ length: MATRIX_VOICES.length }, (_, i) => {
const on = (v.str[i] || "0") === "1";
return `<button class="step-cell${on ? " on" : ""}" data-voice="${v.key}" data-step="${i}" aria-pressed="${on}"></button>`;
}).join("") +
@@ -1101,7 +1108,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: 16 }, () => new Array(32).fill(0));
matGrid = Array.from({ length: MATRIX_VOICES.length }, () => new Array(MATRIX_BARS).fill(0));
saveMatState();
renderMatrix();
send("/matrix/clear");