Merge remote-tracking branch 'origin/main' into refactor/sc-finish
CI build oscope-of / build-check (push) Has been cancelled
CI build oscope-of / build-check (push) Has been cancelled
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
)
|
||||
@@ -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;
|
||||
)
|
||||
@@ -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;
|
||||
)
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+57
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)}"
|
||||
)
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">◀ PREV</button>
|
||||
<button id="scene-next">NEXT ▶</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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user