diff --git a/sound_algo/data_only/boot.scd b/sound_algo/data_only/boot.scd index 68884dd..4ff11d9 100644 --- a/sound_algo/data_only/boot.scd +++ b/sound_algo/data_only/boot.scd @@ -198,6 +198,12 @@ Routine({ // -- 6g. TouchOSC feedback bridge ----- (~base ++ "touchosc_feedback.scd").load; s.sync; + // -- 6h. Live song-building sections ----- + (~base ++ "sections.scd").load; s.sync; + + // -- 6i. Matrix arranger ----- + (~base ++ "matrix.scd").load; s.sync; + // -- 7) Demarre la scene par defaut ----- if(~doScene.isNil) { "[data-only/boot] ERREUR : ~doScene non defini".warn; diff --git a/sound_algo/data_only/matrix.scd b/sound_algo/data_only/matrix.scd new file mode 100644 index 0000000..d7a3b58 --- /dev/null +++ b/sound_algo/data_only/matrix.scd @@ -0,0 +1,175 @@ +// matrix.scd — 16-voice x 32-bar song-arrangement matrix +// Additive: reads base Pdefs from launchpad.scd, does NOT modify them. +// Each cell = 0 (off) or 1-6 (a parametric variation color of the voice's pattern). +// 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. +( + +// -- Env init (nil-guarded; idempotent reload) -- +~lp = ~lp ? (); +~matVoices = ~matVoices ? [ + \kick, \hats, \clap, \perc, + \sub, \acid, \arp, \lead, + \stab, \pad, \ride, \rim, + \tom, \reese, \bells, \sweep +]; +~matBars = ~matBars ? 32; +~lp[\matrix] = ~lp[\matrix] ? Array.fill(~matVoices.size, { Array.fill(~matBars, 0) }); +~lp[\matBar] = ~lp[\matBar] ? 0; +~lp[\matPlaying] = ~lp[\matPlaying] ? false; +~matBase = ~matBase ? IdentityDictionary.new; +~matBeatsPerBar = ~matBeatsPerBar ? 4; +~matLastColor = ~matLastColor ? Array.fill(~matVoices.size, -1); + +// -- ~matBaseFor : capture base Pdef source once (lazy, per voice name symbol) -- +// Returns nil when the Pdef is missing (safe for partial environments). +~matBaseFor = { |name| + var key = ("lp_" ++ name).asSymbol; + ~matBase[name] ?? { + ~matBase[name] = Pdef.all.includesKey(key).if( + { Pdef(key).source }, + { nil } + ); + ~matBase[name] + } +}; + +// -- ~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 shifts degree-based pitch. +// NOTE: voices that compute \freq via Pfunc (ignoring \octave) will not respond +// to the octave variation — acceptable for v1; tune the palette at the live gate. +// Single function so all variation logic lives in one place. +~matVariation = { |name, color| + (color == 0).if({ nil }, { + var base = ~matBaseFor.(name); + base.notNil.if({ + var spec = [ + nil, + (stretch: 1.0, octave: 0, amp: 1.0), + (stretch: 0.5, octave: 0, amp: 1.0), + (stretch: 1.0, octave: 1, amp: 1.0), + (stretch: 2.0, octave: 0, amp: 1.0), + (stretch: 1.0, octave: -1, amp: 1.05), + (stretch: 0.5, octave: 0, amp: 1.2) + ][color]; + spec.notNil.if({ + Pchain( + Pbind( + \stretch, spec[\stretch], + \octave, spec[\octave], + \amp, spec[\amp] + ), + base + ) + }, { base }) + }, { nil }) + }) +}; + +// -- ~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| + ~matVoices.do { |name, vi| + var color = ~lp[\matrix][vi][bar]; + var key = ("lp_" ++ name).asSymbol; + Pdef.all.includesKey(key).if({ + (color == 0).if( + { + Pdef(key).stop; + ~matLastColor[vi] = 0 + }, + { + (color != ~matLastColor[vi]).if({ + var pat = ~matVariation.(name, color); + pat.notNil.if({ + ~matLastColor[vi] = color; + Pdef(key, pat); + Pdef(key).play( + ~lp[\clock] ? TempoClock.default, + quant: ~matBeatsPerBar + ) + }) + }) + } + ) + }) + } +}; + +// -- ~matPush : push playhead position to all TouchOSC clients -- +~matPush = { + ~toscSend !? { ~toscSend.("/matrix/playhead", ~lp[\matBar]) } +}; + +// -- ~matStop : halt the playhead Routine and silence all matrix voices -- +~matStop = { + ~matRoutine !? { ~matRoutine.stop }; + ~lp[\matPlaying] = false; + ~matVoices.do { |name| + var k = ("lp_" ++ name).asSymbol; + Pdef.all.includesKey(k).if({ Pdef(k).stop }) + }; + ~matVoices.size.do { |i| ~matLastColor[i] = -1 }; + ~matPush.() +}; + +// -- ~matPlay : start the playhead Routine from bar 0, looping over ~matBars -- +~matPlay = { + var clock = ~lp[\clock] ? TempoClock.default; + ~matRoutine !? { ~matRoutine.stop }; + ~lp[\matPlaying] = true; + ~lp[\matBar] = 0; + ~matRoutine = Routine({ + loop { + ~matApplyBar.(~lp[\matBar]); + ~toscSend !? { ~toscSend.("/matrix/playhead", ~lp[\matBar]) }; + ~matBeatsPerBar.wait; + ~lp[\matBar] = (~lp[\matBar] + 1) % ~matBars + } + }).play(clock, quant: ~matBeatsPerBar); + ~matPush.() +}; + +// -- ~matSetCell : write matrix[vi][bar] = color; apply live if playhead is there -- +~matSetCell = { |vi, bar, color| + (vi >= 0 and: { vi < ~matVoices.size and: { bar >= 0 and: { bar < ~matBars } } }).if({ + ~lp[\matrix][vi][bar] = color.clip(0, 6).asInteger; + (~lp[\matPlaying] and: { bar == ~lp[\matBar] }).if({ ~matApplyBar.(bar) }); + ~toscSend !? { ~toscSend.("/matrix/cell", vi, bar, ~lp[\matrix][vi][bar]) } + }) +}; + +// -- ~matClear : reset all cells to 0 and push state -- +~matClear = { + ~lp[\matrix] = Array.fill(~matVoices.size, { Array.fill(~matBars, 0) }); + ~matPush.() +}; + +// -- OSCdefs (unique \mat_* keys; register sender for TouchOSC feedback) -- +OSCdef(\mat_cell, { |msg, time, addr| + ~toscTouch !? { ~toscTouch.(addr) }; + ~matSetCell.(msg[1].asInteger, msg[2].asInteger, msg[3].asInteger) +}, '/matrix/cell'); + +OSCdef(\mat_play, { |msg, time, addr| + ~toscTouch !? { ~toscTouch.(addr) }; + ~matPlay.() +}, '/matrix/play'); + +OSCdef(\mat_stop, { |msg, time, addr| + ~toscTouch !? { ~toscTouch.(addr) }; + ~matStop.() +}, '/matrix/stop'); + +OSCdef(\mat_clear, { |msg, time, addr| + ~toscTouch !? { ~toscTouch.(addr) }; + ~matClear.() +}, '/matrix/clear'); + +"[matrix] ready".postln; +) diff --git a/sound_algo/data_only/sections.scd b/sound_algo/data_only/sections.scd new file mode 100644 index 0000000..e2944d1 --- /dev/null +++ b/sound_algo/data_only/sections.scd @@ -0,0 +1,145 @@ +// sections.scd — Ableton-scene-style section slots +// 8 snapshot slots: armed clips + seq presets + harmony. Save, quantized recall, +// next/prev chaining. Loaded after touchosc_feedback.scd (boot step 6h). +// Additive, nil-guarded, idempotent on reload. Single top-level block. +// Feedback: /scene/state ... +( + +// -- Env init (nil-guarded; idempotent reload) -- +~lp = ~lp ? (); +~lp[\sceneN] = ~lp[\sceneN] ? 8; +~lp[\scenes] = ~lp[\scenes] ? Array.fill(~lp[\sceneN], nil); +~lp[\sceneCur] = ~lp[\sceneCur] ? -1; +~lp[\armed] = ~lp[\armed] ? Set.new; // guard isolated reload (launchpad sets it too) + +// 16 clip symbols (same order as ~toscPatterns) ++ the two sequencers +~scenePatterns = ~scenePatterns ? [ + \kick, \hats, \clap, \perc, + \sub, \acid, \arp, \lead, + \stab, \pad, \ride, \rim, + \tom, \reese, \bells, \sweep, + \melseq, \rhythmseq +]; + +// -- ~scenePush : push /scene/state to all TouchOSC clients -- +~scenePush = ~scenePush ? { + var fills = ~lp[\scenes].collect({ |s| s.notNil.binaryValue }); + ~toscSend !? { ~toscSend.valueArray(["/scene/state", ~lp[\sceneCur]] ++ fills) }; +}; + +// -- ~sceneSave : snapshot current engine state into slot i -- +~sceneSave = ~sceneSave ? { |i| + (~lp.notNil and: { i >= 0 and: { i < (~lp[\sceneN] ? 8) } }).if({ + ~lp[\scenes][i] = ( + armed: ~lp[\armed].copy, + melSel: ~lp[\melSel], + rhySel: ~lp[\rhySel], + harmony: (~ccHarmony.notNil).if({ + (root: ~ccHarmony[\root], + scale: ~ccHarmony[\scale], + octave: ~ccHarmony[\octave], + phrase: ~ccHarmony[\phrase]) + }, { nil }) + ); + ~scenePush.(); + }); +}; + +// -- ~sceneLaunch : diff target armed-set vs current, fire ~lpArm per clip -- +// ~lpArm is already quantized: whole section transitions at next bar boundary. +~sceneLaunch = ~sceneLaunch ? { |i| + var sc = ~lp[\scenes][i]; + sc.notNil.if({ + var target = sc[\armed]; + ~lpArm !? { + ~scenePatterns.do { |nm| + var want = target.includes(nm); + var have = ~lp[\armed].includes(nm); + (want and: { have.not }).if({ ~lpArm.(nm.asString, true) }); + (want.not and: { have }).if({ ~lpArm.(nm.asString, false) }); + }; + }; + ~lp[\melSel] = sc[\melSel] ? ~lp[\melSel]; + ~lp[\rhySel] = sc[\rhySel] ? ~lp[\rhySel]; + (sc[\harmony].notNil and: { ~ccHarmony.notNil }).if({ + [\root, \scale, \octave, \phrase].do { |k| + sc[\harmony][k] !? { |v| ~ccHarmony[k] = v }; + }; + }); + ~lp[\sceneCur] = i; + ~scenePush.(); + }); +}; + +// -- ~sceneFilledIndices : list of non-nil slot indices -- +~sceneFilledIndices = ~sceneFilledIndices ? { + var idxs = []; + ~lp[\scenes].doWithIndex { |s, i| s.notNil.if({ idxs = idxs.add(i) }) }; + idxs +}; + +// -- ~sceneNext : advance to next filled slot (wrap-around) -- +~sceneNext = ~sceneNext ? { + var cur = ~lp[\sceneCur] ? -1; + var n = ~lp[\sceneN] ? 8; + var found = nil; + n.do { |offset| + var idx = (cur + 1 + offset) % n; + found.isNil.if({ + ~lp[\scenes][idx].notNil.if({ found = idx }); + }); + }; + found.notNil.if({ ~sceneLaunch.(found) }); +}; + +// -- ~scenePrev : retreat to previous filled slot (wrap-around) -- +~scenePrev = ~scenePrev ? { + var cur = ~lp[\sceneCur] ? -1; + var n = ~lp[\sceneN] ? 8; + var found = nil; + n.do { |offset| + var idx = ((cur - 1 - offset) % n + n) % n; + found.isNil.if({ + ~lp[\scenes][idx].notNil.if({ found = idx }); + }); + }; + found.notNil.if({ ~sceneLaunch.(found) }); +}; + +// -- ~sceneClear : clear slot i; reset ~sceneCur if it was the active scene -- +~sceneClear = ~sceneClear ? { |i| + (i >= 0 and: { i < (~lp[\sceneN] ? 8) }).if({ + ~lp[\scenes][i] = nil; + (~lp[\sceneCur] == i).if({ ~lp[\sceneCur] = -1 }); + ~scenePush.(); + }); +}; + +// -- OSCdefs (unique keys with \scene_ prefix; call ~toscTouch for feedback) -- +OSCdef(\scene_save, { |msg, time, addr| + ~toscTouch !? { ~toscTouch.(addr) }; + ~sceneSave.(msg[1].asInteger); +}, '/scene/save'); + +OSCdef(\scene_launch, { |msg, time, addr| + ~toscTouch !? { ~toscTouch.(addr) }; + ~sceneLaunch.(msg[1].asInteger); +}, '/scene/launch'); + +OSCdef(\scene_next, { |msg, time, addr| + ~toscTouch !? { ~toscTouch.(addr) }; + ~sceneNext.(); +}, '/scene/next'); + +OSCdef(\scene_prev, { |msg, time, addr| + ~toscTouch !? { ~toscTouch.(addr) }; + ~scenePrev.(); +}, '/scene/prev'); + +OSCdef(\scene_clear, { |msg, time, addr| + ~toscTouch !? { ~toscTouch.(addr) }; + ~sceneClear.(msg[1].asInteger); +}, '/scene/clear'); + +"[sections] ready".postln; +) diff --git a/sound_algo/data_only/test/test_matrix.scd b/sound_algo/data_only/test/test_matrix.scd new file mode 100644 index 0000000..f5dda47 --- /dev/null +++ b/sound_algo/data_only/test/test_matrix.scd @@ -0,0 +1,70 @@ +// Headless test for matrix.scd +// Run: /Applications/SuperCollider.app/Contents/MacOS/sclang \ +// sound_algo/data_only/test/test_matrix.scd +// Expected output: TEST PASS (no ERROR lines) +// Server is NOT booted. ~toscSend is stubbed. Two dummy Pdefs are defined. +( +var featureFile = PathName(thisProcess.nowExecutingPath).pathOnly + ++ "../matrix.scd"; +var pass = true; +var variation = nil; + +// Minimal dummy environment +~lp = ( + clock: TempoClock.default +); +~toscSend = { |path ...args| }; // stub: accept any args, do nothing +~toscTouch = { |addr| }; // stub + +// Define dummy base Pdefs so ~matVariation has something to capture +Pdef(\lp_kick, Pbind(\dur, 1)); +Pdef(\lp_hats, Pbind(\dur, 0.5)); + +// Load the feature file (synchronous; stubs and Pdefs already in place) +featureFile.load; + +// Assert public API functions were defined +pass = pass and: { ~matSetCell.notNil }; +pass = pass and: { ~matPlay.notNil }; +pass = pass and: { ~matStop.notNil }; + +// ~matSetCell.(0, 5, 3) must write 3 into matrix[0][5] +try { + ~matSetCell.(0, 5, 3) +} { |e| + pass = false; + ("EXCEPTION in ~matSetCell: " ++ e.class.name).postln +}; +pass = pass and: { ~lp[\matrix][0][5] == 3 }; + +// ~matVariation for a defined voice + color 2 must return a Pattern +try { + variation = ~matVariation.(\kick, 2) +} { |e| + pass = false; + ("EXCEPTION in ~matVariation: " ++ e.class.name).postln +}; +pass = pass and: { variation.notNil and: { variation.isKindOf(Pattern) } }; + +// Out-of-range ~matSetCell must NOT raise (vi=99 is outside 0..15) +try { + ~matSetCell.(99, 0, 1) +} { |e| + pass = false; + ("EXCEPTION on out-of-range ~matSetCell: " ++ e.class.name).postln +}; + +// ~matStop must not raise (matRoutine is nil, matPlaying is false) +try { + ~matStop.() +} { |e| + pass = false; + ("EXCEPTION in ~matStop: " ++ e.class.name).postln +}; + +pass.if( + { "TEST PASS".postln }, + { "TEST FAIL".postln } +); +0.exit; +) diff --git a/sound_algo/data_only/test/test_sections.scd b/sound_algo/data_only/test/test_sections.scd new file mode 100644 index 0000000..ca3f2fa --- /dev/null +++ b/sound_algo/data_only/test/test_sections.scd @@ -0,0 +1,59 @@ +// Headless test for sections.scd +// Run: /Applications/SuperCollider.app/Contents/MacOS/sclang \ +// sound_algo/data_only/test/test_sections.scd +// Expected output: TEST PASS (with no ERROR lines from sections.scd) +// +// Server is NOT booted. ~lpArm, ~toscSend, ~ccHarmony are stubbed. +( +var featureFile = PathName(thisProcess.nowExecutingPath).pathOnly + ++ "../sections.scd"; +var pass = true; + +// Minimal dummy environment matching shapes expected by sections.scd +~lp = ( + armed: Set[\kick], + melSel: 0, + rhySel: 0, + clock: TempoClock.default, + melodies: [[0]], + rhythms: [(name: "x", k: "", s: "", h: "")] +); + +// Stubs — no server, no real transport +~lpArm = { |n, o| }; +~ccHarmony = (root: 40, scale: \minor, octave: 1, phrase: 0); +~toscSend = { |...a| }; + +// Load the feature file (synchronous; stubs already in place) +featureFile.load; + +// Assert public API functions were wired +pass = pass and: { ~sceneSave.notNil }; +pass = pass and: { ~sceneLaunch.notNil }; +pass = pass and: { ~sceneNext.notNil }; + +// ~sceneSave.(0) must populate slot 0 +try { ~sceneSave.(0) } { |e| + pass = false; + ("EXCEPTION in ~sceneSave: " ++ e.class.name).postln; +}; +pass = pass and: { ~lp[\scenes][0].notNil }; + +// ~sceneLaunch.(0) must not raise +try { ~sceneLaunch.(0) } { |e| + pass = false; + ("EXCEPTION in ~sceneLaunch: " ++ e.class.name).postln; +}; + +// ~sceneNext must not raise (slot 0 filled; wraps back and launches 0 again) +try { ~sceneNext.() } { |e| + pass = false; + ("EXCEPTION in ~sceneNext: " ++ e.class.name).postln; +}; + +pass.if( + { "TEST PASS".postln }, + { "TEST FAIL".postln } +); +0.exit; +) diff --git a/sound_algo/data_only/touchosc_feedback.scd b/sound_algo/data_only/touchosc_feedback.scd index 471768b..93259eb 100644 --- a/sound_algo/data_only/touchosc_feedback.scd +++ b/sound_algo/data_only/touchosc_feedback.scd @@ -97,7 +97,7 @@ OSCdef(\tosc_sniff_seqmelody, { |msg, time, addr| idx = msg[1].asInteger; degs = ~lp[\melodies][idx]; degs.notNil.if({ - ~toscSend.valueWithArguments( + ~toscSend.valueArray( ["/seq/melody/state"] ++ [idx] ++ degs ); }); diff --git a/touchosc/dist/av-live-control.tosc b/touchosc/dist/av-live-control.tosc index ae9fec4..a6c0c6f 100644 Binary files a/touchosc/dist/av-live-control.tosc and b/touchosc/dist/av-live-control.tosc differ diff --git a/touchosc/gen/layout.py b/touchosc/gen/layout.py index a07a7f2..04fdedc 100644 --- a/touchosc/gen/layout.py +++ b/touchosc/gen/layout.py @@ -317,6 +317,62 @@ def seq_page(): return page +# --------------------------------------------------------------------------- +# Page 4 — SECTIONS +# --------------------------------------------------------------------------- + +def sections_page(): + """Page 4: 8 section slots (launch + save), prev/next nav, SC scene feedback. + + The GROUP carries sections.lua and a /scene/state receive message so that + onReceiveOSC fires on the GROUP and can color the slot buttons. + """ + page = s.node( + "GROUP", + [ + s.prop("name", "SECTIONS", "s"), + s.frame(0, 0, *CANVAS), + s.script(_lua("sections.lua")), + ], + messages=[ + s.osc("/scene/state", [s.val()], send=0, receive=1, feedback=1), + ], + ) + ch = page.find("children") + + ch.append(label("SECTIONS", 10, 8, 200, 30)) + + gx = 10 + bw = 138 + gap = 10 + step = bw + gap # 148 px per column + + launch_y, launch_h = 46, 140 + save_y, save_h = 196, 60 + + for i in range(8): + x = gx + i * step + # Launch button: name=slot{i} so sections.lua can findByName("slot"..i) + ch.append(s.node( + "BUTTON", + [s.prop("name", f"slot{i}", "s"), s.frame(x, launch_y, bw, launch_h)], + messages=[s.osc("/scene/launch", [s.consti(i)])], + )) + # Save button + ch.append(s.node( + "BUTTON", + [s.prop("name", f"S{i + 1}", "s"), s.frame(x, save_y, bw, save_h)], + messages=[s.osc("/scene/save", [s.consti(i)])], + )) + + # Nav buttons + nav_y, nav_h = 272, 80 + ch.append(button("< PREV", "/scene/prev", 10, nav_y, 200, nav_h, args=[s.vali()])) + ch.append(button("NEXT >", "/scene/next", 220, nav_y, 200, nav_h, args=[s.vali()])) + + return page + + # --------------------------------------------------------------------------- # Pager + root # --------------------------------------------------------------------------- @@ -335,6 +391,6 @@ def build(): root = s.node( "GROUP", [s.prop("name", "av-live-control", "s"), s.frame(0, 0, *CANVAS)], - children=[pager([live_page(), fx_page(), seq_page()])], + children=[pager([live_page(), fx_page(), seq_page(), sections_page()])], ) return root diff --git a/touchosc/gen/lua/sections.lua b/touchosc/gen/lua/sections.lua new file mode 100644 index 0000000..6c399d0 --- /dev/null +++ b/touchosc/gen/lua/sections.lua @@ -0,0 +1,23 @@ +-- SECTIONS GROUP: color the 8 launch slot buttons from /scene/state. +-- API verified on GrosMac at fidelity gate. +-- /scene/state ... +-- cur=-1 means no active section; fillN=1 means slot N is saved. +function onReceiveOSC(message, connections) + local path = message[1] + if path ~= "/scene/state" then return end + local args = message[2] + local cur = math.floor((args[1] and tonumber(args[1].value)) or -1) + for i = 0, 7 do + local fill = math.floor((args[i + 2] and tonumber(args[i + 2].value)) or 0) + local slot = self:findByName("slot" .. i, true) + if slot then + if i == cur then + slot.color = Color(0.27, 0.8, 0.4, 1) -- current (green) + elseif fill == 1 then + slot.color = Color(0.2, 0.4, 0.8, 1) -- filled (blue) + else + slot.color = Color(0.15, 0.15, 0.15, 1) -- empty (dark) + end + end + end +end diff --git a/touchosc/tests/test_layout.py b/touchosc/tests/test_layout.py index 40f3f3d..1811e8e 100644 --- a/touchosc/tests/test_layout.py +++ b/touchosc/tests/test_layout.py @@ -23,7 +23,7 @@ def _addr_list(el): # Root / pager # --------------------------------------------------------------------------- -def test_root_has_pager_with_three_pages(tmp_path): +def test_root_has_pager_with_four_pages(tmp_path): root = layout.build() out = tmp_path / "full.tosc" schema.write_tosc(root, str(out)) @@ -31,7 +31,7 @@ def test_root_has_pager_with_three_pages(tmp_path): pager = parsed.find(".//node[@type='PAGER']") assert pager is not None, "PAGER node not found" pages = pager.findall("children/node[@type='GROUP']") - assert len(pages) == 3, f"expected 3 pages, got {len(pages)}" + assert len(pages) == 4, f"expected 4 pages, got {len(pages)}" def test_sixteen_patterns(): @@ -322,3 +322,72 @@ def test_live_page_pad_groups_carry_armed_receive(): f"pad GROUP '{grp_name}' must have exactly 1 /armed/* receive msg " f"on the GROUP itself, got {len(armed_msgs)}" ) + + +# --------------------------------------------------------------------------- +# Page 4 — SECTIONS +# --------------------------------------------------------------------------- + +def test_sections_page_launch_buttons(): + """SECTIONS page: 8 /scene/launch messages with CONSTANT/INTEGER args 0..7.""" + page = layout.sections_page() + launch_args = [] + for osc_el in page.findall(".//messages/osc"): + path_val = osc_el.find("path/partial/value") + if path_val is not None and path_val.text == "/scene/launch": + for part in osc_el.findall("arguments/partial"): + typ = part.find("type") + conv = part.find("conversion") + val = part.find("value") + if typ is not None and conv is not None and val is not None: + launch_args.append((typ.text, conv.text, val.text)) + assert len(launch_args) == 8, f"expected 8 /scene/launch arg partials, got {len(launch_args)}" + for typ, conv, _ in launch_args: + assert typ == "CONSTANT", f"launch arg type must be CONSTANT, got {typ}" + assert conv == "INTEGER", f"launch arg conversion must be INTEGER, got {conv}" + vals = {v for _, _, v in launch_args} + assert vals == {"0", "1", "2", "3", "4", "5", "6", "7"}, ( + f"expected launch values 0..7, got {vals}" + ) + + +def test_sections_page_save_buttons(): + """SECTIONS page: 8 /scene/save messages with CONSTANT/INTEGER args 0..7.""" + page = layout.sections_page() + save_args = [] + for osc_el in page.findall(".//messages/osc"): + path_val = osc_el.find("path/partial/value") + if path_val is not None and path_val.text == "/scene/save": + for part in osc_el.findall("arguments/partial"): + typ = part.find("type") + conv = part.find("conversion") + val = part.find("value") + if typ is not None and conv is not None and val is not None: + save_args.append((typ.text, conv.text, val.text)) + assert len(save_args) == 8, f"expected 8 /scene/save arg partials, got {len(save_args)}" + for typ, conv, _ in save_args: + assert typ == "CONSTANT", f"save arg type must be CONSTANT, got {typ}" + assert conv == "INTEGER", f"save arg conversion must be INTEGER, got {conv}" + + +def test_sections_page_nav_buttons(): + """SECTIONS page: /scene/prev and /scene/next present.""" + page = layout.sections_page() + addrs = _addrs(page) + assert "/scene/prev" in addrs, "/scene/prev not found on SECTIONS page" + assert "/scene/next" in addrs, "/scene/next not found on SECTIONS page" + + +def test_sections_page_state_receive_on_group(): + """SECTIONS GROUP itself must carry /scene/state receive so sections.lua fires.""" + page = layout.sections_page() + # Direct messages/osc on the top-level GROUP (not descendants) + state_msgs = [ + osc_el for osc_el in page.findall("messages/osc") + if osc_el.get("receive") == "1" + and osc_el.find("path/partial/value") is not None + and osc_el.find("path/partial/value").text == "/scene/state" + ] + assert len(state_msgs) == 1, ( + f"SECTIONS GROUP must have exactly 1 /scene/state receive msg, got {len(state_msgs)}" + ) diff --git a/web_realart/public/control/control.css b/web_realart/public/control/control.css index a19e25a..0c209d5 100644 --- a/web_realart/public/control/control.css +++ b/web_realart/public/control/control.css @@ -75,3 +75,42 @@ button.sel { background: #246; border-color: #4af; color: #8cf; } to { opacity: 0.35; } } button.queued { animation: queued-blink 600ms ease-in-out infinite alternate; } + +/* --- Sections tab --- */ +.mode-bar { display: flex; align-items: center; gap: 6px; margin: 8px 0; flex-wrap: wrap; } +.mode-label { font-size: 13px; color: #888; flex-shrink: 0; } +.mode-btn { width: auto; padding: 8px 14px; font-size: 14px; flex-shrink: 0; } +.mode-btn.active { background: #246; color: #8cf; border-color: #4af; font-weight: 700; } + +.scene-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 12px 0; } +.scene-slot { background: #1c1c1c; border: 2px solid #333; border-radius: 10px; + padding: 24px 10px; font-size: 22px; font-weight: 700; cursor: pointer; width: 100%; color: #666; } +.scene-slot.filled { background: #1a1a2e; border-color: #446; color: #8cf; } +.scene-slot.current { background: #2b6; border-color: #4d9; color: #061; font-weight: 900; } +.scene-slot.filled.current { background: #2b6; border-color: #4d9; color: #fff; } + +.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 c7706f7..eea2c10 100644 --- a/web_realart/public/control/control.js +++ b/web_realart/public/control/control.js @@ -7,6 +7,15 @@ function send(address, ...args) { } const armed = new Set(); const clipState = new Map(); // name -> last-known state int: 0 off, 1 playing, 2 queued-launch, 3 queued-stop + +// --- Scene section mode --- +let sceneMode = "launch"; // "launch" | "save" | "clear" +function setSceneMode(mode) { + sceneMode = mode; + document.querySelectorAll(".mode-btn").forEach((b) => { + b.classList.toggle("active", b.dataset.mode === mode); + }); +} function togglePad(el, name) { // Use last-known state for toggle direction; fall back to armed Set const st = clipState.has(name) ? clipState.get(name) : (armed.has(name) ? 1 : 0); @@ -107,8 +116,144 @@ ws.addEventListener("message", (ev) => { } return; } + + // /scene/state ... + if (address === "/scene/state") { + const cur = Number(args[0]); + document.querySelectorAll("[data-scene]").forEach((btn) => { + const n = +btn.dataset.scene; + btn.classList.toggle("filled", Number(args[n + 1]) === 1); + btn.classList.toggle("current", n === cur); + }); + 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 && + row.every(v => Number.isInteger(v) && v >= 0 && v <= 6)) + ) { + 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"; @@ -256,6 +401,7 @@ function updateNameInputs() { document.addEventListener("DOMContentLoaded", () => { loadState(); + loadMatState(); // Tab switching document.querySelectorAll(".tab-btn").forEach((btn) => { @@ -276,6 +422,7 @@ document.addEventListener("DOMContentLoaded", () => { updateNameInputs(); renderRhyGrid(); renderMelSteps(); + renderMatrix(); // Name input: rename selected preset and update button labels const melName = document.getElementById("mel-name"); @@ -358,4 +505,37 @@ document.addEventListener("DOMContentLoaded", () => { send("/seq/melody/set", ...state.melodies[idx].degrees); } })); + + // Scene mode toggle buttons + document.querySelectorAll(".mode-btn").forEach((btn) => + btn.addEventListener("click", () => setSceneMode(btn.dataset.mode))); + + // Scene slot buttons + document.querySelectorAll("[data-scene]").forEach((btn) => + btn.addEventListener("click", () => { + const i = +btn.dataset.scene; + send("/scene/" + sceneMode, i); + if (sceneMode === "save" || sceneMode === "clear") { + setSceneMode("launch"); + } + })); + + // Scene prev / next + const scenePrev = document.getElementById("scene-prev"); + 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 acb60f9..dbc61ee 100644 --- a/web_realart/public/control/index.html +++ b/web_realart/public/control/index.html @@ -12,6 +12,8 @@ + +
@@ -125,4 +127,40 @@
+
+

Sections

+
+ Mode + + + +
+
+ + + + + + + + +
+
+ + +
+
+
+
+

Matrice

+
+ + + +
+
+
+
+
+
diff --git a/web_realart/server.js b/web_realart/server.js index dd64634..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/"]; +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 });