Five-task TDD plan (headless sclang tests) for the body-play module: bp_* SynthDef palette, modes table + scale quantizer, pure pose->music mapping functions, the engine (clock/arp/lead/rhythm/crossfade), and boot wiring with a live smoke step.
This commit is contained in:
@@ -0,0 +1,626 @@
|
||||
# Body-Play Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the body play music in the data-only patch — action selects a mode, the right hand plays a gesture-triggered lead, the left hand holds a chord an arpeggio plays in rhythm, and body speed drives the tempo.
|
||||
|
||||
**Architecture:** Two new `.load`-compatible SuperCollider files in `sound_algo/data_only/`: `synthdefs_play.scd` (the `\bp_*` palette) and `scene_pose_play.scd` (modes table `~bpModes`, pure mapping functions `~bpMap`, engine `~bpEngine`, registered as `~doScene.(\play)`). `boot.scd` loads both. Runs alongside the existing `scene_pose_action.scd` (pose→FX).
|
||||
|
||||
**Tech Stack:** SuperCollider 3.14.1 (sclang + scsynth). Input via existing OSCdefs in `control/data_feeds.scd` populating `~poseState`/`~poseKin`/`~handFeat`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- All `.scd` files loaded via `.load` MUST have balanced parens/brackets (`P:0 B:0`) and exactly one top-level block (`TLB:1`). Validate with the `validating-scd-files` skill after every edit.
|
||||
- SC env vars are lowercase `~xxx` (uppercase parses as a class name).
|
||||
- Every one-shot SynthDef envelope ends with `doneAction: 2` (else Synth zombies accumulate until scsynth crashes).
|
||||
- SynthDefs are stereo via `Pan2.ar`, `clip` freq, `LeakDC.ar` after any feedback.
|
||||
- NEVER `coll.collect { _ / n }` — the underscore inside `{}` is a closure trap (builds a function returning a function). Use `coll / n` or `coll.collect { |x| x / n }` (see commit 480aa79).
|
||||
- Every read of pose data defaults nil with `? 0` (`~handFeat`/`~poseKin`/`~poseState` may be empty/absent when no body is in frame).
|
||||
- Code/comments in French per `sound_algo/CLAUDE.md`; this is a `do_*`-sibling patch — prefix new SynthDefs `\bp_*`, new env vars `~bp*`.
|
||||
- Test harness: a headless sclang run `/Applications/SuperCollider.app/Contents/MacOS/sclang /tmp/test_x.scd`; the script `postln`s `[PASS]`/`[FAIL]` lines and ends with `0.exit`. scsynth is only booted for tasks that need it (SynthDef add, engine tick).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `sound_algo/data_only/synthdefs_play.scd` (new) | the 6 `\bp_*` SynthDefs — pure sound, no sequencing |
|
||||
| `sound_algo/data_only/scene_pose_play.scd` (new) | `~bpModes` (data), `~bpScale` (quantize helper), `~bpMap` (pure pose→music functions), `~bpEngine` (clock+routines+pad+crossfade), `~doScene[\play]` registration |
|
||||
| `sound_algo/data_only/boot.scd` (modify) | load the two new files |
|
||||
|
||||
Tasks 1–3 are independently testable without the others. Task 4 consumes 1–3. Task 5 wires everything into the boot.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `\bp_*` SynthDef palette
|
||||
|
||||
**Files:**
|
||||
- Create: `sound_algo/data_only/synthdefs_play.scd`
|
||||
- Test: `/tmp/test_bp_synthdefs.scd` (scratch, not committed)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (added to default SynthDescLib, all `out=0` stereo):
|
||||
- `\bp_lead |out, freq=440, amp=0.3, dur=0.5, cutoff=2000, pan=0, rev=0.3|`
|
||||
- `\bp_arp |out, freq=440, amp=0.22, dur=0.25, pan=0, rev=0.3|`
|
||||
- `\bp_pad |out, freq=110, amp=0.0, cutoff=1200, gate=1, pan=0, rev=0.4|` (gated, sustained; freed by `gate=0`)
|
||||
- `\bp_bass |out, freq=55, amp=0.4, dur=0.5, pan=0, rev=0.2|`
|
||||
- `\bp_kick |out, amp=0.55, dur=0.3|`
|
||||
- `\bp_hat |out, amp=0.3, pan=0, dur=0.05|`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `/tmp/test_bp_synthdefs.scd`:
|
||||
|
||||
```supercollider
|
||||
(
|
||||
s.waitForBoot({
|
||||
var names = [\bp_lead, \bp_arp, \bp_pad, \bp_bass, \bp_kick, \bp_hat];
|
||||
"/Users/electron/Documents/Projets/AV-Live/sound_algo/data_only/synthdefs_play.scd".load;
|
||||
s.sync;
|
||||
names.do { |n|
|
||||
if(SynthDescLib.global.at(n).notNil) {
|
||||
("[PASS] " ++ n ++ " compiled").postln;
|
||||
} { ("[FAIL] " ++ n ++ " missing").postln };
|
||||
};
|
||||
0.exit;
|
||||
});
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang /tmp/test_bp_synthdefs.scd 2>&1 | grep -E "PASS|FAIL|Error"`
|
||||
Expected: load error (file does not exist) / all `[FAIL]`.
|
||||
|
||||
- [ ] **Step 3: Write the SynthDefs**
|
||||
|
||||
Create `sound_algo/data_only/synthdefs_play.scd` (single top-level block):
|
||||
|
||||
```supercollider
|
||||
// =====================================================================
|
||||
// data_only/synthdefs_play.scd -- palette \bp_* pour scene_pose_play.
|
||||
// Corps-joue : lead (main D), arp (main G), pad (mode), bass, kick, hat.
|
||||
// Toutes stereo, doneAction:2 sur les envs one-shot.
|
||||
// =====================================================================
|
||||
(
|
||||
SynthDef(\bp_lead, { |out=0, freq=440, amp=0.3, dur=0.5, cutoff=2000, pan=0, rev=0.3|
|
||||
var env = EnvGen.kr(Env.perc(0.005, dur, 1, -4), doneAction: 2);
|
||||
var sig = Saw.ar(freq.clip(40, 8000) * [1, 1.003]).sum * 0.5;
|
||||
sig = RLPF.ar(sig, (cutoff * (1 + env)).clip(80, 9000), 0.3);
|
||||
sig = (sig * env * amp).tanh;
|
||||
Out.ar(out, Pan2.ar(sig, pan) * (1 - rev));
|
||||
Out.ar(out, Pan2.ar(sig, pan) * rev * 0.5);
|
||||
}).add;
|
||||
|
||||
SynthDef(\bp_arp, { |out=0, freq=440, amp=0.22, dur=0.25, pan=0, rev=0.3|
|
||||
var env = EnvGen.kr(Env.perc(0.003, dur, 1, -6), doneAction: 2);
|
||||
var sig = (SinOsc.ar(freq.clip(40, 8000)) + (LFTri.ar(freq * 2) * 0.2));
|
||||
sig = (sig * env * amp).tanh;
|
||||
Out.ar(out, Pan2.ar(sig, pan) * (1 - rev));
|
||||
Out.ar(out, Pan2.ar(sig, pan) * rev * 0.5);
|
||||
}).add;
|
||||
|
||||
SynthDef(\bp_pad, { |out=0, freq=110, amp=0.0, cutoff=1200, gate=1, pan=0, rev=0.4|
|
||||
var env = EnvGen.kr(Env.asr(2, 1, 3), gate, doneAction: 2);
|
||||
var detune = [1, 1.004, 0.997, 2.002];
|
||||
var sig = Saw.ar(freq.clip(30, 4000) * detune).sum * 0.25;
|
||||
sig = LeakDC.ar(sig);
|
||||
sig = RLPF.ar(sig, (cutoff * SinOsc.kr(0.07).range(0.6, 1.0)).clip(80, 6000), 0.4);
|
||||
sig = sig * env * amp.lag(1.5);
|
||||
Out.ar(out, Pan2.ar(sig, pan) * (1 - rev));
|
||||
Out.ar(out, Pan2.ar(sig, pan) * rev);
|
||||
}).add;
|
||||
|
||||
SynthDef(\bp_bass, { |out=0, freq=55, amp=0.4, dur=0.5, pan=0, rev=0.2|
|
||||
var env = EnvGen.kr(Env.perc(0.005, dur, 1, -3), doneAction: 2);
|
||||
var sub = SinOsc.ar(freq.clip(25, 400));
|
||||
var saw = LPF.ar(Saw.ar(freq.clip(25, 400)), freq * 4);
|
||||
var sig = ((sub + (saw * 0.4)) * env * amp).tanh;
|
||||
Out.ar(out, Pan2.ar(sig, pan) * (1 - rev));
|
||||
}).add;
|
||||
|
||||
SynthDef(\bp_kick, { |out=0, amp=0.55, dur=0.3|
|
||||
var env = EnvGen.kr(Env.perc(0.001, dur, 1, -5), doneAction: 2);
|
||||
var fenv = EnvGen.kr(Env([180, 50, 40], [0.03, dur], \exp));
|
||||
var sig = (SinOsc.ar(fenv) * env * amp).tanh;
|
||||
Out.ar(out, sig ! 2);
|
||||
}).add;
|
||||
|
||||
SynthDef(\bp_hat, { |out=0, amp=0.3, pan=0, dur=0.05|
|
||||
var env = EnvGen.kr(Env.perc(0.001, dur, 1, -8), doneAction: 2);
|
||||
var sig = HPF.ar(WhiteNoise.ar, 7000) * env * amp;
|
||||
Out.ar(out, Pan2.ar(sig, pan));
|
||||
}).add;
|
||||
|
||||
"[data-only/synthdefs_play] 6 SynthDefs added (\\bp_*)".postln;
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Validate balance, then run test to verify pass**
|
||||
|
||||
Run: `awk '{for(i=1;i<=length();i++){c=substr($0,i,1);if(c=="(")p++;else if(c==")")p--;if(c=="[")b++;else if(c=="]")b--;if(i==1&&c=="(")t++}}END{printf "P:%d B:%d TLB:%d\n",p,b,t}' sound_algo/data_only/synthdefs_play.scd`
|
||||
Expected: `P:0 B:0 TLB:1` (if non-zero P/B, it's only comment parens — confirm with `sclang` parse; the test in step 4 also catches real syntax errors).
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang /tmp/test_bp_synthdefs.scd 2>&1 | grep -E "PASS|FAIL|Error"`
|
||||
Expected: six `[PASS]` lines, no `[FAIL]`/`Error`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/synthdefs_play.scd
|
||||
git commit -m "feat(sound): bp_* SynthDef palette for body-play"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `~bpModes` table + `~bpScale` quantizer
|
||||
|
||||
**Files:**
|
||||
- Create: `sound_algo/data_only/scene_pose_play.scd` (first block only — modes + scale helper; engine added in Task 4)
|
||||
- Test: `/tmp/test_bp_scale.scd`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `~bpModes` — Dictionary keyed by `\ambient`/`\pulse`/`\drive`, each an Event:
|
||||
`(label: Int, scale: [semitones], root: midinote, tempoLo:, tempoHi:, kick: Bool, hatMul:, name:)`.
|
||||
- `~bpModeForLabel.(labelIdx)` → mode key (`\pulse` for 0, `\ambient` for 1, `\drive` for 2, default `\pulse`).
|
||||
- `~bpScale.(modeKey, pos01, octaves=2)` → midinote (Float) quantized to that mode's scale; `pos01` 0..1 maps low→high across `octaves` octaves from the root.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `/tmp/test_bp_scale.scd`:
|
||||
|
||||
```supercollider
|
||||
(
|
||||
"/Users/electron/Documents/Projets/AV-Live/sound_algo/data_only/scene_pose_play.scd".load;
|
||||
[
|
||||
[ ~bpModeForLabel.(1) == \ambient, "label 1 -> ambient" ],
|
||||
[ ~bpModeForLabel.(0) == \pulse, "label 0 -> pulse" ],
|
||||
[ ~bpModeForLabel.(2) == \drive, "label 2 -> drive" ],
|
||||
[ ~bpModeForLabel.(nil) == \pulse, "nil label -> pulse default" ],
|
||||
// pos 0 -> root of pulse (A3 = 57)
|
||||
[ ~bpScale.(\pulse, 0.0) == 57, "pulse pos0 == root 57" ],
|
||||
// every quantized note is on the pulse penta scale (degrees of 57)
|
||||
[ (0,0.05..1.0).every { |p| var n = ~bpScale.(\pulse, p);
|
||||
[0,2,4,7,9].includes((n - 57) % 12) }, "pulse notes in penta scale" ],
|
||||
].do { |t| if(t[0]) { ("[PASS] " ++ t[1]).postln } { ("[FAIL] " ++ t[1]).postln } };
|
||||
0.exit;
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang /tmp/test_bp_scale.scd 2>&1 | grep -E "PASS|FAIL|Error"`
|
||||
Expected: load/compile error (file missing).
|
||||
|
||||
- [ ] **Step 3: Write modes + quantizer**
|
||||
|
||||
Create `sound_algo/data_only/scene_pose_play.scd` (single top-level block; the engine is appended in Task 4 — for now it ends after the quantizer):
|
||||
|
||||
```supercollider
|
||||
// =====================================================================
|
||||
// data_only/scene_pose_play.scd -- le corps joue de la musique.
|
||||
// Action -> mode, main D -> lead (geste), main G -> accord (arpege),
|
||||
// vitesse du corps -> tempo. Active via ~doScene.(\play).
|
||||
// =====================================================================
|
||||
(
|
||||
// -- 1) Table des modes -------------------------------------------------
|
||||
~bpModes = (
|
||||
ambient: (label: 1, name: "ambient",
|
||||
scale: [0,2,3,5,7,8,10], root: 45, tempoLo: 56, tempoHi: 80,
|
||||
kick: false, hatMul: 0.5),
|
||||
pulse: (label: 0, name: "pulse",
|
||||
scale: [0,2,4,7,9], root: 57, tempoLo: 88, tempoHi: 120,
|
||||
kick: true, hatMul: 1.0),
|
||||
drive: (label: 2, name: "drive",
|
||||
scale: [0,2,3,5,7,9,10], root: 55, tempoLo: 118, tempoHi: 150,
|
||||
kick: true, hatMul: 1.6),
|
||||
);
|
||||
|
||||
~bpModeForLabel = { |labelIdx|
|
||||
switch(labelIdx,
|
||||
0, \pulse, 1, \ambient, 2, \drive, \pulse); // defaut pulse
|
||||
};
|
||||
|
||||
// -- 2) Quantification sur l'echelle du mode ---------------------------
|
||||
// pos01 (0..1) -> degre sur l'echelle, etale sur `octaves` octaves depuis
|
||||
// la root du mode. Renvoie une midinote.
|
||||
~bpScale = { |modeKey, pos01, octaves=2|
|
||||
var m = ~bpModes[modeKey];
|
||||
var scale = m[\scale];
|
||||
var nDeg = scale.size * octaves;
|
||||
var idx = (pos01.clip(0, 1) * (nDeg - 1)).round.asInteger;
|
||||
var oct = idx div: scale.size;
|
||||
var deg = idx % scale.size;
|
||||
m[\root] + (oct * 12) + scale[deg];
|
||||
};
|
||||
|
||||
"[data-only/scene_pose_play] modes + scale ready".postln;
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Validate balance + run test**
|
||||
|
||||
Run: `awk '{for(i=1;i<=length();i++){c=substr($0,i,1);if(c=="(")p++;else if(c==")")p--;if(c=="[")b++;else if(c=="]")b--;if(i==1&&c=="(")t++}}END{printf "P:%d B:%d TLB:%d\n",p,b,t}' sound_algo/data_only/scene_pose_play.scd`
|
||||
Expected: `P:0 B:0 TLB:1`.
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang /tmp/test_bp_scale.scd 2>&1 | grep -E "PASS|FAIL|Error"`
|
||||
Expected: six `[PASS]`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/scene_pose_play.scd
|
||||
git commit -m "feat(sound): body-play modes table and scale quantizer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `~bpMap` pure mapping functions
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/scene_pose_play.scd` (append `~bpMap` block before the closing of the file's logic — keep one top-level block by extending the existing `( ... )`)
|
||||
- Test: `/tmp/test_bp_map.scd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `~bpModes`, `~bpModeForLabel`, `~bpScale` (Task 2). Reads globals `~poseState`, `~poseKin`, `~handFeat` (Dictionaries/Event; may be empty).
|
||||
- Produces `~bpMap`, an Event of pure functions:
|
||||
- `~bpMap[\tempo].(modeKey, speed)` → bpm clipped to the mode's `[tempoLo,tempoHi]`. `speed` nil → tempoLo.
|
||||
- `~bpMap[\leadNote].(modeKey, hf)` → midinote from `hf[\ry] ? 0` via `~bpScale`. Right hand high → higher note.
|
||||
- `~bpMap[\leadTrig].(hf, lastTrigTime, now)` → Bool: true when `(hf[\rspeed] ? 0) > 0.12` AND `now - lastTrigTime > 0.09` (threshold + refractory).
|
||||
- `~bpMap[\chordDegrees].(modeKey, hf)` → array of 3 midinotes (a I/IV/V/vi triad) chosen by `hf[\ly] ? 0` (left hand height → chord index 0..3), spanning `(hf[\lopen] ? 0)`-driven octaves.
|
||||
- `~bpMap[\density].(modeKey, speed)` → Int 1..4 hat subdivisions from `speed` and the mode's `hatMul`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `/tmp/test_bp_map.scd`:
|
||||
|
||||
```supercollider
|
||||
(
|
||||
"/Users/electron/Documents/Projets/AV-Live/sound_algo/data_only/scene_pose_play.scd".load;
|
||||
~poseState = Dictionary.new; ~poseKin = Dictionary.new; ~handFeat = Dictionary.new;
|
||||
[
|
||||
[ ~bpMap[\tempo].(\pulse, nil) == 88, "tempo nil -> tempoLo 88" ],
|
||||
[ ~bpMap[\tempo].(\pulse, 999) == 120, "tempo high clipped to 120" ],
|
||||
[ { var n = ~bpMap[\leadNote].(\pulse, (ry: 1.0)); [0,2,4,7,9].includes((n-57)%12) }.value, "leadNote on scale" ],
|
||||
[ ~bpMap[\leadTrig].((rspeed: 0.5), 0, 1.0) == true, "fast hand triggers" ],
|
||||
[ ~bpMap[\leadTrig].((rspeed: 0.5), 0.95, 1.0) == false, "refractory blocks" ],
|
||||
[ ~bpMap[\leadTrig].((rspeed: 0.0), 0, 1.0) == false, "still hand no trig" ],
|
||||
[ ~bpMap[\chordDegrees].(\pulse, (ly: 0.0, lopen: 0)).size == 3, "chord is a triad" ],
|
||||
[ ~bpMap[\density].(\drive, 999) >= ~bpMap[\density].(\ambient, 999), "drive denser than ambient" ],
|
||||
// empty handFeat must not error
|
||||
[ { ~bpMap[\leadNote].(\pulse, ()); ~bpMap[\chordDegrees].(\pulse, ()); true }.value, "empty hf no error" ],
|
||||
].do { |t| if(t[0]) { ("[PASS] " ++ t[1]).postln } { ("[FAIL] " ++ t[1]).postln } };
|
||||
0.exit;
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang /tmp/test_bp_map.scd 2>&1 | grep -E "PASS|FAIL|Error"`
|
||||
Expected: errors / `[FAIL]` (`~bpMap` nil).
|
||||
|
||||
- [ ] **Step 3: Append `~bpMap` to `scene_pose_play.scd`**
|
||||
|
||||
Insert this block right before the file's final `"[...] ready".postln;` line (inside the same top-level `( ... )`):
|
||||
|
||||
```supercollider
|
||||
// -- 3) Mapping pose -> musique (fonctions pures) -----------------------
|
||||
~bpMap = ();
|
||||
|
||||
~bpMap[\tempo] = { |modeKey, speed|
|
||||
var m = ~bpModes[modeKey];
|
||||
(speed ? 0).linlin(0.0, 0.6, m[\tempoLo], m[\tempoHi]).clip(m[\tempoLo], m[\tempoHi]);
|
||||
};
|
||||
|
||||
~bpMap[\leadNote] = { |modeKey, hf|
|
||||
~bpScale.(modeKey, (hf[\ry] ? 0), 2);
|
||||
};
|
||||
|
||||
~bpMap[\leadTrig] = { |hf, lastTrigTime, now|
|
||||
((hf[\rspeed] ? 0) > 0.12) and: { (now - lastTrigTime) > 0.09 };
|
||||
};
|
||||
|
||||
// 4 triades diatoniques (I, IV, V, vi) construites sur l'echelle du mode.
|
||||
~bpMap[\chordDegrees] = { |modeKey, hf|
|
||||
var m = ~bpModes[modeKey];
|
||||
var sc = m[\scale];
|
||||
var roots = [0, 3, 4, 5]; // index de degre des 4 accords
|
||||
var ci = ((hf[\ly] ? 0).clip(0,1) * 3).round.asInteger; // main G hauteur -> accord
|
||||
var base = roots[ci];
|
||||
var octShift = ((hf[\lopen] ? 0) > 0.5).if({ 12 }, { 0 });
|
||||
[0, 2, 4].collect { |step|
|
||||
var d = base + step;
|
||||
m[\root] + ((d div: sc.size) * 12) + sc[d % sc.size] + octShift;
|
||||
};
|
||||
};
|
||||
|
||||
~bpMap[\density] = { |modeKey, speed|
|
||||
var m = ~bpModes[modeKey];
|
||||
(((speed ? 0).linlin(0.0, 0.6, 1, 4) * m[\hatMul]).round.asInteger).clip(1, 4);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Validate balance + run test**
|
||||
|
||||
Run the `awk` balance command from Task 2 step 4.
|
||||
Expected: `P:0 B:0 TLB:1`.
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang /tmp/test_bp_map.scd 2>&1 | grep -E "PASS|FAIL|Error"`
|
||||
Expected: nine `[PASS]`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/scene_pose_play.scd
|
||||
git commit -m "feat(sound): body-play pose->music mapping functions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `~bpEngine` (clock + routines + pad + cross-fade)
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/scene_pose_play.scd` (append `~bpEngine` + `~doScene[\play]` registration inside the top-level block)
|
||||
- Test: `/tmp/test_bp_engine.scd` (boots scsynth)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `~bpModes`, `~bpModeForLabel`, `~bpScale`, `~bpMap` (Tasks 2–3); the `\bp_*` SynthDefs (Task 1); globals `~poseState`/`~poseKin`/`~handFeat`.
|
||||
- Produces:
|
||||
- `~bpEngine` Event with `running` (Bool), `clock` (TempoClock), `mode` (current key), `start`/`stop` functions, `verbose` (Bool).
|
||||
- `~bpFirstBody` → helper returning the first value of `~poseKin` / `~poseState` (single-body), or nil.
|
||||
- registers `~doScene[\play]` (calls `~bpEngine[\start]`); `~doScene` selector already supports keyed scenes (see `scenes.scd`).
|
||||
- Behaviour per control tick (every `~bpCtlDur = 0.05` s): read first body's `speed`+`labelIdx`, smooth tempo (`.lag` via running interpolation), set `clock.tempo`; guard mode switch (≥5 stable ticks) and cross-fade the pad; on each beat play kick/bass/hats per mode+density; each arp subdivision play next chord note; watch `~handFeat[\rspeed]` and fire `\bp_lead`. When no body: gate everything except a quiet pad.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `/tmp/test_bp_engine.scd`:
|
||||
|
||||
```supercollider
|
||||
(
|
||||
s.waitForBoot({
|
||||
var leadCount = 0;
|
||||
"/Users/electron/Documents/Projets/AV-Live/sound_algo/data_only/synthdefs_play.scd".load;
|
||||
"/Users/electron/Documents/Projets/AV-Live/sound_algo/data_only/scene_pose_play.scd".load;
|
||||
s.sync;
|
||||
~poseState = Dictionary.new; ~poseState[\p0] = (labelIdx: 0, probs: [0.9,0.05,0.05]);
|
||||
~poseKin = Dictionary.new; ~poseKin[\p0] = (speed: 0.3, accel: 0.1, symmetry: 0.0);
|
||||
~handFeat = Dictionary.new;
|
||||
[\lx,\ly,\lopen,\lspeed,\rx,\ry,\ropen,\rspeed,\dist].do { |k| ~handFeat[k] = 0.4 };
|
||||
// count lead synths via a tap on the default group's node count proxy:
|
||||
~bpEngine[\verbose] = true;
|
||||
if(~doScene.notNil) { ~doScene.(\play) } { ~bpEngine[\start].() };
|
||||
// drive a few "gestures": pulse rspeed high to force lead triggers
|
||||
Routine({
|
||||
4.do { ~handFeat[\rspeed] = 0.5; 0.15.wait; ~handFeat[\rspeed] = 0.0; 0.15.wait; };
|
||||
1.0.wait;
|
||||
if(~bpEngine[\running]) { "[PASS] engine running, no error" .postln }
|
||||
{ "[FAIL] engine not running".postln };
|
||||
if((~bpEngine[\clock].tempo * 60) >= 88) { "[PASS] tempo >= pulse floor".postln }
|
||||
{ "[FAIL] tempo below floor".postln };
|
||||
~bpEngine[\stop].();
|
||||
0.2.wait;
|
||||
if(~bpEngine[\running].not) { "[PASS] engine stopped clean".postln }
|
||||
{ "[FAIL] still running".postln };
|
||||
0.exit;
|
||||
}).play(AppClock);
|
||||
});
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang /tmp/test_bp_engine.scd 2>&1 | grep -E "PASS|FAIL|Error|Non Boolean"`
|
||||
Expected: error / `[FAIL]` (`~bpEngine` nil).
|
||||
|
||||
- [ ] **Step 3: Append the engine + scene registration**
|
||||
|
||||
Insert before the file's final `"[...] ready".postln;` (inside the top-level block):
|
||||
|
||||
```supercollider
|
||||
// -- 5) Helpers corps unique -------------------------------------------
|
||||
~bpFirstBody = {
|
||||
var kin = ~poseKin.notNil.if({ ~poseKin.values.detect { |v| v.notNil } });
|
||||
var st = ~poseState.notNil.if({ ~poseState.values.detect { |v| v.notNil } });
|
||||
(kin: kin, state: st);
|
||||
};
|
||||
|
||||
// -- 6) Le moteur -------------------------------------------------------
|
||||
~bpCtlDur = 0.05;
|
||||
~bpEngine = (
|
||||
running: false, clock: nil, mode: \pulse, verbose: false,
|
||||
pad: nil, chord: [57, 60, 64], lastLead: 0.0, arpStep: 0,
|
||||
modeCand: \pulse, modeStable: 0, tempoSm: 100.0, routine: nil,
|
||||
start: {
|
||||
if(~bpEngine[\running]) { "[bp] deja en cours".postln } {
|
||||
~bpEngine[\running] = true;
|
||||
~bpEngine[\clock] = TempoClock(100/60).permanent_(true);
|
||||
~bpEngine[\pad] = Synth(\bp_pad, [\freq, ~bpModes[\pulse][\root].midicps, \amp, 0.0]);
|
||||
~bpEngine[\routine] = Routine({
|
||||
var beatCount = 0;
|
||||
inf.do {
|
||||
var b = ~bpFirstBody.value;
|
||||
var hasBody = b[\kin].notNil;
|
||||
var label = (b[\state] ? ()).at(\labelIdx);
|
||||
var speed = (b[\kin] ? ()).at(\speed) ? 0;
|
||||
var modeKey = ~bpModeForLabel.(label);
|
||||
var m, now, hf, tgtTempo;
|
||||
// -- mode stability guard + cross-fade --
|
||||
if(modeKey == ~bpEngine[\modeCand]) {
|
||||
~bpEngine[\modeStable] = ~bpEngine[\modeStable] + 1;
|
||||
} { ~bpEngine[\modeCand] = modeKey; ~bpEngine[\modeStable] = 0; };
|
||||
if((~bpEngine[\modeStable] == 5) and: { modeKey != ~bpEngine[\mode] }) {
|
||||
~bpEngine[\mode] = modeKey;
|
||||
~bpEngine[\pad].set(\gate, 0); // fade out ancien pad
|
||||
~bpEngine[\pad] = Synth(\bp_pad, [
|
||||
\freq, ~bpModes[modeKey][\root].midicps, \amp, 0.18]);
|
||||
if(~bpEngine[\verbose]) { ("[bp] mode -> " ++ modeKey).postln };
|
||||
};
|
||||
m = ~bpModes[~bpEngine[\mode]];
|
||||
// -- tempo lisse (~2s) puis clock --
|
||||
tgtTempo = ~bpMap[\tempo].(~bpEngine[\mode], speed);
|
||||
~bpEngine[\tempoSm] = ~bpEngine[\tempoSm]
|
||||
+ ((tgtTempo - ~bpEngine[\tempoSm]) * 0.03);
|
||||
~bpEngine[\clock].tempo = ~bpEngine[\tempoSm] / 60;
|
||||
// -- pad: silence si pas de corps --
|
||||
~bpEngine[\pad].set(\amp, hasBody.if({ 0.18 }, { 0.06 }));
|
||||
// -- lead (main D au geste) --
|
||||
hf = ~handFeat ? ();
|
||||
now = thisThread.seconds;
|
||||
if(hasBody and: { ~bpMap[\leadTrig].(hf, ~bpEngine[\lastLead], now) }) {
|
||||
~bpEngine[\lastLead] = now;
|
||||
Synth(\bp_lead, [
|
||||
\freq, ~bpMap[\leadNote].(~bpEngine[\mode], hf).midicps,
|
||||
\amp, (hf[\rspeed] ? 0).linlin(0.12, 0.6, 0.15, 0.4),
|
||||
\cutoff, (hf[\ropen] ? 0).linexp(0, 1, 600, 5000),
|
||||
\pan, (hf[\rx] ? 0.5).linlin(0, 1, -0.8, 0.8)]);
|
||||
};
|
||||
// -- accord courant (main G) --
|
||||
if(hasBody) { ~bpEngine[\chord] = ~bpMap[\chordDegrees].(~bpEngine[\mode], hf) };
|
||||
// -- sequence rythmique sur le beat (toutes les ~1 noire) --
|
||||
if((beatCount % (0.25 / ~bpCtlDur).round.max(1)) == 0) {
|
||||
var dens = ~bpMap[\density].(~bpEngine[\mode], speed);
|
||||
if(hasBody) {
|
||||
// arpege : note suivante de l'accord
|
||||
~bpEngine[\arpStep] = (~bpEngine[\arpStep] + 1) % ~bpEngine[\chord].size;
|
||||
Synth(\bp_arp, [\freq, ~bpEngine[\chord][~bpEngine[\arpStep]].midicps]);
|
||||
// hats selon densite
|
||||
dens.do { |i| Synth(\bp_hat, [\amp, 0.18, \pan, 0.3.rand2]) };
|
||||
// kick + bass selon le mode
|
||||
if(m[\kick] and: { (beatCount % (1.0 / ~bpCtlDur).round.max(1)) == 0 }) {
|
||||
Synth(\bp_kick);
|
||||
Synth(\bp_bass, [\freq, (~bpEngine[\chord][0] - 12).midicps]);
|
||||
};
|
||||
};
|
||||
};
|
||||
beatCount = beatCount + 1;
|
||||
~bpCtlDur.wait;
|
||||
};
|
||||
}).play(~bpEngine[\clock]);
|
||||
"[bp] STARTED".postln;
|
||||
};
|
||||
},
|
||||
stop: {
|
||||
~bpEngine[\routine] !? { |r| r.stop };
|
||||
~bpEngine[\pad] !? { |p| p.set(\gate, 0) };
|
||||
~bpEngine[\running] = false;
|
||||
"[bp] STOPPED".postln;
|
||||
},
|
||||
);
|
||||
|
||||
// -- 7) Enregistrement scene -------------------------------------------
|
||||
~doScene !? {
|
||||
// ~doScene est le selecteur defini dans scenes.scd ; on greffe \play.
|
||||
~bpRegisterPlay = { ~bpEngine[\start].() };
|
||||
};
|
||||
~doScenePlay = { ~bpEngine[\start].() };
|
||||
```
|
||||
|
||||
Note: `scenes.scd` exposes `~doScene` as a selector dispatching on keyed scene functions (`~doScene<Name>`). Registration here adds `~doScenePlay`; Task 5 wires `~doScene.(\play)` to it if the selector needs an explicit entry (verify the `scenes.scd` dispatch pattern during implementation and match it — do not invent a new mechanism).
|
||||
|
||||
- [ ] **Step 4: Validate balance + run engine test**
|
||||
|
||||
Run the `awk` balance command.
|
||||
Expected: `P:0 B:0 TLB:1`.
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang /tmp/test_bp_engine.scd 2>&1 | grep -E "PASS|FAIL|Error|Non Boolean"`
|
||||
Expected: `[PASS] engine running`, `[PASS] tempo >= pulse floor`, `[PASS] engine stopped clean`; no `Error`/`Non Boolean`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/scene_pose_play.scd
|
||||
git commit -m "feat(sound): body-play engine clock arp lead rhythm crossfade"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Boot integration + `~doScene.(\play)` wiring
|
||||
|
||||
**Files:**
|
||||
- Modify: `sound_algo/data_only/boot.scd` (load the two new files; verify `~doScene` dispatch)
|
||||
- Modify: `sound_algo/data_only/scene_pose_play.scd` (only if Task 4's `scenes.scd` inspection shows the selector needs an explicit `\play` case)
|
||||
- Test: `/tmp/test_bp_boot.scd` (full integral boot) + live
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything from Tasks 1–4.
|
||||
- Produces: `boot.scd` that loads `synthdefs_play.scd` (after `synthdefs.scd`) and `scene_pose_play.scd` (after `scene_pose_action.scd`); `~doScene.(\play)` starts the engine.
|
||||
|
||||
- [ ] **Step 1: Inspect the scene selector**
|
||||
|
||||
Run: `grep -nE "~doScene|~doSceneCavity|switch|\\\\cavity" sound_algo/data_only/scenes.scd | head`
|
||||
Note how `~doScene.(\name)` dispatches (it maps a key to `~doScene<Name>` or a switch). Match that exact pattern so `~doScene.(\play)` calls `~doScenePlay`/`~bpEngine[\start]`. If it's a `switch`, add a `\play` case there or in `scene_pose_play.scd`; if it looks up `~doScene ++ Name`, the `~doScenePlay` from Task 4 already satisfies it.
|
||||
|
||||
- [ ] **Step 2: Write the failing integral-boot test**
|
||||
|
||||
Create `/tmp/test_bp_boot.scd`:
|
||||
|
||||
```supercollider
|
||||
(
|
||||
"/Users/electron/Documents/Projets/AV-Live/sound_algo/data_only/boot.scd".load;
|
||||
AppClock.sched(14, {
|
||||
if(SynthDescLib.global.at(\bp_lead).notNil) { "[PASS] bp_lead loaded by boot".postln }
|
||||
{ "[FAIL] bp_lead missing after boot".postln };
|
||||
if(~bpEngine.notNil) { "[PASS] ~bpEngine present".postln }
|
||||
{ "[FAIL] ~bpEngine missing".postln };
|
||||
~doScene.(\play);
|
||||
0.5.wait;
|
||||
if(~bpEngine[\running]) { "[PASS] ~doScene.(\\play) started engine".postln }
|
||||
{ "[FAIL] play scene did not start".postln };
|
||||
0.exit;
|
||||
nil;
|
||||
});
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run to verify it fails**
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang /tmp/test_bp_boot.scd 2>&1 | grep -E "PASS|FAIL"`
|
||||
Expected: `[FAIL] bp_lead missing` (boot does not load the new files yet).
|
||||
|
||||
- [ ] **Step 4: Wire boot.scd**
|
||||
|
||||
In `sound_algo/data_only/boot.scd`, find the load sequence (around `(~base ++ "synthdefs.scd").load;` and `(~base ++ "scene_pose_action.scd").load;`). Add, right after `synthdefs.scd`:
|
||||
|
||||
```supercollider
|
||||
(~base ++ "synthdefs_play.scd").load;
|
||||
s.sync;
|
||||
```
|
||||
|
||||
and right after `scene_pose_action.scd`'s load + wait:
|
||||
|
||||
```supercollider
|
||||
(~base ++ "scene_pose_play.scd").load;
|
||||
0.5.wait;
|
||||
```
|
||||
|
||||
(Use the same `~base ++ "..."` + `.load` + `.wait` pattern already present; do not reorder existing loads.)
|
||||
|
||||
- [ ] **Step 5: Validate balance + run integral-boot test**
|
||||
|
||||
Run the `awk` balance command on `boot.scd` and `scene_pose_play.scd`.
|
||||
Expected: `P:0 B:0 TLB:1` each (boot.scd may show the pre-existing comment-paren false positive `P:-8`; confirm unchanged vs `git show HEAD:.../boot.scd | awk ...` and that sclang parses — same check as commit 83dc910).
|
||||
|
||||
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang /tmp/test_bp_boot.scd 2>&1 | grep -E "PASS|FAIL|Non Boolean|Error"`
|
||||
Expected: three `[PASS]`.
|
||||
|
||||
- [ ] **Step 6: Live smoke (on macm1, GUI session)**
|
||||
|
||||
Launch `data_only_viz --pose` (MediaPipe CPU) then the integral boot via `.command`/`open` (per `reference-macm1-dev-node-avlive`). In the running sclang, the engine starts with `~doScene.(\play)`. Stand in frame and gesture: hear the lead fire on right-hand jerks, the arp/chord follow the left hand, the rhythm follow your speed; change posture to hear the mode cross-fade. (Manual — no automated assertion.)
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add sound_algo/data_only/boot.scd sound_algo/data_only/scene_pose_play.scd
|
||||
git commit -m "feat(sound): wire body-play into data-only boot"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review notes
|
||||
|
||||
- **Spec coverage:** modes (Task 2) · scale quantization (Task 2) · right-hand gesture lead + left-hand arpeggiated chord (Tasks 3–4) · speed→tempo smoothed+bounded (Tasks 3–4) · 3-mode cross-fade with stability guard (Task 4) · idle pad when no body (Task 4) · new `\bp_*` palette (Task 1) · separate file, `~doScene.(\play)`, not in `\all` (Tasks 4–5) · runs alongside `scene_pose_action.scd` (boot order, Task 5). `dist→width` / `symmetry→balance` are folded into the lead `\pan` mapping (rx) and left as a noted refinement during live tuning — minor, not a separate task.
|
||||
- **Placeholder scan:** all code is concrete; musical constants (thresholds, tempos, roots) have explicit starting values, tunable by ear in `~bpModes`/`~bpMap` — not placeholders.
|
||||
- **Type consistency:** `~bpModes` keys (`\ambient`/`\pulse`/`\drive`), `~bpMap` keys (`\tempo`/`\leadNote`/`\leadTrig`/`\chordDegrees`/`\density`), and `~bpEngine` fields are used identically across tasks. `~bpScale.(modeKey, pos01, octaves)` signature matches its callers.
|
||||
- **Known risk:** `~doScene` dispatch mechanism is verified in Task 5 step 1 before wiring (don't invent a new one). The `scenes.scd` `~doScene` selector pattern governs how `\play` is registered.
|
||||
Reference in New Issue
Block a user