diff --git a/sound_algo/data_only/matrix.scd b/sound_algo/data_only/matrix.scd index b3ca606..86127b1 100644 --- a/sound_algo/data_only/matrix.scd +++ b/sound_algo/data_only/matrix.scd @@ -35,6 +35,19 @@ ~matModNeutralCut = ~matModNeutralCut ? IdentityDictionary[ \acid->700, \arp->3000, \lead->4000, \stab->2200, \pad->1500 ]; + +// -- default color defs reproduce the historical global variation table -- +~matDefaultColorDefs = { + [ nil, + (stretch: 1.0, octave: 0, amp: 1.0, inst: nil, cutoff: nil, pan: nil), + (stretch: 0.5, octave: 0, amp: 1.0, inst: nil, cutoff: nil, pan: nil), + (stretch: 1.0, octave: 1, amp: 1.0, inst: nil, cutoff: nil, pan: nil), + (stretch: 2.0, octave: 0, amp: 1.0, inst: nil, cutoff: nil, pan: nil), + (stretch: 1.0, octave: -1, amp: 1.05, inst: nil, cutoff: nil, pan: nil), + (stretch: 0.5, octave: 0, amp: 1.2, inst: nil, cutoff: nil, pan: nil) ] +}; +~matColorDefs = ~matColorDefs ? Array.fill(~matVoices.size, { ~matDefaultColorDefs.value }); + ~lp[\matLoopStart] = ~lp[\matLoopStart] ? 0; ~lp[\matLoopEnd] = ~lp[\matLoopEnd] ? (~matBars - 1); @@ -144,66 +157,67 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd }); }) }; +// -- ~matVariationOverlay : assemble the Pchain overlay given a resolved spec -- +// Factored out of ~matVariation so the main function stays readable. +// cdPairs (static per-color cutoff/pan) come before modPairs so a live capture +// mod on the same target overrides the static color-def value — intended. +~matVariationOverlay = { |name, vi, spec, volOf, instPair, cdPairs| + var base = ~matBaseFor.(name); + var mod = ~matMod[vi]; + // octave colors multiply \freq by 2**octave (main's freqRatio scheme), pitched + // voices only — works for voices that set \freq directly via Pfunc. + var pitched = [\sub, \acid, \arp, \lead, \stab, \pad, \reese, \bells, \perc, \tom]; + var freqRatio = 2 ** (spec[\octave] ? 0); + var freqPair = (pitched.includes(name) and: { freqRatio != 1.0 }).if({ + [\freq, Pfunc({ |e| (e[\freq] ? 440) * freqRatio })] + }, { [] }); + var modPairs = mod.isNil.if({ [] }, { + var src = mod[\source], tgt = mod[\target], d = mod[\depth]; + (tgt == \cutoff).if({ + var c0 = ~matModNeutralCut[name] ? 1000; + [\cutoff, Pfunc { var s = ~matModSourceVal.(src); + c0 * (s.linexp(0, 1, 200, 6000) / c0).pow(d) }] + }, { + (tgt == \pan).if({ + [\pan, Pfunc { (~matModSourceVal.(src) * 2 - 1) * d }] + }, { + [\amp, Pfunc { (spec[\amp] * volOf.value) * (1 + (d * (~matModSourceVal.(src) * 2 - 1))).max(0) }] + }) + }) + }); + Pchain( + Pbind(*([ + \matGlow, Pfunc { |e| ~matEmitTrig.(vi, (e[\amp] ? spec[\amp]).clip(0, 1)); 0 }, + \stretch, spec[\stretch], + \amp, Pfunc({ (spec[\amp] ? 1.0) * volOf.value }) + ] ++ instPair ++ cdPairs ++ freqPair ++ modPairs)), + base + ) +}; + // -- ~matVariation : map color 1-6 to a Pchain overlay on the captured base -- // color 1 = base as-is, 2 = double-time, 3 = octave up, 4 = half-time, // 5 = octave down + slight accent, 6 = accent roll (denser + louder). -// \stretch multiplies event duration; octave colors multiply the base \freq -// (x2 up, x0.5 down) over the base event, so they transpose even voices that set -// \freq directly via Pfunc (which bypass SC's \octave). Only pitched voices get -// the \freq multiply — drums keep their pitch (and any instrument override). -// Single function so all variation logic lives in one place. +// \stretch multiplies event duration; octave colors multiply the base \freq by +// 2**octave (x2 up, x0.5 down) via Pfunc, so they transpose even voices that set +// \freq directly (bypassing SC's \octave). Only pitched voices get the \freq +// multiply — drums keep their pitch. Reads ~matColorDefs[vi][color] so per-voice +// color tables are editable at runtime (octave field maps to the freq multiply). ~matVariation = { |name, color, vi = 0| (color == 0).if({ nil }, { var base = ~matBaseFor.(name); base.notNil.if({ - var spec = [ - nil, - (stretch: 1.0, freqRatio: 1.0, amp: 1.0), - (stretch: 0.5, freqRatio: 1.0, amp: 1.0), - (stretch: 1.0, freqRatio: 2.0, amp: 1.0), - (stretch: 2.0, freqRatio: 1.0, amp: 1.0), - (stretch: 1.0, freqRatio: 0.5, amp: 1.05), - (stretch: 0.5, freqRatio: 1.0, amp: 1.2) - ][color]; + var spec = ~matColorDefs[vi][color]; spec.notNil.if({ - var inst = ~matInstruments[vi]; - // per-voice volume (mixer fix from main): read live, default 0.8 + var inst = spec[\inst] ? ~matInstruments[vi]; // per-color wins, else voice default var volOf = { (~lp[\vol].notNil).if({ ~lp[\vol][name.asSymbol] ? 0.8 }, { 0.8 }) }; var instPair = inst.notNil.if({ [\instrument, inst] }, { [] }); - // octave colors: multiply the base \freq, pitched voices only. - // gating avoids forcing a pitch onto drums (incl. drum voices - // remapped to a pitched instrument override). - var pitched = [\sub, \acid, \arp, \lead, \stab, \pad, \reese, \bells, \perc, \tom]; - var freqRatio = spec[\freqRatio] ? 1.0; - var freqPair = (pitched.includes(name) and: { freqRatio != 1.0 }).if({ - [\freq, Pfunc({ |e| (e[\freq] ? 440) * freqRatio })] - }, { [] }); - var mod = ~matMod[vi]; - var modPairs = mod.isNil.if({ [] }, { - var src = mod[\source], tgt = mod[\target], d = mod[\depth]; - (tgt == \cutoff).if({ - var c0 = ~matModNeutralCut[name] ? 1000; - [\cutoff, Pfunc { var s = ~matModSourceVal.(src); - c0 * (s.linexp(0, 1, 200, 6000) / c0).pow(d) }] - }, { - (tgt == \pan).if({ - [\pan, Pfunc { (~matModSourceVal.(src) * 2 - 1) * d }] - }, { // amp: variation amp x per-voice volume x mod factor - [\amp, Pfunc { (spec[\amp] * volOf.value) * (1 + (d * (~matModSourceVal.(src) * 2 - 1))).max(0) }] - }) - }) - }); - Pchain( - Pbind(*([ - \matGlow, Pfunc { |e| - ~matEmitTrig.(vi, (e[\amp] ? spec[\amp]).clip(0, 1)); - 0 - }, - \stretch, spec[\stretch], - \amp, Pfunc({ (spec[\amp] ? 1.0) * volOf.value }) - ] ++ freqPair ++ instPair ++ modPairs)), - base - ) + var cdPairs = []; + (spec[\cutoff].notNil and: { (~matModTargets[name] ? []).includes(\cutoff) }).if({ + cdPairs = cdPairs ++ [\cutoff, spec[\cutoff]] }); + (spec[\pan].notNil and: { (~matModTargets[name] ? []).includes(\pan) }).if({ + cdPairs = cdPairs ++ [\pan, spec[\pan]] }); + ~matVariationOverlay.(name, vi, spec, volOf, instPair, cdPairs) }, { base }) }, { nil }) }) @@ -378,6 +392,50 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd }); } }; +// -- ~matSetColorDef : set one field of one voice/color, validate, re-source, echo -- +~matSetColorDef = { |vi, color, field, value| + var fields = [\stretch, \octave, \amp, \inst, \cutoff, \pan]; + ((vi >= 0 and: { vi < ~matVoices.size }) and: { color >= 1 and: { color <= 6 } } + and: { fields.includes(field.asSymbol) }).if({ + var name = ~matVoices[vi]; + var ok = true; + var v = value; + (field.asSymbol == \inst).if({ + v = ((value.asSymbol == \default) or: { value.isNil }).if({ nil }, { value.asSymbol }); + ok = v.isNil or: { (~matInstChoices[name] ? []).includes(v) }; + }); + ((field.asSymbol == \cutoff) or: { field.asSymbol == \pan }).if({ + ok = (~matModTargets[name] ? []).includes(field.asSymbol); + v = value.asFloat; + }); + ((field.asSymbol == \stretch) or: { field.asSymbol == \octave } + or: { field.asSymbol == \amp }).if({ + v = (field.asSymbol == \octave).if({ value.asInteger }, { value.asFloat }); + }); + ok.if({ + ~matColorDefs[vi][color][field.asSymbol] = v; + ~matLastColor[vi] = -1; + (~lp[\matPlaying] and: { ~matApplyBar.notNil }).if({ ~matApplyBar.(~lp[\matBar]) }); + ~matColorDefPush.(vi) + }) + }) +}; + +// -- ~matColorDefPush : send one voice's 6 color defs flattened to surfaces -- +// flat layout per color (1..6): stretch, octave, amp, inst(str), cutoff(-1=nil), pan(-2=nil) +// Distinct OUTBOUND address (plural) so it never collides with the 4-arg inbound +// /matrix/colordef set (mirrors /matrix/instrument vs /matrix/instruments). +~matColorDefPush = { |vi| + ~toscSend !? { + var flat = (1..6).collect({ |c| + var d = ~matColorDefs[vi][c]; + [ d[\stretch], d[\octave], d[\amp], (d[\inst] ? \default).asString, + d[\cutoff] ? -1, d[\pan] ? -2 ] + }).flatten; + ~toscSend.valueArray(["/matrix/colordefs", vi] ++ flat) + } +}; + // -- ~matModPush : send 16 mod triples (source,target,depth) flattened -- ~matModPush = { ~toscSend !? { @@ -407,7 +465,8 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd }); grid: ~lp[\matrix], instruments: ~matInstruments.collect({ |x| (x ? \default) }), mods: ~matMod.collect({ |m| m.isNil.if({ \none }, - { [m[\source], m[\target], m[\depth]] }) }) + { [m[\source], m[\target], m[\depth]] }) }), + colorDefs: ~matColorDefs.collect({ |defs| (1..6).collect({ |c| defs[c] }) }) ); File.use(path, "w", { |f| f.write(payload.asCompileString) }); ~matSavedNames.add(safe.asSymbol); @@ -443,6 +502,21 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd }); }) } }); + // restore color defs (default missing/legacy to the historical table) + ~matColorDefs = Array.fill(~matVoices.size, { ~matDefaultColorDefs.value }); + ((raw.isArray.not) and: { raw[\colorDefs].notNil }).if({ + raw[\colorDefs].do { |voiceDefs, vi| + (voiceDefs.isArray and: { vi < ~matVoices.size }).if({ + voiceDefs.do { |d, ci| + (d.isKindOf(Event)).if({ + [\stretch, \octave, \amp, \inst, \cutoff, \pan].do { |f| + d[f].notNil.if({ ~matColorDefs[vi][ci + 1][f] = d[f] }) + } + }) + } + }) + } + }); ~lp[\matPlaying].if({ ~matApplyBar.(~lp[\matBar]) }); ~matGridPush.(); ~matInstrPush.(); @@ -467,6 +541,35 @@ File.exists(~matDir).not.if({ ("mkdir -p " ++ ~matDir.quote).systemCmd }); File.exists(up).if({ ~matLoadFile.(up) }, { ~matLoadFile.(pp) }) }; +// -- ~matAudition : preview a voice's 6 colors as a 1-beat-per-step loop -- +// on=true starts a Routine cycling color 1..6 (re-sources the voice's Pdef each +// step); on=false stops the routine and silences the voice. Preview only — does +// not move the grid playhead. +~matAudition = { |vi, on| + (vi >= 0 and: { vi < ~matVoices.size }).if({ + var name = ~matVoices[vi]; + var key = ("lp_" ++ name).asSymbol; + ~matAudRoutine = ~matAudRoutine ? IdentityDictionary.new; + ~matAudRoutine[vi] !? { ~matAudRoutine[vi].stop }; + on.if({ + var clock = ~lp[\clock] ? TempoClock.default; + ~matAudRoutine[vi] = Routine({ + var c = 1; + loop { + var pat = ~matVariation.(name, c, vi); + pat.notNil.if({ Pdef(key, pat); Pdef(key).play(clock, quant: 1) }); + 1.wait; + c = (c % 6) + 1; + } + }).play(clock, quant: 1); + }, { + ~matAudRoutine[vi] = nil; + Pdef.all.includesKey(key).if({ Pdef(key).stop }); + ~matLastColor[vi] = -1; // force normal re-source on next grid bar + }) + }) +}; + // -- OSCdefs (unique \mat_* keys; register sender for TouchOSC feedback) -- OSCdef(\mat_cell, { |msg, time, addr| ~toscTouch !? { ~toscTouch.(addr) }; @@ -529,6 +632,22 @@ OSCdef(\mat_seek, { |msg, time, addr| ~matSeek.(msg[1].asInteger) }, '/matrix/seek'); +OSCdef(\mat_colordef, { |msg, time, addr| + ~toscTouch !? { ~toscTouch.(addr) }; + ~matSetColorDef.((msg[1] ? 0).asInteger, (msg[2] ? 1).asInteger, + (msg[3] ? \amp).asSymbol, msg[4]) +}, '/matrix/colordef'); + +OSCdef(\mat_colordef_get, { |msg, time, addr| + ~toscTouch !? { ~toscTouch.(addr) }; + ~matColorDefPush.((msg[1] ? 0).asInteger) +}, '/matrix/colordefs/get'); + +OSCdef(\mat_audition, { |msg, time, addr| + ~toscTouch !? { ~toscTouch.(addr) }; + ~matAudition.((msg[1] ? 0).asInteger, (msg[2] ? 0) > 0) +}, '/matrix/audition'); + // -- Capture mod source cache (nil-guarded; idempotent reload) -- ~matModCache = ~matModCache ? IdentityDictionary.new; ~matModSources = ~matModSources ? diff --git a/sound_algo/data_only/test/test_matrix.scd b/sound_algo/data_only/test/test_matrix.scd index e2b7adf..e31bbba 100644 --- a/sound_algo/data_only/test/test_matrix.scd +++ b/sound_algo/data_only/test/test_matrix.scd @@ -282,6 +282,87 @@ pass = pass and: { ~lp[\matBar] == 31 }; // 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 } }; +// 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 }; +pass = pass and: { ~matColorDefs[5][2][\amp] == 1.0 }; +pass = pass and: { ~matColorDefs[5][5][\amp] == 1.05 }; // color 5 default amp +pass = pass and: { ~matColorDefs[0][1][\inst].isNil }; // inst default nil +// ~matVariation reads the def: editing the def changes the produced event +~matColorDefs[5][2][\stretch] = 0.25; +~tv = ~matVariation.(\acid, 2, 5).asStream.next(()); +pass = pass and: { ~tv[\stretch] == 0.25 }; +~matColorDefs[5][2][\stretch] = 0.5; // restore default +// per-color cutoff applies for a filter voice (acid supports cutoff) +~matColorDefs[5][2][\cutoff] = 1234; +~tv2 = ~matVariation.(\acid, 2, 5).asStream.next(()); +pass = pass and: { ~tv2[\cutoff] == 1234 }; +~matColorDefs[5][2][\cutoff] = nil; +// per-color inst overrides the per-voice default +~matColorDefs[6][2][\inst] = \do_strike; // arp supports do_strike +~tv3 = ~matVariation.(\arp, 2, 6).asStream.next(()); +pass = pass and: { ~tv3[\instrument] == \do_strike }; +~matColorDefs[6][2][\inst] = nil; + +// --- Color editor: set + push (Task 2) --- +pass = pass and: { ~matSetColorDef.notNil and: { ~matColorDefPush.notNil } }; +~trigLog = nil; +~toscSend = { |path ...args| ~trigLog = ([path] ++ args) }; +~matSetColorDef.(5, 3, \octave, -2); // acid, color 3 +pass = pass and: { ~matColorDefs[5][3][\octave] == -2 }; +pass = pass and: { ~trigLog[0] == "/matrix/colordefs" and: { ~trigLog[1] == 5 } }; +pass = pass and: { ~trigLog.size == 38 }; // path + vi + 36 values + +// direct test of ~matColorDefPush: address, vi, 38 items total, nil pan=-2, nil cutoff=-1 +~trigLog = nil; +~matColorDefPush.(5); +pass = pass and: { ~trigLog[0] == "/matrix/colordefs" }; +pass = pass and: { ~trigLog[1] == 5 }; +pass = pass and: { ~trigLog.size == 38 }; +pass = pass and: { ~trigLog[(2 + ((1-1)*6) + 5)] == -2 }; // color 1 pan nil -> -2 +pass = pass and: { ~trigLog[(2 + ((1-1)*6) + 4)] == -1 }; // color 1 cutoff nil -> -1 + +// invalid field is rejected (state unchanged) +~matSetColorDef.(5, 3, \bogus, 9); +pass = pass and: { (~matColorDefs[5][3][\bogus]).isNil }; +// invalid inst for the voice is rejected +~matSetColorDef.(0, 1, \inst, \do_plane); // kick can't be do_plane +pass = pass and: { ~matColorDefs[0][1][\inst].isNil }; +~matSetColorDef.(5, 3, \octave, 0); // restore + +// --- Color editor: audition (Task 3) --- +pass = pass and: { ~matAudition.notNil }; +// off when not running must not raise +try { ~matAudition.(0, false) } { |e| pass = false; + ("EXCEPTION matAudition off: " ++ e.class.name).postln }; + +// --- Color editor: persistence (Task 4) --- +~matColorDefs[4][3][\amp] = 0.42; // sub, color 3 +~matSave.("cdef grid"); +~matColorDefs = Array.fill(~matVoices.size, { ~matDefaultColorDefs.value }); +pass = pass and: { ~matLoad.("cdef grid") }; +pass = pass and: { ~matColorDefs[4][3][\amp] == 0.42 }; +File.delete(~matDir +/+ "cdef_grid.matrix"); +// legacy grid-only file loads with default color defs +File.use(~matDir +/+ "cleg.matrix", "w", { |f| + f.write(Array.fill(16, { Array.fill(32, 0) }).asCompileString) }); +~matColorDefs[4][3][\amp] = 0.9; +pass = pass and: { ~matLoad.("cleg") }; +pass = pass and: { ~matColorDefs[4][3][\amp] == 1.0 }; // defaulted (color 3 amp default) +File.delete(~matDir +/+ "cleg.matrix"); + +// REGRESSION: clear a per-color instrument to default via the STRING "default" (web path) +~matColorDefs[6][2][\inst] = \do_strike; // arp, color 2 (do_strike is a valid arp choice) +~matSetColorDef.(6, 2, \inst, "default"); // String, as OSC delivers it +pass = pass and: { ~matColorDefs[6][2][\inst].isNil }; // cleared +// valid inst as a String also works +~matSetColorDef.(6, 2, \inst, "do_strike"); +pass = pass and: { ~matColorDefs[6][2][\inst] == \do_strike }; +~matColorDefs[6][2][\inst] = nil; // restore + pass.if( { "TEST PASS".postln }, { "TEST FAIL".postln } diff --git a/web_realart/public/control/control.css b/web_realart/public/control/control.css index 068855b..7e8d247 100644 --- a/web_realart/public/control/control.css +++ b/web_realart/public/control/control.css @@ -134,6 +134,7 @@ button.queued { animation: queued-blink 600ms ease-in-out infinite alternate; } background: #1a1a1a; color: #ccc; border: 1px solid #333; flex-shrink: 0; } .kit-btn { flex: 1; padding: 10px 4px; font-size: 11px; } .minst-head { width: 78px; flex-shrink: 0; } +.minst-edit-head { width: 22px; flex-shrink: 0; } .mmod-head { width: 192px; flex-shrink: 0; } .mmod { position: sticky; right: 0; z-index: 2; background: #111; display: flex; gap: 2px; align-items: center; flex-shrink: 0; padding-left: 4px; } @@ -163,3 +164,23 @@ button.queued { animation: queued-blink 600ms ease-in-out infinite alternate; } .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; } + +/* --- Color def modal --- */ +.minst-edit { position: sticky; left: 132px; z-index: 2; width: 22px; font-size: 11px; + background: #222; color: #ccc; border: 1px solid #333; flex-shrink: 0; } +.cd-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; + align-items: center; justify-content: center; z-index: 50; } +.cd-modal[hidden] { display: none; } +.cd-panel { background: #161616; border: 1px solid #333; border-radius: 6px; + padding: 12px; max-height: 90vh; overflow-y: auto; min-width: 320px; } +.cd-head { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; } +.cd-aud.on { background: #2b6; color: #fff; } +.cd-row { display: flex; gap: 6px; align-items: center; padding: 4px; border-radius: 3px; + border-left: 4px solid; margin-bottom: 3px; } +.cd-field { font-size: 9px; color: #999; display: flex; flex-direction: column; } +.cd-row.m1 { border-left-color: #c22; } +.cd-row.m2 { border-left-color: #1a8; } +.cd-row.m3 { border-left-color: #25b; } +.cd-row.m4 { border-left-color: #990; } +.cd-row.m5 { border-left-color: #c60; } +.cd-row.m6 { border-left-color: #82a; } diff --git a/web_realart/public/control/control.js b/web_realart/public/control/control.js index 2033eb1..489db73 100644 --- a/web_realart/public/control/control.js +++ b/web_realart/public/control/control.js @@ -256,6 +256,20 @@ ws.addEventListener("message", (ev) => { saveMatState(); return; } + if (address === "/matrix/colordefs") { + const vi = Math.round(Number(args[0])); + if (vi >= 0 && vi < 16) { + 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])), + amp:Number(args[b+2]), inst:String(args[b+3] ?? "default"), + cutoff:Number(args[b+4]), pan:Number(args[b+5]) }; + } + saveMatState(); + if (cdEditVoice === vi && !document.getElementById("colordef-modal").hidden) renderColorDefRows(vi); + } + return; + } }); // --- Matrix (16-voice x 32-bar song arranger) --- @@ -287,6 +301,15 @@ const MATRIX_MOD_TARGETS = { bells:["none","amp"], sweep:["none","amp"] }; let matMod = Array.from({length:16}, () => ({source:"none", target:"none", depth:0.5})); +let matColorDefs = Array.from({ length: 16 }, () => + [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}, + {stretch:1.0,octave:1,amp:1.0,inst:"default",cutoff:-1,pan:-2}, + {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 cdEditVoice = -1, cdAuditioning = false; const MATRIX_BARS = 32; let matGrid = Array.from({ length: 16 }, () => new Array(32).fill(0)); // cellRefs[bar][vi] for O(16) playhead column toggle @@ -297,14 +320,16 @@ let matLoopEnd = 31; function saveMatState() { try { localStorage.setItem(MATRIX_STORAGE_KEY, - JSON.stringify({ grid: matGrid, inst: matInst, mod: matMod })); } catch (_e) {} + JSON.stringify({ grid: matGrid, inst: matInst, mod: matMod, 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 === 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; return; } + if (Array.isArray(raw.mod) && raw.mod.length === 16) matMod = raw.mod; + if (Array.isArray(raw.cdef) && raw.cdef.length === 16) matColorDefs = raw.cdef; + return; } if (Array.isArray(raw) && raw.length === 16) { matGrid = raw; return; } // legacy: bare grid } catch (_e) {} matGrid = Array.from({ length: 16 }, () => new Array(32).fill(0)); @@ -330,6 +355,9 @@ function renderMatrix() { const instHead = document.createElement("span"); instHead.className = "minst-head"; headerRow.appendChild(instHead); + const editHead = document.createElement("span"); + editHead.className = "minst-edit-head"; + headerRow.appendChild(editHead); for (let bar = 0; bar < MATRIX_BARS; bar++) { const num = document.createElement("span"); num.className = "mbar-num"; @@ -364,6 +392,10 @@ function renderMatrix() { send("/matrix/instrument", vi, inst.value); }); row.appendChild(inst); + const edit = document.createElement("button"); + edit.className = "minst-edit"; edit.textContent = "..."; edit.dataset.vi = vi; + edit.addEventListener("click", () => openColorDef(vi)); + row.appendChild(edit); for (let bar = 0; bar < MATRIX_BARS; bar++) { const color = matGrid[vi][bar]; const cell = document.createElement("button"); @@ -411,6 +443,63 @@ function renderMatrix() { } } +// --- Color def modal --- +function renderColorDefRows(vi) { + document.getElementById("cd-title").textContent = MATRIX_VOICES[vi]; + const rows = document.getElementById("cd-rows"); + rows.innerHTML = ""; + const targets = MATRIX_MOD_TARGETS[MATRIX_VOICES[vi]] || ["none","amp"]; + const hasCut = targets.includes("cutoff"), hasPan = targets.includes("pan"); + for (let c = 1; c <= 6; c++) { + const d = matColorDefs[vi][c]; + const r = document.createElement("div"); r.className = "cd-row m" + c; + const mk = (label, el) => { const w = document.createElement("label"); + w.className = "cd-field"; w.textContent = label; w.appendChild(el); return w; }; + const inst = document.createElement("select"); inst.className = "cd-inst"; + [["default","default"]].concat((MATRIX_INST_CHOICES[MATRIX_VOICES[vi]]||[]).map(s=>[s,s])) + .forEach(([v,t]) => { const o=document.createElement("option"); o.value=v; o.textContent=t; inst.appendChild(o); }); + inst.value = d.inst || "default"; + inst.addEventListener("change", () => setCD(c, "inst", inst.value)); + const stretch = document.createElement("select"); stretch.className = "cd-str"; + [0.5,1.0,2.0].forEach(v => { const o=document.createElement("option"); o.value=v; o.textContent=v+"x"; stretch.appendChild(o); }); + stretch.value = d.stretch; stretch.addEventListener("change", () => setCD(c, "stretch", +stretch.value)); + const oct = document.createElement("select"); oct.className = "cd-oct"; + [-1,0,1].forEach(v => { const o=document.createElement("option"); o.value=v; o.textContent=v; oct.appendChild(o); }); + oct.value = d.octave; oct.addEventListener("change", () => setCD(c, "octave", +oct.value)); + const amp = document.createElement("input"); amp.type="range"; amp.min=0; amp.max=1.5; amp.step=0.01; + amp.value = d.amp; amp.addEventListener("change", () => setCD(c, "amp", +amp.value)); + r.appendChild(mk("inst", inst)); r.appendChild(mk("stretch", stretch)); + r.appendChild(mk("oct", oct)); r.appendChild(mk("amp", amp)); + if (hasCut) { const cut = document.createElement("input"); cut.type="range"; cut.min=200; cut.max=6000; cut.step=10; + cut.value = d.cutoff > 0 ? d.cutoff : 1000; cut.addEventListener("change", () => setCD(c, "cutoff", +cut.value)); r.appendChild(mk("cutoff", cut)); } + if (hasPan) { const pan = document.createElement("input"); pan.type="range"; pan.min=-1; pan.max=1; pan.step=0.01; + pan.value = d.pan >= -1 ? d.pan : 0; pan.addEventListener("change", () => setCD(c, "pan", +pan.value)); r.appendChild(mk("pan", pan)); } + rows.appendChild(r); + } +} + +function openColorDef(vi) { + cdEditVoice = vi; + document.getElementById("cd-audition").classList.toggle("on", cdAuditioning); + renderColorDefRows(vi); + document.getElementById("colordef-modal").hidden = false; + send("/matrix/colordefs/get", vi); +} + +function setCD(color, field, value) { + if (cdEditVoice < 0) return; + matColorDefs[cdEditVoice][color][field] = value; + saveMatState(); + send("/matrix/colordef", cdEditVoice, color, field, value); +} + +function closeColorDef() { + if (cdAuditioning && cdEditVoice >= 0) { send("/matrix/audition", cdEditVoice, 0); cdAuditioning = false; } + document.getElementById("cd-audition").classList.remove("on"); + document.getElementById("colordef-modal").hidden = true; + cdEditVoice = -1; +} + // --- Matrix timeline: 32-bar loop region + playhead --- function renderTimeline() { const tl = document.getElementById("matrix-timeline"); @@ -804,6 +893,14 @@ document.addEventListener("DOMContentLoaded", () => { initTimelinePointer(); renderMixer(); + document.getElementById("cd-close").addEventListener("click", closeColorDef); + document.getElementById("cd-audition").addEventListener("click", () => { + if (cdEditVoice < 0) return; + cdAuditioning = !cdAuditioning; + send("/matrix/audition", cdEditVoice, cdAuditioning ? 1 : 0); + document.getElementById("cd-audition").classList.toggle("on", cdAuditioning); + }); + // Name input: rename selected preset and update button labels const melName = document.getElementById("mel-name"); if (melName) { diff --git a/web_realart/public/control/index.html b/web_realart/public/control/index.html index aad4c1d..81a38b6 100644 --- a/web_realart/public/control/index.html +++ b/web_realart/public/control/index.html @@ -177,5 +177,13 @@
+