From 2d927107f1df4d46d36bf65ca5411d5cdae1b5f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:55:59 +0200 Subject: [PATCH] feat: web matrix arranger tab --- web_realart/public/control/control.css | 23 ++++ web_realart/public/control/control.js | 139 +++++++++++++++++++++++++ web_realart/public/control/index.html | 13 +++ web_realart/server.js | 2 +- 4 files changed, 176 insertions(+), 1 deletion(-) diff --git a/web_realart/public/control/control.css b/web_realart/public/control/control.css index 5bf39f1..0c209d5 100644 --- a/web_realart/public/control/control.css +++ b/web_realart/public/control/control.css @@ -91,3 +91,26 @@ button.queued { animation: queued-blink 600ms ease-in-out infinite alternate; } .scene-nav { display: flex; gap: 8px; margin: 8px 0; } .scene-nav button { flex: 1; font-size: 16px; padding: 16px; } + +/* --- Matrix arranger (16x32) --- */ +.matrix-transport { display: flex; gap: 8px; margin: 10px 0; } +.matrix-transport button { flex: 1; padding: 14px 10px; font-size: 14px; } +.matrix-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; } +.mrow { display: flex; align-items: center; } +.mvoice-label { width: 54px; font-size: 10px; color: #888; text-align: right; + padding-right: 6px; flex-shrink: 0; white-space: nowrap; overflow: hidden; + text-overflow: ellipsis; } +.mbar-num { width: 22px; font-size: 9px; color: #555; text-align: center; + flex-shrink: 0; height: 14px; line-height: 14px; } +.mcell { width: 22px; height: 22px; min-width: 22px; border: 1px solid; + border-radius: 2px; cursor: pointer; padding: 0; flex-shrink: 0; } +.mcell:active { opacity: 0.65; } +.mcell.m0 { background: #1a1a1a; border-color: #2a2a2a; } +.mcell.m1 { background: #c22; border-color: #e44; } +.mcell.m2 { background: #1a8; border-color: #2da; } +.mcell.m3 { background: #25b; border-color: #46e; } +.mcell.m4 { background: #990; border-color: #cc0; } +.mcell.m5 { background: #c60; border-color: #e80; } +.mcell.m6 { background: #82a; border-color: #a4d; } +.mcell.playing { box-shadow: 0 0 0 2px rgba(255,255,255,0.28), + inset 0 0 0 1px rgba(255,255,255,0.40); } diff --git a/web_realart/public/control/control.js b/web_realart/public/control/control.js index 61a3e6a..b0b7511 100644 --- a/web_realart/public/control/control.js +++ b/web_realart/public/control/control.js @@ -127,8 +127,132 @@ ws.addEventListener("message", (ev) => { }); return; } + + // /matrix/playhead — advance playhead column highlight + if (address === "/matrix/playhead") { + const bar = Math.round(Number(args[0])); + if (!Number.isFinite(bar) || bar < 0 || bar >= MATRIX_BARS) return; + if (matPlayhead >= 0) { + for (let vi = 0; vi < MATRIX_VOICES.length; vi++) { + const el = cellRefs[matPlayhead][vi]; + if (el) el.classList.remove("playing"); + } + } + matPlayhead = bar; + for (let vi = 0; vi < MATRIX_VOICES.length; vi++) { + const el = cellRefs[matPlayhead][vi]; + if (el) el.classList.add("playing"); + } + return; + } + + // /matrix/cell — cross-surface cell sync + if (address === "/matrix/cell") { + 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; + matGrid[vi][bar] = color; + saveMatState(); + const el = cellRefs[bar][vi]; + if (el) { + applyMatCellColor(el, color); + if (bar === matPlayhead) el.classList.add("playing"); + } + return; + } }); +// --- 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" +]; +const MATRIX_BARS = 32; +let matGrid = Array.from({ length: 16 }, () => new Array(32).fill(0)); +// cellRefs[bar][vi] for O(16) playhead column toggle +const cellRefs = Array.from({ length: 32 }, () => new Array(16).fill(null)); +let matPlayhead = -1; + +function loadMatState() { + try { + const raw = localStorage.getItem(MATRIX_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if ( + Array.isArray(parsed) && parsed.length === 16 && + parsed.every(row => Array.isArray(row) && row.length === 32) + ) { + matGrid = parsed; + return; + } + } + } catch (_e) {} + matGrid = Array.from({ length: 16 }, () => new Array(32).fill(0)); +} + +function saveMatState() { + try { localStorage.setItem(MATRIX_STORAGE_KEY, JSON.stringify(matGrid)); } catch (_e) {} +} + +function applyMatCellColor(el, color) { + for (let c = 0; c <= 6; c++) el.classList.remove("m" + c); + el.classList.add("m" + color); + el.dataset.state = color; +} + +function renderMatrix() { + const container = document.getElementById("matrix-grid"); + if (!container) return; + container.innerHTML = ""; + + // Header row: bar numbers every 4 bars + const headerRow = document.createElement("div"); + headerRow.className = "mrow"; + const corner = document.createElement("span"); + corner.className = "mvoice-label"; + headerRow.appendChild(corner); + for (let bar = 0; bar < MATRIX_BARS; bar++) { + const num = document.createElement("span"); + num.className = "mbar-num"; + num.textContent = (bar % 4 === 0) ? String(bar + 1) : ""; + headerRow.appendChild(num); + } + container.appendChild(headerRow); + + // Voice rows + for (let vi = 0; vi < MATRIX_VOICES.length; vi++) { + const row = document.createElement("div"); + row.className = "mrow"; + const label = document.createElement("span"); + label.className = "mvoice-label"; + label.textContent = MATRIX_VOICES[vi]; + row.appendChild(label); + for (let bar = 0; bar < MATRIX_BARS; bar++) { + const color = matGrid[vi][bar]; + const cell = document.createElement("button"); + cell.className = "mcell m" + color; + cell.dataset.vi = vi; + cell.dataset.bar = bar; + cell.dataset.state = color; + if (bar === matPlayhead) cell.classList.add("playing"); + cell.addEventListener("click", () => { + const cur = +cell.dataset.state; + const next = (cur + 1) % 7; + matGrid[vi][bar] = next; + saveMatState(); + applyMatCellColor(cell, next); + if (bar === matPlayhead) cell.classList.add("playing"); + send("/matrix/cell", vi, bar, next); + }); + cellRefs[bar][vi] = cell; + row.appendChild(cell); + } + container.appendChild(row); + } +} + // --- Pattern state (defaults mirror launchpad.scd exactly) --- const STORAGE_KEY = "avlive.seq"; @@ -276,6 +400,7 @@ function updateNameInputs() { document.addEventListener("DOMContentLoaded", () => { loadState(); + loadMatState(); // Tab switching document.querySelectorAll(".tab-btn").forEach((btn) => { @@ -296,6 +421,7 @@ document.addEventListener("DOMContentLoaded", () => { updateNameInputs(); renderRhyGrid(); renderMelSteps(); + renderMatrix(); // Name input: rename selected preset and update button labels const melName = document.getElementById("mel-name"); @@ -398,4 +524,17 @@ document.addEventListener("DOMContentLoaded", () => { if (scenePrev) scenePrev.addEventListener("click", () => send("/scene/prev")); const sceneNext = document.getElementById("scene-next"); if (sceneNext) sceneNext.addEventListener("click", () => send("/scene/next")); + + // Matrix transport + const matPlay = document.getElementById("matrix-play"); + if (matPlay) matPlay.addEventListener("click", () => send("/matrix/play")); + const matStop = document.getElementById("matrix-stop"); + 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)); + saveMatState(); + renderMatrix(); + send("/matrix/clear"); + }); }); diff --git a/web_realart/public/control/index.html b/web_realart/public/control/index.html index c597a36..dbc61ee 100644 --- a/web_realart/public/control/index.html +++ b/web_realart/public/control/index.html @@ -13,6 +13,7 @@ +
@@ -150,4 +151,16 @@
+
+

Matrice

+
+ + + +
+
+
+
+
+
diff --git a/web_realart/server.js b/web_realart/server.js index 3e0b9e2..09c681a 100644 --- a/web_realart/server.js +++ b/web_realart/server.js @@ -114,7 +114,7 @@ feedbackPort.on("ready", () => { console.log(`[feedback] ecoute :${FEEDBACK_PORT_IN} <- SC engine`); }); -const FEEDBACK_PREFIXES = ["/armed/", "/sync/", "/seq/", "/scene/"]; +const FEEDBACK_PREFIXES = ["/armed/", "/sync/", "/seq/", "/scene/", "/matrix/"]; feedbackPort.on("message", (oscMsg) => { if (!FEEDBACK_PREFIXES.some((p) => oscMsg.address.startsWith(p))) return; broadcast({ address: oscMsg.address, args: oscMsg.args });