feat: sc touchosc feedback sender
Adds sound_algo/data_only/touchosc_feedback.scd: captures the controlling client IP from inbound /launch* OSC traffic, pushes /armed/<name> for all 16 pads, /sync/beat each beat on ~lp[clock], /sync/rms at 20 Hz (server-only, guarded), and echoes /seq/rhythm/state + /seq/melody/state on preset select. Additive, nil-guarded, safe to load headless without a booted audio server. Also adds test/test_touchosc_feedback.scd: headless harness that builds a dummy ~lp env, loads the feature, asserts the three public API functions are wired, exercises ~toscArmedPush with nil ~tosc, and prints TEST PASS.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
// Headless test for touchosc_feedback.scd
|
||||
// Run: /Applications/SuperCollider.app/Contents/MacOS/sclang \
|
||||
// sound_algo/data_only/test/test_touchosc_feedback.scd
|
||||
// Expected output: TEST PASS (with no ERROR lines from the feature file)
|
||||
//
|
||||
// The audio server is NOT booted, so the RMS probe block is skipped by its
|
||||
// Server.default.serverRunning guard. The Routine is started and immediately
|
||||
// stopped — the armed push fires once with ~tosc nil, which must not raise.
|
||||
(
|
||||
var featureFile = "/Users/electron/Documents/Projets/AV-Live/sound_algo/data_only/touchosc_feedback.scd";
|
||||
var pass = true;
|
||||
|
||||
// Build a minimal dummy environment matching the shapes launchpad.scd creates
|
||||
~lp = (
|
||||
clock: TempoClock.default,
|
||||
armed: Set[\kick],
|
||||
melodies: [[0, 2, 3, 5]],
|
||||
rhythms: [(name: "4floor", k: "1000100010001000",
|
||||
s: "0000100000001000", h: "0010101010101010")]
|
||||
);
|
||||
|
||||
// Load the feature file (synchronous; server not booted -> RMS block skipped)
|
||||
featureFile.load;
|
||||
|
||||
// Assert the three public API functions were wired up
|
||||
pass = pass and: { ~toscSend.notNil };
|
||||
pass = pass and: { ~toscTouch.notNil };
|
||||
pass = pass and: { ~toscArmedPush.notNil };
|
||||
|
||||
// Calling ~toscArmedPush.() with ~tosc nil must not raise
|
||||
// (~toscSend nil-guards via !? so no OSC is actually sent)
|
||||
try { ~toscArmedPush.() } { |e|
|
||||
pass = false;
|
||||
("EXCEPTION in ~toscArmedPush: " ++ e.messageString).postln;
|
||||
};
|
||||
|
||||
// Stop the background Routine so the process can exit cleanly
|
||||
~toscFeedbackRoutine !? { ~toscFeedbackRoutine.stop };
|
||||
|
||||
pass.if(
|
||||
{ "TEST PASS".postln },
|
||||
{ "TEST FAIL".postln }
|
||||
);
|
||||
0.exit;
|
||||
)
|
||||
@@ -0,0 +1,131 @@
|
||||
// TouchOSC feedback bridge
|
||||
// Sends engine state back to the TouchOSC control surface so the iPad
|
||||
// reflects what the SC engine is doing:
|
||||
//
|
||||
// /armed/<name> pad armed state (0 or 1), one message per pattern
|
||||
// /sync/rms master amplitude (single float 0..1), 20 Hz
|
||||
// /sync/beat beat pulse (value 1, once per beat on ~lp clock)
|
||||
// /seq/rhythm/state idx k s h name (echoed when /seq/rhythm received)
|
||||
// /seq/melody/state idx deg0 deg1 ... (echoed when /seq/melody received)
|
||||
//
|
||||
// Sender capture strategy: we sniff /launch, /launch/vol, /launch/tempo
|
||||
// and /launch/clear — messages the surface sends on every connect/pad tap —
|
||||
// to discover the client IP. The /seq/* sniffers double as echo triggers.
|
||||
// We intentionally skip /control/* exact paths: the surface sends
|
||||
// /control/fx/... paths (not bare /control) which OSCdef can't match as a
|
||||
// prefix, so they add no capture value.
|
||||
//
|
||||
// Additive + idempotent: re-loading stops the prior Routine and re-registers
|
||||
// OSCdefs (same unique keys replace existing ones in OSCdef's registry).
|
||||
(
|
||||
|
||||
// -- Port + address init --
|
||||
~toscPort = ~toscPort ? 9000;
|
||||
~tosc = ~tosc; // nil on first load, preserved on reload
|
||||
|
||||
// Refresh the target NetAddr when a control message arrives from the surface
|
||||
~toscTouch = { |addr|
|
||||
var ip = addr.ip;
|
||||
(~tosc.isNil or: { ~tosc.ip != ip }).if({
|
||||
~tosc = NetAddr(ip, ~toscPort);
|
||||
("[touchosc] client -> " ++ ip ++ ":" ++ ~toscPort.asString).postln;
|
||||
});
|
||||
};
|
||||
|
||||
// Send an OSC message to the surface, silently if ~tosc not yet known
|
||||
~toscSend = { |path ...args|
|
||||
~tosc !? { ~tosc.sendMsg(path, *args) };
|
||||
};
|
||||
|
||||
// Canonical pad name order (16 pads in grid order, matches the surface layout)
|
||||
~toscPatterns = [
|
||||
\kick, \hats, \clap, \perc,
|
||||
\sub, \acid, \arp, \lead,
|
||||
\stab, \pad, \ride, \rim,
|
||||
\tom, \reese, \bells, \sweep
|
||||
];
|
||||
|
||||
// Push armed state for all 16 pads; nil-guarded
|
||||
~toscArmedPush = {
|
||||
(~lp.notNil and: { ~lp[\armed].notNil }).if({
|
||||
~toscPatterns.do { |name|
|
||||
~toscSend.(
|
||||
"/armed/" ++ name.asString,
|
||||
~lp[\armed].includes(name).binaryValue
|
||||
);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// -- Sender-capture sniffers (unique keys prefixed \tosc_sniff_) --
|
||||
OSCdef(\tosc_sniff_launch, { |msg, time, addr| ~toscTouch.(addr) }, '/launch');
|
||||
OSCdef(\tosc_sniff_vol, { |msg, time, addr| ~toscTouch.(addr) }, '/launch/vol');
|
||||
OSCdef(\tosc_sniff_tempo, { |msg, time, addr| ~toscTouch.(addr) }, '/launch/tempo');
|
||||
OSCdef(\tosc_sniff_clear, { |msg, time, addr| ~toscTouch.(addr) }, '/launch/clear');
|
||||
|
||||
// -- Seq state echo + capture --
|
||||
// When the surface selects a rhythm preset, we echo the full pattern back.
|
||||
OSCdef(\tosc_sniff_seqrhythm, { |msg, time, addr|
|
||||
var idx, item;
|
||||
~toscTouch.(addr);
|
||||
(~lp.notNil and: { ~lp[\rhythms].notNil }).if({
|
||||
idx = msg[1].asInteger;
|
||||
item = ~lp[\rhythms][idx];
|
||||
item.notNil.if({
|
||||
~toscSend.(
|
||||
"/seq/rhythm/state",
|
||||
idx,
|
||||
item[\k].asString,
|
||||
item[\s].asString,
|
||||
item[\h].asString,
|
||||
item[\name].asString
|
||||
);
|
||||
});
|
||||
});
|
||||
}, '/seq/rhythm');
|
||||
|
||||
// When the surface selects a melody preset, we echo the degree array back.
|
||||
OSCdef(\tosc_sniff_seqmelody, { |msg, time, addr|
|
||||
var idx, degs;
|
||||
~toscTouch.(addr);
|
||||
(~lp.notNil and: { ~lp[\melodies].notNil }).if({
|
||||
idx = msg[1].asInteger;
|
||||
degs = ~lp[\melodies][idx];
|
||||
degs.notNil.if({
|
||||
~toscSend.valueWithArguments(
|
||||
["/seq/melody/state"] ++ [idx] ++ degs
|
||||
);
|
||||
});
|
||||
});
|
||||
}, '/seq/melody');
|
||||
|
||||
// -- Master RMS probe (server-only block; skipped when booting headless) --
|
||||
// Mirrors the \webRmsProbe recipe from web_bridge.scd, renamed to avoid clash.
|
||||
Server.default.serverRunning.if({
|
||||
SynthDef(\toscRmsProbe, { |inBus = 0, rate = 20|
|
||||
var sig = Mix.ar(In.ar(inBus, 2)) * 0.5;
|
||||
var amp = Amplitude.kr(sig, 0.01, 0.2);
|
||||
SendReply.kr(Impulse.kr(rate), '/toscRms', [amp]);
|
||||
}).add;
|
||||
~toscRmsResp !? { ~toscRmsResp.free };
|
||||
~toscRmsSynth !? { ~toscRmsSynth.free };
|
||||
Server.default.sync;
|
||||
~toscRmsSynth = Synth.tail(nil, \toscRmsProbe, [\inBus, 0, \rate, 20]);
|
||||
~toscRmsResp = OSCFunc({ |msg|
|
||||
~toscSend.("/sync/rms", msg[3].asFloat);
|
||||
}, '/toscRms', Server.default.addr);
|
||||
});
|
||||
|
||||
// -- Beat pulse + armed state push on the launchpad clock --
|
||||
// Stop any prior Routine before (re)starting so reloads don't stack.
|
||||
~toscFeedbackRoutine !? { ~toscFeedbackRoutine.stop };
|
||||
~toscFeedbackRoutine = Routine({
|
||||
{
|
||||
~toscArmedPush.();
|
||||
~toscSend.("/sync/beat", 1);
|
||||
1.wait;
|
||||
}.loop;
|
||||
}).play(~lp.notNil.if({ ~lp[\clock] ? TempoClock.default }, { TempoClock.default }));
|
||||
|
||||
"[touchosc] feedback ready".postln;
|
||||
)
|
||||
Reference in New Issue
Block a user