merge: reconcile instrument and capture with main

Merge main (presets, loop/seek, per-voice mixer) into the SDD branch.
matVariation combines main per-voice volume amp with the instPair and
modPairs spread; volume is propagated into the mod amp formula. Both
OSCdef sets (mod/capture + loop/seek) and both test blocks are kept.
This commit is contained in:
clement
2026-06-28 20:35:07 +02:00
8 changed files with 476 additions and 27 deletions
@@ -222,12 +222,13 @@ overlay construction so a mod binding contributes its target key:
})
})
});
var inst = ~matInstruments[vi];
var instPair = inst.notNil.if({ [\instrument, inst] }, { [] });
Pchain(
Pbind(*([
\matGlow, Pfunc { |e| ~matEmitTrig.(vi, (e[\amp] ? spec[\amp]).clip(0,1)); 0 },
\stretch, spec[\stretch], \octave, spec[\octave], \amp, spec[\amp],
\instrument, Pfunc { ~matInstruments[vi] }
] ++ modPairs)),
\stretch, spec[\stretch], \octave, spec[\octave], \amp, spec[\amp]
] ++ instPair ++ modPairs)),
base
)
```
@@ -242,7 +243,7 @@ first and reads the base amp.)
```supercollider
OSCdef(\mat_mod, { |msg, time, addr|
~toscTouch !? { ~toscTouch.(addr) };
~matApplyMod.(msg[1].asInteger, (msg[2] ? \none).asSymbol,
~matApplyMod.((msg[1] ? 0).asInteger, (msg[2] ? \none).asSymbol,
(msg[3] ? \none).asSymbol, (msg[4] ? 0).asFloat)
}, '/matrix/mod');
```
@@ -183,25 +183,34 @@ Add after the kits:
- [ ] **Step 6: Inject `\instrument` into the `~matVariation` overlay**
In `~matVariation`, the overlay Pbind (currently the `Pchain(Pbind(\matGlow, ...,
\amp, spec[\amp]), base)` at ~L66-79) gains an `\instrument` key AFTER `\amp`, read
from `~matInstruments[vi]` (only when non-nil; nil → no key → base instrument). Keep
`\matGlow` first. Replace the overlay `Pbind(...)` argument list's end so it reads:
\amp, spec[\amp]), base)` at ~L66-79) gains an `\instrument` key built
CONDITIONALLY — included ONLY when `~matInstruments[vi]` is non-nil. Keep `\matGlow`
first. Build the overlay args with a spread so the instrument pair can be omitted:
```supercollider
Pbind(
\matGlow, Pfunc { |e|
~matEmitTrig.(vi, (e[\amp] ? spec[\amp]).clip(0, 1));
0
},
\stretch, spec[\stretch],
\octave, spec[\octave],
\amp, spec[\amp],
\instrument, Pfunc { ~matInstruments[vi] } // nil → base \instrument kept
),
var inst = ~matInstruments[vi];
var instPair = inst.notNil.if({ [\instrument, inst] }, { [] });
Pchain(
Pbind(*([
\matGlow, Pfunc { |e|
~matEmitTrig.(vi, (e[\amp] ? spec[\amp]).clip(0, 1));
0
},
\stretch, spec[\stretch],
\octave, spec[\octave],
\amp, spec[\amp]
] ++ instPair)),
base
)
```
Note: a Pbind value of `nil` for `\instrument` leaves the base event's
`\instrument` untouched (Pchain only overrides keys with non-nil values per event).
CRITICAL: do NOT use `\instrument, Pfunc { ~matInstruments[vi] }` — a Pbind value
pattern that yields nil ENDS the stream, so a default (nil-instrument) voice would
produce zero events (silence). The conditional spread omits the key entirely when
nil, preserving the base Pdef's instrument. Changing an instrument forces a
re-source (`~matLastColor[vi] = -1`), so building with the current value is reactive.
A regression test MUST consume a nil-instrument `~matVariation` stream and assert
the event is non-nil and carries the base instrument.
- [ ] **Step 7: Add the OSCdefs**
@@ -0,0 +1,162 @@
# Matrix per-instrument color editor — design
Date: 2026-06-28
Status: approved (brainstorming), pending implementation plan
Scope: `sound_algo/data_only/matrix.scd` + `web_realart/public/control/`
Sequencing: implement AFTER the instrument-selection and capture-effect plans
(`2026-06-28-matrix-instrument-selection.md`, `2026-06-28-matrix-capture-effect.md`),
because this generalizes the `~matVariation` engine those plans extend. See
Reconciliation.
## Goal
Open an editor for an instrument (a matrix voice) that exposes its 6 colors as 6
editable, auditionable "steps". For each color (1-6), the performer edits what that
color does for that voice — its variation (stretch/octave/amp) and its sound
(instrument + cutoff + pan, within what the synth exposes). The 32-bar grid stays
the sequencer; the editor only defines what the colors mean per voice.
## Decisions (from brainstorming)
- Unifies three asks: (1) edit the 6 colors' variation per voice, (2) lay the 6
colors out as 6 "steps" with an audition loop, (3) edit the sound per color.
- The "6-step sequencer" = the 6 colors edited/auditioned in the popup, NOT an
independent live loop and NOT nested sub-steps. The grid remains the sequencer.
- Color defs are **per voice**; defaults reproduce the current global
`~matVariation` table, so behavior is unchanged until edited.
- Editable sound knobs are bounded to synth-exposed args: instrument, stretch,
octave, amp, cutoff (filter voices), pan (pan voices). No envelope/drive editing
(the `lp_*`/`do_*` synths do not expose those).
## Approach
**Per-voice color-def table replacing the global spec**`~matColorDefs[vi]` holds
6 definitions (colors 1-6); `~matVariation` reads it instead of the hardcoded spec.
Defaults equal the current table → behavior-preserving. Single source of truth.
Rejected: global table + per-voice override merge (two-source complexity); full
synth-preset per color (synths expose too few args).
## Reconciliation with instrument-selection & capture-effect
This feature changes `~matVariation` from a global hardcoded spec to a per-voice
table. The instrument-selection plan (`~matInstruments[vi]`, `\instrument` overlay
key) and capture-effect plan (`~matMod[vi]`, mod target key) also extend
`~matVariation`. Implement this AFTER both; the unified `~matVariation.(name,
color, vi)` overlay then resolves, in order:
1. `\matGlow` (FIRST key — reads base velocity).
2. variation keys `\stretch`/`\octave`/`\amp` from `~matColorDefs[vi][color]`.
3. `\instrument` = `~matColorDefs[vi][color].inst ?? ~matInstruments[vi]`
(per-color override wins; else the voice default from instrument-selection; else
nil → base synth).
4. optional `\cutoff`/`\pan` from the color def (only for voices whose synth has
the arg).
5. mod target key from `~matMod[vi]` (capture-effect), appended last; a mod `\amp`
overrides the color-def `\amp`.
The instrument-selection plan's per-voice `<select>` stays as the voice default;
this editor adds per-color overrides + variation + audition.
## Data model
```
~matColorDefs[vi] // Array of 7; index 0 unused; 1..6 = an Event:
( inst: nil|Symbol, // nil = use ~matInstruments[vi] default
stretch: Float, octave: Integer, amp: Float,
cutoff: nil|Float, // nil = synth default; only applied for filter voices
pan: nil|Float ) // nil = synth default; only applied for pan voices
```
Default `~matColorDefs[vi]` for every voice (reproduces the current global table):
| color | stretch | octave | amp |
|-------|---------|--------|------|
| 1 | 1.0 | 0 | 1.0 |
| 2 | 0.5 | 0 | 1.0 |
| 3 | 1.0 | 1 | 1.0 |
| 4 | 2.0 | 0 | 1.0 |
| 5 | 1.0 | -1 | 1.05 |
| 6 | 0.5 | 0 | 1.2 |
`inst`/`cutoff`/`pan` default nil. Filter voices (acid/arp/lead/stab/pad) accept
`cutoff`; pan voices (acid/arp/lead/stab) accept `pan` — reuse the capture-effect
`~matModTargets` membership to decide applicability.
## Components
### SC (`sound_algo/data_only/matrix.scd`)
- `~matColorDefs``Array.fill(~matVoices.size, { defaultColorDefs.copy })`,
nil-guarded/idempotent; `defaultColorDefs` = the table above.
- `~matVariation` — read `~matColorDefs[vi][color]` for the variation/sound keys
(replacing the hardcoded `spec`), preserving the overlay key order in
Reconciliation. `color == 0` → nil (off) unchanged.
- `~matSetColorDef.(vi, color, field, value)` — validate (`field` in
`[\inst,\stretch,\octave,\amp,\cutoff,\pan]`; `inst` in the voice's
`~matInstChoices` or nil; `cutoff`/`pan` ignored for unsupported voices); set the
field; force re-source; echo `/matrix/colordef`.
- `~matColorDefPush.(vi)` — send a voice's 6 color defs to surfaces
(`/matrix/colordefs vi <flattened fields>`).
- `~matAudition.(vi, on)` — when on, a Routine on the shared clock cycles
`color = 1..6` calling `~matApplyBar`-style application for that one voice (~1
beat/step), so all 6 defs are heard in a loop; when off, stop the routine and the
voice's Pdef. Preview only (does not touch the grid playhead).
- OSCdefs: `/matrix/colordef <vi> <color> <field> <value>`,
`/matrix/colordefs/get <vi>` (→ `~matColorDefPush`), `/matrix/audition <vi> <on>`.
### Bridge (`web_realart/server.js`)
- No change (`/matrix/*` already relayed).
### Web (`web_realart/public/control/`)
- An "edit" button (⋯) next to each voice's instrument `<select>` (from the
instrument-selection plan) opens a modal for that voice.
- Modal: voice name + 6 rows (colors 1-6, tinted with the `m1`-`m6` hue). Each row:
instrument `<select>` (voice's curated choices), `stretch` (0.5/1/2 select),
`octave` (-1/0/+1 select), `amp` range, and — for supported voices — `cutoff`
range, `pan` range. A `colorDef` change sends `/matrix/colordef vi color field
value` and updates local persisted state.
- Audition Play/Stop toggle → `/matrix/audition vi on`.
- WS handlers: `/matrix/colordef` (single field echo) and `/matrix/colordefs`
(bulk, for preset load) update the modal + state.
- Modal opens populated from local state; `/matrix/colordefs/get` requested on open
to sync from SC.
## Persistence
`~matColorDefs` is stored in the `.matrix` Event (the format established by the
instrument-selection plan) as a `colorDefs` field (16 voices × 6 colors × fields).
Load defaults missing/invalid defs to the default table → backward-compatible with
grid-only and instrument-only `.matrix` files. Web mirrors color defs in the matrix
localStorage object alongside grid/inst/mod.
## Error handling / robustness
- `/matrix/colordef` with bad `vi`/`color`/`field`, an `inst` not in the voice's
choices, or `cutoff`/`pan` on an unsupported voice → ignored.
- Audition while the grid is playing: audition drives only the one voice's Pdef for
preview; stopping audition restores normal grid-driven behavior on the next bar
(force re-source). Document that audition is a preview tool, best used while the
grid for that voice is not actively playing.
- nil-guarded, idempotent reload; defaults reproduce current behavior exactly.
## Testing
- **SC** (`data_only/test/test_matrix.scd`, headless, P:0 B:0):
- `~matColorDefs` initialized; the DEFAULT defs reproduce the current variation —
assert a consumed `~matVariation.(\acid, 2, vi)` event has `\stretch == 0.5`,
`\octave == 0`, `\amp == 1.0` (color 2 default), proving parity.
- `~matSetColorDef.(vi, 3, \octave, -2)` changes the def; consuming the stream
reflects it; an invalid field/inst is rejected.
- `~matAudition` defined and `~matAudition.(0, false)` does not raise.
- Persistence round-trip: set a color def, save, clear, load → restored;
grid-only legacy file → defaults.
- **Web**: manual — open the modal from a voice, edit a color's stretch/amp and
hear it on the next bar; audition cycles the 6 colors; save/reload a preset and
confirm color defs restore.
## Out of scope
- Envelope/drive/deep synth-param editing; the 6-step as an independent live loop
or nested sub-steps; editing colors globally (this is per-voice); adding new
colors beyond 1-6.
+42 -8
View File
@@ -4,8 +4,8 @@
// A playhead Routine advances bar-by-bar on the launchpad clock, applying cells.
// Colors 1-6 map to Pchain overlays (\stretch / \octave / \amp). 0 = Pdef.stop.
// Loaded after sections.scd (boot step 6i). Idempotent reload, nil-guarded.
// NOTE: the \amp variation override bypasses ~lpVol per-voice volume; web vol sliders
// have no effect while a matrix variation plays — deferred to v2.
// NOTE: the \amp variation is multiplied by the per-voice fader (~lp[\vol]) so the
// web mixer (/launch/vol) controls matrix output in real time.
(
// -- Env init (nil-guarded; idempotent reload) --
@@ -35,6 +35,8 @@
~matModNeutralCut = ~matModNeutralCut ? IdentityDictionary[
\acid->700, \arp->3000, \lead->4000, \stab->2200, \pad->1500
];
~lp[\matLoopStart] = ~lp[\matLoopStart] ? 0;
~lp[\matLoopEnd] = ~lp[\matLoopEnd] ? (~matBars - 1);
// -- Persistence env init (nil-guarded) --
~matDir = ~matDir ? "~/.config/av-live/matrices".standardizePath;
@@ -159,6 +161,8 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd });
][color];
spec.notNil.if({
var inst = ~matInstruments[vi];
// per-voice volume (mixer fix from main): read live, default 0.8
var volOf = { (~lp[\vol].notNil).if({ ~lp[\vol][name.asSymbol] ? 0.8 }, { 0.8 }) };
var instPair = inst.notNil.if({ [\instrument, inst] }, { [] });
var mod = ~matMod[vi];
var modPairs = mod.isNil.if({ [] }, {
@@ -170,8 +174,8 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd });
}, {
(tgt == \pan).if({
[\pan, Pfunc { (~matModSourceVal.(src) * 2 - 1) * d }]
}, { // amp: multiply the variation amp
[\amp, Pfunc { spec[\amp] * (1 + (d * (~matModSourceVal.(src) * 2 - 1))).max(0) }]
}, { // amp: variation amp x per-voice volume x mod factor
[\amp, Pfunc { (spec[\amp] * volOf.value) * (1 + (d * (~matModSourceVal.(src) * 2 - 1))).max(0) }]
})
})
});
@@ -183,7 +187,7 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd });
},
\stretch, spec[\stretch],
\octave, spec[\octave],
\amp, spec[\amp]
\amp, Pfunc({ (spec[\amp] ? 1.0) * volOf.value })
] ++ instPair ++ modPairs)),
base
)
@@ -192,6 +196,12 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd });
})
};
// -- ~matNextBar : compute next playhead bar clipped within the loop region --
// Wraps nb back to loop start when nb overshoots le or undershoots ls.
~matNextBar = { var nb = ~lp[\matBar] + 1; var ls = ~lp[\matLoopStart] ? 0; var le = ~lp[\matLoopEnd] ? (~matBars - 1);
((nb > le) or: { nb < ls }).if({ nb = ls });
nb.clip(0, ~matBars - 1) };
// -- ~matApplyBar : set each voice's Pdef to its cell color and (re)play or stop --
// Re-sourcing a playing Pdef updates it at the next quant boundary (smooth for v1).
~matApplyBar = { |bar|
@@ -233,6 +243,19 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd });
~toscSend !? { ~toscSend.("/matrix/trig", vi, amp.clip(0, 1)) }
};
// -- ~matLoopPush : send current loop region to surfaces --
~matLoopPush = { ~toscSend !? { ~toscSend.("/matrix/loop", ~lp[\matLoopStart] ? 0, ~lp[\matLoopEnd] ? (~matBars - 1)) } };
// -- ~matSetLoop : set loop region (auto-ordered) and push to surfaces --
~matSetLoop = { |s, e| var a = s.asInteger.clip(0, ~matBars - 1); var b = e.asInteger.clip(0, ~matBars - 1);
(a <= b).if({ ~lp[\matLoopStart] = a; ~lp[\matLoopEnd] = b }, { ~lp[\matLoopStart] = b; ~lp[\matLoopEnd] = a });
~matLoopPush.() };
// -- ~matSeek : jump playhead to bar; re-applies if playing --
~matSeek = { |bar| ~lp[\matBar] = bar.asInteger.clip(0, ~matBars - 1);
~toscSend !? { ~toscSend.("/matrix/playhead", ~lp[\matBar]) };
~lp[\matPlaying].if({ ~matApplyBar.(~lp[\matBar]) }) };
// -- ~matStop : halt the playhead Routine and silence all matrix voices --
~matStop = {
~matRoutine !? { ~matRoutine.stop };
@@ -250,13 +273,13 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd });
var clock = ~lp[\clock] ? TempoClock.default;
~matRoutine !? { ~matRoutine.stop };
~lp[\matPlaying] = true;
~lp[\matBar] = 0;
~lp[\matBar] = ~lp[\matLoopStart] ? 0;
~matRoutine = Routine({
loop {
~matApplyBar.(~lp[\matBar]);
~toscSend !? { ~toscSend.("/matrix/playhead", ~lp[\matBar]) };
~matBeatsPerBar.wait;
~lp[\matBar] = (~lp[\matBar] + 1) % ~matBars
~lp[\matBar] = ~matNextBar.value;
}
}).play(clock, quant: ~matBeatsPerBar);
~matPush.()
@@ -356,7 +379,8 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd });
~matListPush = {
try {
var names = (~matNames.(~matPresetDir) ++ ~matNames.(~matDir)).as(Set).asArray.sort;
~toscSend !? { ~toscSend.valueArray(["/matrix/list"] ++ names) }
~toscSend !? { ~toscSend.valueArray(["/matrix/list"] ++ names) };
~matLoopPush.()
} { |e|
("[matrix] listPush err: " ++ e.class.name).postln
}
@@ -482,6 +506,16 @@ OSCdef(\mat_mod, { |msg, time, addr|
(msg[3] ? \none).asSymbol, (msg[4] ? 0).asFloat)
}, '/matrix/mod');
OSCdef(\mat_loop, { |msg, time, addr|
~toscTouch !? { ~toscTouch.(addr) };
~matSetLoop.(msg[1].asInteger, msg[2].asInteger)
}, '/matrix/loop');
OSCdef(\mat_seek, { |msg, time, addr|
~toscTouch !? { ~toscTouch.(addr) };
~matSeek.(msg[1].asInteger)
}, '/matrix/seek');
// -- Capture mod source cache (nil-guarded; idempotent reload) --
~matModCache = ~matModCache ? IdentityDictionary.new;
~matModSources = ~matModSources ?
+30
View File
@@ -252,6 +252,36 @@ pass = pass and: { ~matLoad.("mod grid") };
pass = pass and: { ~matMod[5].notNil and: { ~matMod[5][\target] == \cutoff } };
File.delete(~matDir +/+ "mod_grid.matrix");
// -- Loop region --
pass = pass and: { ~matNextBar.notNil and: { ~matSetLoop.notNil and: { ~matSeek.notNil } } };
~matSetLoop.(4, 8);
pass = pass and: { ~lp[\matLoopStart] == 4 };
pass = pass and: { ~lp[\matLoopEnd] == 8 };
// Swapped args must auto-order
~matSetLoop.(10, 6);
pass = pass and: { ~lp[\matLoopStart] == 6 };
pass = pass and: { ~lp[\matLoopEnd] == 10 };
// Wrap: loop 2..4
~matSetLoop.(2, 4);
~lp[\matBar] = 4;
pass = pass and: { ~matNextBar.value == 2 }; // at le -> wraps to loop start
~lp[\matBar] = 2;
pass = pass and: { ~matNextBar.value == 3 }; // inside region -> advances
~lp[\matBar] = 0;
pass = pass and: { ~matNextBar.value == 2 }; // below ls -> snap to loop start
// Seek
~matSeek.(7);
pass = pass and: { ~lp[\matBar] == 7 };
~matSeek.(99);
pass = pass and: { ~lp[\matBar] == 31 }; // clamped to ~matBars - 1
// Reset loop to full range
~matSetLoop.(0, 31);
pass.if(
{ "TEST PASS".postln },
{ "TEST FAIL".postln }
+24
View File
@@ -139,3 +139,27 @@ button.queued { animation: queued-blink 600ms ease-in-out infinite alternate; }
display: flex; gap: 2px; align-items: center; flex-shrink: 0; padding-left: 4px; }
.mmod-src, .mmod-tgt { width: 64px; font-size: 9px; background: #1a1a1a; color: #ccc; border: 1px solid #333; }
.mmod-dep { width: 56px; }
.mat-loop-full-btn { width: auto; padding: 8px 14px; font-size: 13px; margin: 4px 0;
background: #1a1a2e; border-color: #335; color: #9af; }
/* --- Matrix timeline header --- */
.matrix-timeline { display: flex; gap: 1px; margin: 2px 0; touch-action: none; }
.tl-spacer { width: 54px; flex-shrink: 0; }
.tl-bar { width: 22px; min-width: 22px; height: 20px; flex-shrink: 0;
background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 2px;
cursor: pointer; font-size: 9px; color: #555; text-align: center;
line-height: 20px; user-select: none; -webkit-user-select: none; }
.tl-bar.in-loop { background: #1a2a1a; border-color: #2a5a2a; color: #4d9; }
.tl-bar.tl-head { background: #fa0; border-color: #fc0; color: #000; }
.tl-bar.tl-head.in-loop { background: #fa0; border-color: #fc0; color: #000; }
/* --- Matrix mixer: 16-strip volume + mute --- */
.matrix-mixer { display: flex; gap: 4px; margin: 12px 0; overflow-x: auto;
-webkit-overflow-scrolling: touch; padding-bottom: 4px; }
.mix-strip { display: flex; flex-direction: column; align-items: center; gap: 4px;
min-width: 40px; flex-shrink: 0; }
.mix-fader { writing-mode: vertical-lr; direction: rtl; width: 28px; height: 110px;
accent-color: #4c8; cursor: pointer; -webkit-appearance: slider-vertical; appearance: slider-vertical; }
.mix-mute { width: auto; padding: 4px 6px; font-size: 11px; }
.mix-mute.on { background: #622; border-color: #c44; color: #f88; font-weight: 700; }
.mix-label { font-size: 10px; color: #9af; text-align: center; word-break: break-all; }
+186
View File
@@ -143,6 +143,19 @@ ws.addEventListener("message", (ev) => {
const el = cellRefs[matPlayhead][vi];
if (el) el.classList.add("playing");
}
updateTimelineHead(bar);
return;
}
// /matrix/loop <start> <end> — loop region feedback
if (address === "/matrix/loop") {
const s = Math.round(Number(args[0]));
const e = Math.round(Number(args[1]));
if (Number.isFinite(s) && Number.isFinite(e) && s >= 0 && e < MATRIX_BARS) {
matLoopStart = Math.min(s, e);
matLoopEnd = Math.max(s, e);
updateTimelineLoop();
}
return;
}
@@ -278,6 +291,8 @@ 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;
let matLoopStart = 0;
let matLoopEnd = 31;
function saveMatState() {
try { localStorage.setItem(MATRIX_STORAGE_KEY,
@@ -395,6 +410,170 @@ function renderMatrix() {
}
}
// --- Matrix timeline: 32-bar loop region + playhead ---
function renderTimeline() {
const tl = document.getElementById("matrix-timeline");
if (!tl) return;
tl.innerHTML = "";
const spacer = document.createElement("span");
spacer.className = "tl-spacer";
tl.appendChild(spacer);
for (let bar = 0; bar < MATRIX_BARS; bar++) {
const cell = document.createElement("span");
cell.className = "tl-bar";
cell.dataset.bar = bar;
if (bar % 4 === 0) cell.textContent = String(bar + 1);
if (bar >= matLoopStart && bar <= matLoopEnd) cell.classList.add("in-loop");
if (bar === matPlayhead) cell.classList.add("tl-head");
tl.appendChild(cell);
}
}
function updateTimelineLoop() {
const tl = document.getElementById("matrix-timeline");
if (!tl) return;
tl.querySelectorAll(".tl-bar").forEach((cell) => {
const b = +cell.dataset.bar;
cell.classList.toggle("in-loop", b >= matLoopStart && b <= matLoopEnd);
});
}
function updateTimelineHead(bar) {
const tl = document.getElementById("matrix-timeline");
if (!tl) return;
const prev = tl.querySelector(".tl-head");
if (prev) prev.classList.remove("tl-head");
const next = tl.querySelector(`.tl-bar[data-bar="${bar}"]`);
if (next) next.classList.add("tl-head");
}
function initTimelinePointer() {
const tl = document.getElementById("matrix-timeline");
if (!tl) return;
let dragStart = -1;
let dragging = false;
function barFromPoint(x, y) {
const el = document.elementFromPoint(x, y);
if (!el) return -1;
const barEl = el.classList && el.classList.contains("tl-bar")
? el
: (el.closest ? el.closest(".tl-bar") : null);
if (!barEl) return -1;
const b = +barEl.dataset.bar;
return (Number.isFinite(b) && b >= 0 && b < MATRIX_BARS) ? b : -1;
}
function previewLoop(start, end) {
const lo = Math.min(start, end);
const hi = Math.max(start, end);
tl.querySelectorAll(".tl-bar").forEach((cell) => {
const b = +cell.dataset.bar;
cell.classList.toggle("in-loop", b >= lo && b <= hi);
});
}
tl.addEventListener("pointerdown", (e) => {
const bar = barFromPoint(e.clientX, e.clientY);
if (bar < 0) return;
e.preventDefault();
tl.setPointerCapture(e.pointerId);
dragStart = bar;
dragging = true;
});
tl.addEventListener("pointermove", (e) => {
if (!dragging) return;
const cur = barFromPoint(e.clientX, e.clientY);
if (cur >= 0) previewLoop(dragStart, cur);
});
tl.addEventListener("pointerup", (e) => {
if (!dragging) return;
dragging = false;
tl.releasePointerCapture(e.pointerId);
const end = barFromPoint(e.clientX, e.clientY);
const validEnd = end >= 0 ? end : dragStart;
if (validEnd === dragStart) {
send("/matrix/seek", dragStart);
} else {
const lo = Math.min(dragStart, validEnd);
const hi = Math.max(dragStart, validEnd);
send("/matrix/loop", lo, hi);
}
dragStart = -1;
});
tl.addEventListener("pointercancel", () => {
dragging = false;
dragStart = -1;
updateTimelineLoop();
});
}
// --- Matrix mixer: 16-voice per-strip volume + mute ---
const mixerLevel = {};
const mixerMuted = {};
MATRIX_VOICES.forEach(v => { mixerLevel[v] = 0.8; mixerMuted[v] = false; });
function renderMixer() {
const container = document.getElementById("matrix-mixer");
if (!container) return;
container.innerHTML = "";
MATRIX_VOICES.forEach(voice => {
const strip = document.createElement("div");
strip.className = "mix-strip";
strip.dataset.voice = voice;
const fader = document.createElement("input");
fader.type = "range";
fader.min = "0";
fader.max = "1.5";
fader.step = "0.05";
fader.value = String(mixerLevel[voice]);
fader.className = "mix-fader";
fader.dataset.voice = voice;
const muteBtn = document.createElement("button");
muteBtn.className = "mix-mute";
muteBtn.textContent = "M";
const label = document.createElement("span");
label.className = "mix-label";
label.textContent = voice;
fader.addEventListener("input", () => {
const v = +fader.value;
mixerLevel[voice] = v;
// Moving the fader unmutes + sends immediately
if (mixerMuted[voice]) {
mixerMuted[voice] = false;
strip.classList.remove("muted");
muteBtn.classList.remove("on");
}
send("/launch/vol", voice, v);
});
muteBtn.addEventListener("click", () => {
mixerMuted[voice] = !mixerMuted[voice];
if (mixerMuted[voice]) {
muteBtn.classList.add("on");
strip.classList.add("muted");
send("/launch/vol", voice, 0);
} else {
muteBtn.classList.remove("on");
strip.classList.remove("muted");
send("/launch/vol", voice, mixerLevel[voice]);
}
});
strip.appendChild(fader);
strip.appendChild(muteBtn);
strip.appendChild(label);
container.appendChild(strip);
});
}
// --- Matrix audio-reactive glow (trigger envelope; math in matrix_glow.js) ---
const voiceLevel = new Array(MATRIX_VOICES.length).fill(0);
let glowRaf = null;
@@ -620,6 +799,9 @@ document.addEventListener("DOMContentLoaded", () => {
renderRhyGrid();
renderMelSteps();
renderMatrix();
renderTimeline();
initTimelinePointer();
renderMixer();
// Name input: rename selected preset and update button labels
const melName = document.getElementById("mel-name");
@@ -723,6 +905,10 @@ document.addEventListener("DOMContentLoaded", () => {
const sceneNext = document.getElementById("scene-next");
if (sceneNext) sceneNext.addEventListener("click", () => send("/scene/next"));
// Matrix loop full reset
const matLoopFull = document.getElementById("matrix-loop-full");
if (matLoopFull) matLoopFull.addEventListener("click", () => send("/matrix/loop", 0, MATRIX_BARS - 1));
// Matrix transport
const matPlay = document.getElementById("matrix-play");
if (matPlay) matPlay.addEventListener("click", () => send("/matrix/play"));
+3
View File
@@ -169,9 +169,12 @@
<button id="matrix-load" class="mat-action-btn">CHARGER</button>
<button id="matrix-refresh" class="mat-refresh-btn" title="Rafraichir la liste">&#8635;</button>
</div>
<button id="matrix-loop-full" class="mat-action-btn mat-loop-full-btn">BOUCLE COMPLETE</button>
<div class="matrix-scroll">
<div id="matrix-timeline" class="matrix-timeline"></div>
<div id="matrix-grid"></div>
</div>
<div id="matrix-mixer" class="matrix-mixer"></div>
</section>
</div>
<script type="module">import * as G from "./matrix_glow.js"; window.MatrixGlow = G;</script>