feat: live section scene slots in sc

8 snapshot slots (clips armed + seq presets + harmony).
Quantized recall via ~lpArm diff, next/prev chaining,
/scene/state feedback via ~toscSend. Headless test passes.
This commit is contained in:
L'électron rare
2026-06-28 15:51:57 +02:00
parent bb11311720
commit ca21a4216d
2 changed files with 199 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
// 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;
// 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];
~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|
~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,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;
)