# 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.
