Merge remote-tracking branch 'origin/main' into refactor/sc-finish
CI build oscope-of / build-check (push) Has been cancelled

This commit is contained in:
clement
2026-06-28 17:13:21 +02:00
14 changed files with 865 additions and 5 deletions
+6
View File
@@ -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;
+175
View File
@@ -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;
)
+145
View File
@@ -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 <cur:int> <fill0:int> ... <fill7:int>
(
// -- 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;
)
+70
View File
@@ -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;
)
@@ -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;
)
+1 -1
View File
@@ -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
);
});
Binary file not shown.
+57 -1
View File
@@ -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
+23
View File
@@ -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:int> <fill0:int> ... <fill7:int>
-- 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
+71 -2
View File
@@ -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)}"
)
+39
View File
@@ -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); }
+180
View File
@@ -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 <cur:int> <fill0:int> ... <fill7:int>
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 <bar> — 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 <vi> <bar> <color> — 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");
});
});
+38
View File
@@ -12,6 +12,8 @@
<button class="tab-btn active" data-tab="live">LIVE</button>
<button class="tab-btn" data-tab="fx">FX / HARMONIE / CONCERT</button>
<button class="tab-btn" data-tab="seq">SÉQUENCEURS</button>
<button class="tab-btn" data-tab="sections">SECTIONS</button>
<button class="tab-btn" data-tab="matrix">MATRICE</button>
</nav>
<div class="tab-panel active" id="tab-live">
@@ -125,4 +127,40 @@
</div>
</section>
</div>
<div class="tab-panel" id="tab-sections">
<section><h2>Sections</h2>
<div class="mode-bar">
<span class="mode-label">Mode</span>
<button class="mode-btn active" data-mode="launch">LANCER</button>
<button class="mode-btn" data-mode="save">ENREGISTRER</button>
<button class="mode-btn" data-mode="clear">EFFACER</button>
</div>
<div class="scene-grid">
<button class="scene-slot" data-scene="0">1</button>
<button class="scene-slot" data-scene="1">2</button>
<button class="scene-slot" data-scene="2">3</button>
<button class="scene-slot" data-scene="3">4</button>
<button class="scene-slot" data-scene="4">5</button>
<button class="scene-slot" data-scene="5">6</button>
<button class="scene-slot" data-scene="6">7</button>
<button class="scene-slot" data-scene="7">8</button>
</div>
<div class="scene-nav">
<button id="scene-prev">&#9664; PREV</button>
<button id="scene-next">NEXT &#9654;</button>
</div>
</section>
</div>
<div class="tab-panel" id="tab-matrix">
<section><h2>Matrice</h2>
<div class="matrix-transport">
<button id="matrix-play">PLAY</button>
<button id="matrix-stop">STOP</button>
<button id="matrix-clear">CLEAR</button>
</div>
<div class="matrix-scroll">
<div id="matrix-grid"></div>
</div>
</section>
</div>
<script src="control.js"></script></body></html>
+1 -1
View File
@@ -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 });