From 73f85aa27653c7a372bd1b526cb444e6ed3c233b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:14:06 +0200 Subject: [PATCH] docs: plan body-play implementation 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. --- .../superpowers/plans/2026-06-27-body-play.md | 626 ++++++++++++++++++ 1 file changed, 626 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-27-body-play.md diff --git a/docs/superpowers/plans/2026-06-27-body-play.md b/docs/superpowers/plans/2026-06-27-body-play.md new file mode 100644 index 0000000..ecccb6a --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-body-play.md @@ -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`). 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` 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.