docs: lot A plan, unified note helpers

This commit is contained in:
clement
2026-06-28 15:26:34 +02:00
parent 9ad0ddda13
commit 2aac8f57f6
@@ -0,0 +1,569 @@
# SC Refactor Lot A — Unified music helpers — 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:** Create one source of truth for scales + degree→note resolution
(`palette/note.scd`) and route the three duplicated resolvers (`~ccNote`,
`~lpNote`, `~fpResolve`) through it, without changing concert behavior.
**Architecture:** A new dependency-free `palette/note.scd` defines `~scales`
(named semitone-offset arrays), `~noteFor` and `~freqFor`. It loads in both
boot paths before any consumer. `~ccNote`/`~lpNote`/`~fpResolve` become thin
wrappers. The bespoke 8-note `~scaleXxx` arrays in `palette/scales.scd` (used
by the LIVE melody/harmony generators) are LEFT UNCHANGED — Lot A only unifies
the degree→note resolvers, not the LIVE melody arrays.
**Tech Stack:** SuperCollider (sclang), headless `.scd` tests via
`/Applications/SuperCollider.app/Contents/MacOS/sclang`.
## Global Constraints
- Branch: `refactor/sc-harmonize` (never `main`).
- `.scd` `.load`-compatible files: exactly ONE top-level `(...)` block; balance
must be `P:0 B:0` (awk check below).
- SC env vars lowercase `~xxx`. Comments in French. No emojis.
- `sclang` is NOT on PATH — use
`/Applications/SuperCollider.app/Contents/MacOS/sclang`.
- Headless tests boot no server (`s.serverRunning` false) and end with `0.exit`.
- Behavior parity: concert (`~ccNote`) and launchpad (`~lpNote`) note output
must be identical to today. `~fpResolve` piano-mode notes MAY change (it
moves from bespoke absolute arrays to canonical wrapped scales) — this is the
intended harmonization and is called out in Task 5.
- Commits: subject ≤ 50 chars, no AI attribution, no `--no-verify`, no
underscore in scope.
- Balance check command (must print `P:0 B:0`):
`awk 'BEGIN{p=0;b=0} {for(i=1;i<=length($0);i++){c=substr($0,i,1); if(c=="(")p++; if(c==")")p--; if(c=="[")b++; if(c=="]")b--}} END{print "P:"p" B:"b}' <file>`
---
### Task 1: `palette/note.scd` — source of truth + headless test
**Files:**
- Create: `sound_algo/palette/note.scd`
- Test: `sound_algo/tests/test_note.scd`
**Interfaces:**
- Produces:
- `~scales` — IdentityDictionary: Symbol → Array of Int semitone offsets.
Keys: `minor, dorian, phrygian, penta, minorPent, blues, japanese, hijaz,
hirajoshi`.
- `~noteFor.(degree:Int, scaleName:Symbol=\minor, root:Int=60, octave:Int=0)
-> Int` (MIDI note, octave-carrying wrap, floored mod for negative degrees).
- `~freqFor.(degree, scaleName, root, octave) -> Float` (`~noteFor(...).midicps`).
- [ ] **Step 1: Write the failing test**
Create `sound_algo/tests/test_note.scd`:
```supercollider
// Headless unit test for palette/note.scd.
// Run: sclang sound_algo/tests/test_note.scd
(
var ok = true, base;
base = thisProcess.nowExecutingPath.dirname.dirname ++ "/";
(base ++ "palette/note.scd").load;
// root note of minor pentatonic at root 60
if(~noteFor.(0, \minorPent, 60, 0) != 60) { ok = false; "FAIL: root".postln };
// degree 5 wraps one octave on a 5-note scale -> 72
if(~noteFor.(5, \minorPent, 60, 0) != 72) { ok = false; "FAIL: wrap".postln };
// minor degree 2 = +3 semitones -> 63
if(~noteFor.(2, \minor, 60, 0) != 63) { ok = false; "FAIL: minor".postln };
// concert parity: minor root 45 degree 2 -> 48 (old ~ccNote value)
if(~noteFor.(2, \minor, 45, 0) != 48) { ok = false; "FAIL: cc".postln };
// negative degree descends with floored mod: -1 on minor -> 55
if(~noteFor.(-1, \minor, 60, 0) != 55) { ok = false; "FAIL: neg".postln };
// freq matches midicps
if(~freqFor.(0, \minorPent, 60, 0) != 60.midicps) { ok = false; "FAIL: freq".postln };
if(ok) { "PASS test_note".postln } { "TESTS FAILED".postln };
0.exit;
)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/tests/test_note.scd`
Expected: error / `~noteFor` is nil (file does not exist yet).
- [ ] **Step 3: Write the implementation**
Create `sound_algo/palette/note.scd`:
```supercollider
// =====================================================================
// palette/note.scd -- Source de verite : gammes + degree->note
//
// Une seule definition de la resolution degree->note MIDI, consommee par
// les resolveurs (~ccNote concert, ~lpNote launchpad, ~fpResolve air-piano).
// Independant : aucun pre-requis, chargeable dans LES DEUX chemins de boot
// (LIVE et data-only) AVANT tout consommateur.
//
// NB : les arrays ~scaleXxx de palette/scales.scd (melodies LIVE, 8 notes
// sur-mesure) sont distincts et NON touches ici.
// Un seul bloc top-level (compatible .load).
// =====================================================================
(
// Gammes en OFFSETS de demi-tons depuis la fondamentale.
~scales = IdentityDictionary[
\minor -> [0, 2, 3, 5, 7, 8, 10],
\dorian -> [0, 2, 3, 5, 7, 9, 10],
\phrygian -> [0, 1, 3, 5, 7, 8, 10],
\penta -> [0, 2, 4, 7, 9], // pentatonique majeure (concert)
\minorPent -> [0, 3, 5, 7, 10],
\blues -> [0, 3, 5, 6, 7, 10],
\japanese -> [0, 2, 5, 7, 9],
\hijaz -> [0, 1, 4, 5, 7, 8, 10],
\hirajoshi -> [0, 2, 3, 7, 8]
];
// degree -> note MIDI, avec report d'octave (mod plancher gere les degres
// negatifs : -1 descend). root en MIDI, octave en octaves entieres.
~noteFor = { |degree, scaleName = \minor, root = 60, octave = 0|
var sc = ~scales[scaleName] ? ~scales[\minor];
var d = degree.asInteger;
(root + ((octave + (d div: sc.size)) * 12) + sc[d % sc.size]).asInteger;
};
~freqFor = { |degree, scaleName = \minor, root = 60, octave = 0|
~noteFor.(degree, scaleName, root, octave).midicps;
};
"[OK] palette/note.scd : ~scales, ~noteFor, ~freqFor".postln;
)
```
- [ ] **Step 4: Run test + balance check**
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/tests/test_note.scd`
Expected: `PASS test_note`
Run the balance check on `sound_algo/palette/note.scd` — expected `P:0 B:0`.
- [ ] **Step 5: Commit**
```bash
git add sound_algo/palette/note.scd sound_algo/tests/test_note.scd
git commit -m "feat: unified note/scale source of truth"
```
---
### Task 2: Load `note.scd` in all boot paths (before consumers)
**Files:**
- Modify: `sound_algo/live/_load.scd:37` (before `palette/scales.scd`)
- Modify: `sound_algo/data_only/boot.scd:151` (before `control/data_feeds.scd`)
- Modify: `sound_algo/boot.data-only.scd:40` (before `control/data_feeds.scd`)
**Interfaces:**
- Consumes: `palette/note.scd` (Task 1).
- Produces: `~scales`/`~noteFor`/`~freqFor` available in both runtimes before
any resolver loads.
- [ ] **Step 1: LIVE path — load before scales.scd**
In `sound_algo/live/_load.scd`, the block around line 37 currently is:
```supercollider
(~base ++ "palette/scales.scd").load;
(~base ++ "palette/melodies/index.scd").load;
```
Change to:
```supercollider
(~base ++ "palette/note.scd").load;
(~base ++ "palette/scales.scd").load;
(~base ++ "palette/melodies/index.scd").load;
```
- [ ] **Step 2: data-only `data_only/boot.scd` — load before data_feeds**
In `sound_algo/data_only/boot.scd`, around line 151:
```supercollider
(~repoRoot ++ "control/data_feeds.scd").load;
```
Insert BEFORE it:
```supercollider
(~repoRoot ++ "palette/note.scd").load;
(~repoRoot ++ "control/data_feeds.scd").load;
```
(Note: `data_only/boot.scd` uses `~repoRoot` for the `sound_algo/` root — the
same variable its `control/...` loads use. `palette/` is under that root.)
- [ ] **Step 3: data-only `boot.data-only.scd` — load before data_feeds**
In `sound_algo/boot.data-only.scd`, around line 40:
```supercollider
(~base ++ "control/data_feeds.scd").load;
```
Insert BEFORE it:
```supercollider
(~base ++ "palette/note.scd").load;
(~base ++ "control/data_feeds.scd").load;
```
- [ ] **Step 4: Verify both boots still reach READY**
Run (LIVE headless boot smoke):
`/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/boot.scd`
Expected: boots, prints the live ready banner, no `palette/note.scd`-related
error. (Ctrl-C / it will keep running; confirm no error in the first output.)
Run (data-only headless boot smoke), from `sound_algo/data_only`:
`/Applications/SuperCollider.app/Contents/MacOS/sclang boot.scd`
Expected: reaches `AV-Live DATA-ONLY PATCH READY`, log shows
`[OK] palette/note.scd` before `[OK] finger_piano`.
- [ ] **Step 5: Commit**
```bash
git add sound_algo/live/_load.scd sound_algo/data_only/boot.scd sound_algo/boot.data-only.scd
git commit -m "feat: load note helpers in all boot paths"
```
---
### Task 3: Route `~ccNote` through `~noteFor` (concert parity)
**Files:**
- Modify: `sound_algo/data_only/scene_concert.scd:72-82`
- Test: `sound_algo/tests/test_note.scd` (extend)
**Interfaces:**
- Consumes: `~noteFor`, `~scales` (Task 1).
- Produces: `~ccNote.(degree, oct)` unchanged signature/output; `~ccScales`
kept as an alias of `~scales` for any external reader.
- [ ] **Step 1: Add a parity assertion to the test**
Append to `sound_algo/tests/test_note.scd` BEFORE the `if(ok)` line:
```supercollider
// ~ccNote parity: wrapper must match the historical formula
~ccHarmony = (root: 45, scale: \minor, pad: \dark, phrase: 0, octave: 0);
~ccScales = ~scales;
~ccNote = { |degree, oct = 0|
~noteFor.(degree, ~ccHarmony[\scale] ? \minor,
~ccHarmony[\root] ? 45, (~ccHarmony[\octave] ? 0) + oct);
};
if(~ccNote.(2, 0) != 48) { ok = false; "FAIL: ccNote 48".postln };
if(~ccNote.(0, 1) != 57) { ok = false; "FAIL: ccNote oct".postln };
```
(`~ccNote.(0,1)` = root 45 + octave 1 → 45+12+0 = 57.)
- [ ] **Step 2: Run test to verify it fails**
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/tests/test_note.scd`
Expected: still `PASS` actually — this step defines `~ccNote` inline in the
test, so it passes once the formula is right. If it FAILS, the wrapper formula
is wrong; fix it here before touching the real file.
- [ ] **Step 3: Replace the real `~ccNote`/`~ccScales` definition**
In `sound_algo/data_only/scene_concert.scd`, replace lines 72-82:
```supercollider
~ccScales = (
minor: [0,2,3,5,7,8,10], dorian: [0,2,3,5,7,9,10],
phrygian: [0,1,3,5,7,8,10], penta: [0,2,4,7,9]);
~ccHarmony = ~ccHarmony ? (root: 45, scale: \minor, pad: \dark, phrase: 0, octave: 0);
~ccNote = { |degree, oct=0|
var sc = ~ccScales[~ccHarmony[\scale]] ? ~ccScales[\minor];
var d = degree.asInteger;
(~ccHarmony[\root] ? 45)
+ (((~ccHarmony[\octave] ? 0) + oct + (d div: sc.size)) * 12)
+ sc[d % sc.size];
};
```
with:
```supercollider
// Gammes + resolution degree->note : delegue a palette/note.scd (~noteFor).
// ~ccScales reste un alias de ~scales pour les lecteurs externes.
~ccScales = ~scales ? ~ccScales;
~ccHarmony = ~ccHarmony ? (root: 45, scale: \minor, pad: \dark, phrase: 0, octave: 0);
~ccNote = { |degree, oct=0|
~noteFor.(degree, ~ccHarmony[\scale] ? \minor,
~ccHarmony[\root] ? 45, (~ccHarmony[\octave] ? 0) + oct);
};
```
- [ ] **Step 4: Verify**
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/tests/test_note.scd`
Expected: `PASS test_note`
Balance check on `sound_algo/data_only/scene_concert.scd` — expected `P:0 B:0`.
Run the data-only boot smoke (from `sound_algo/data_only`):
`/Applications/SuperCollider.app/Contents/MacOS/sclang boot.scd`
Expected: reaches `PATCH READY`, no error.
- [ ] **Step 5: Commit**
```bash
git add sound_algo/data_only/scene_concert.scd sound_algo/tests/test_note.scd
git commit -m "refactor: ccNote delegates to noteFor"
```
---
### Task 4: Route `~lpNote` through `~ccNote`/`~noteFor`
**Files:**
- Modify: `sound_algo/data_only/launchpad.scd:136`
**Interfaces:**
- Consumes: `~ccNote` (Task 3), `~noteFor` (Task 1).
- Produces: `~lpNote.(deg, oct)` — unchanged primary path (delegates to
`~ccNote` when present). Fallback (concert absent) now uses `~noteFor` with
root 45 minor instead of the old degenerate `[deg%7]` table — a near-dead
path, improved to octave-correct.
- [ ] **Step 1: Replace `~lpNote`**
In `sound_algo/data_only/launchpad.scd` line 136:
```supercollider
~lpNote = { |deg, oct=0| (~ccNote.notNil).if({ ~ccNote.(deg, oct) }, { 45 + (oct*12) + ([0,2,3,5,7,8,10][deg % 7]) }) };
```
with:
```supercollider
~lpNote = { |deg, oct=0|
~ccNote.notNil.if({ ~ccNote.(deg, oct) }, { ~noteFor.(deg, \minor, 45, oct) })
};
```
- [ ] **Step 2: Verify balance + data-only boot smoke**
Balance check on `sound_algo/data_only/launchpad.scd` — expected `P:0 B:0`.
Run the data-only boot smoke (from `sound_algo/data_only`):
`/Applications/SuperCollider.app/Contents/MacOS/sclang boot.scd`
Expected: reaches `PATCH READY`; log still shows
`[data-only/launchpad] ... ready`, no error.
- [ ] **Step 3: Commit**
```bash
git add sound_algo/data_only/launchpad.scd
git commit -m "refactor: lpNote delegates to noteFor"
```
---
### Task 5: Route `~fpResolve` through `~noteFor` (drop embedded scales)
**Files:**
- Modify: `sound_algo/control/finger_piano.scd` (`~fpScaleArr`, `~fpScales`,
`~fpResolve`, `~fpScale_`)
- Modify: `sound_algo/tests/test_finger_piano.scd` (note assertions)
**Interfaces:**
- Consumes: `~noteFor`, `~scales` (Task 1).
- Produces: `~fpScaleName` (Symbol, default `\minorPent`) replaces
`~fpScaleArr`/`~fpScales`. `~fpResolve.(hand, finger, strikeSpeed, z, tipx,
tipy)` keeps its Event output (`\note \freq \amp \inst`) but resolves notes
via `~noteFor`. `~fpScale_.(name)` sets `~fpScaleName`.
- **Behavior note (intended):** piano-mode finger notes now follow the
canonical wrapped scale (e.g. minor pentatonic 5-note + octave carry) instead
of the previous bespoke 8-note absolute array. Clips mode (the concert path)
is unaffected (it uses `~lpArm`, not `~fpResolve`).
- [ ] **Step 1: Update the finger_piano test note assertions**
In `sound_algo/tests/test_finger_piano.scd`, the block that loads scales +
finger_piano and asserts notes. Replace the scale-array assertions (which
referenced `~fpScaleArr.wrapAt`) with `~noteFor`-based expectations. Find:
```supercollider
// default scale is minor pentatonic; left thumb (hand 0, finger 0) -> degree 0
ev = ~fpResolve.(0, 0, 1.0, 0.0, 0.5, 0.5);
("note L thumb = " ++ ev[\note]).postln;
if(ev[\note] != ~fpScaleArr.wrapAt(0)) {
ok = false; "FAIL: L thumb note wrong".postln;
};
// right hand defaults one octave up (~fpHandOct[1] = 12)
ev = ~fpResolve.(1, 0, 1.0, 0.0, 0.5, 0.5);
if(ev[\note] != (~fpScaleArr.wrapAt(0) + 12)) {
ok = false; "FAIL: R thumb octave wrong".postln;
};
```
replace with:
```supercollider
// note.scd must be loaded for ~noteFor
(base ++ "palette/note.scd").load;
// default scale minorPent; L thumb (hand 0, finger 0) -> degree 0 -> root 60
ev = ~fpResolve.(0, 0, 1.0, 0.0, 0.5, 0.5);
("note L thumb = " ++ ev[\note]).postln;
if(ev[\note] != ~noteFor.(0, \minorPent, 60, 0)) {
ok = false; "FAIL: L thumb note wrong".postln;
};
// right hand defaults one octave up (~fpHandOct[1] = 12)
ev = ~fpResolve.(1, 0, 1.0, 0.0, 0.5, 0.5);
if(ev[\note] != (~noteFor.(0, \minorPent, 60, 0) + 12)) {
ok = false; "FAIL: R thumb octave wrong".postln;
};
```
- [ ] **Step 2: Run test to verify it fails**
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/tests/test_finger_piano.scd`
Expected: FAIL (`~fpResolve` still uses `~fpScaleArr`, and/or `~noteFor` value
differs from the old wrapAt array).
- [ ] **Step 3: Refactor finger_piano scale handling + `~fpResolve`**
In `sound_algo/control/finger_piano.scd`:
(3a) Replace the scale config block. Find:
```supercollider
~fpInst = ~fpInst ? \live; // \live = suit ~melodyInst
// Fallback embarque : en mode data-only, palette/scales.scd n'est pas
// charge, donc ~scaleMinorPent est nil. On garantit une gamme valide.
~fpScaleArr = ~fpScaleArr ? (~scaleMinorPent ? [60, 63, 65, 67, 70, 72, 75, 79]);
~fpMap = ~fpMap ? [[0,1,2,3,4], [0,1,2,3,4]]; // [L, R] degres
```
replace with:
```supercollider
~fpInst = ~fpInst ? \live; // \live = suit ~melodyInst
~fpScaleName = ~fpScaleName ? \minorPent; // nom de gamme dans ~scales
~fpRoot = ~fpRoot ? 60; // fondamentale MIDI
~fpMap = ~fpMap ? [[0,1,2,3,4], [0,1,2,3,4]]; // [L, R] degres
```
(3b) Remove the `~fpScales` named-scale dict block. Find and DELETE:
```supercollider
// Gammes nommees accessibles via ~fpScale_
// Gammes nommees (fallback embarque si palette/scales.scd absent).
~fpScales = (
minorPent: ~scaleMinorPent ? [60, 63, 65, 67, 70, 72, 75, 79],
minor: ~scaleMinor ? [60, 62, 63, 65, 67, 68, 70, 72],
dorian: ~scaleDorian ? [60, 62, 63, 65, 67, 69, 70, 72],
phrygian: ~scalePhrygian ? [60, 61, 63, 65, 67, 68, 70, 72],
blues: ~scaleBlues ? [60, 63, 65, 66, 67, 70, 72, 75],
japanese: ~scaleJapanese ? [60, 62, 65, 67, 69, 72, 74, 77],
hijaz: ~scaleHijaz ? [60, 61, 64, 65, 67, 68, 70, 72],
hirajoshi: ~scaleHirajoshi ? [60, 62, 63, 67, 68, 72, 74, 75]
);
```
(3c) Replace the note line inside `~fpResolve`. Find:
```supercollider
note = ((~fpScaleArr ? [60, 63, 65, 67, 70, 72, 75, 79]).wrapAt(deg)
+ transpose).asInteger;
```
replace with (transpose is a semitone offset in multiples of 12 → octaves):
```supercollider
note = ~noteFor.(deg, ~fpScaleName, ~fpRoot, transpose div: 12);
```
(3d) Replace `~fpScale_`. Find:
```supercollider
~fpScale_ = { |name|
~fpScaleArr = ~fpScales[name] ? ~fpScaleArr;
("[fp] scale = " ++ name).postln;
};
```
replace with:
```supercollider
~fpScale_ = { |name|
(~scales[name].notNil).if({ ~fpScaleName = name });
("[fp] scale = " ++ ~fpScaleName).postln;
};
```
- [ ] **Step 4: Run test + balance + boot smoke**
Run: `/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/tests/test_finger_piano.scd`
Expected: `PASS test_finger_piano`
Balance check on `sound_algo/control/finger_piano.scd` — expected `P:0 B:0`.
Data-only boot smoke (from `sound_algo/data_only`):
`/Applications/SuperCollider.app/Contents/MacOS/sclang boot.scd`
Expected: `PATCH READY`, `[OK] finger_piano` present, no error.
- [ ] **Step 5: Commit**
```bash
git add sound_algo/control/finger_piano.scd sound_algo/tests/test_finger_piano.scd
git commit -m "refactor: fpResolve delegates to noteFor"
```
---
### Task 6: Full verification gate (no new code)
**Files:** none (verification only).
- [ ] **Step 1: E2E suite green**
Run: `cd sound_algo/tests && bash run_all.sh`
Expected: all checks pass (syntax / load / reload-guard / SynthDef-compile).
If `run_all.sh` needs a TTY or a running server and cannot complete headless,
record exactly which checks ran and their results in the task report.
- [ ] **Step 2: All headless unit tests green**
Run:
```bash
/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/tests/test_note.scd
/Applications/SuperCollider.app/Contents/MacOS/sclang sound_algo/tests/test_finger_piano.scd
```
Expected: `PASS test_note` and `PASS test_finger_piano`.
- [ ] **Step 3: Balance check every Lot A `.scd`**
Run the balance awk on: `palette/note.scd`, `data_only/scene_concert.scd`,
`data_only/launchpad.scd`, `control/finger_piano.scd`. All `P:0 B:0`.
- [ ] **Step 4: Live smoke (manual, with the running rig)**
With the data-only stack running, confirm via OSC that concert harmony and the
air-piano still produce notes (no `nil`/`wrapAt`/`noteFor` errors in
`/tmp/sc_boot.log`). If a regression appears, it is a failed gate — fix before
closing the lot.
## Self-Review
**Spec coverage:** `palette/note.scd` (`~scales`/`~noteFor`/`~freqFor`) → Task 1.
Loaded in both boots before consumers → Task 2. `~ccNote` wrapper → Task 3.
`~lpNote` wrapper → Task 4. `~fpResolve` via `~noteFor`, embedded fallback
removed (root cause of the `154db32` fix) → Task 5. Parity tests + E2E + boot
smoke → Tasks 1,3,5,6. `palette/scales.scd` LIVE arrays intentionally untouched
(documented in Architecture). OK.
**Placeholder scan:** every code step has full code; no TBD. OK.
**Type consistency:** `~noteFor(degree, scaleName, root, octave) -> Int` used
identically in Tasks 1/3/4/5. `~fpScaleName`/`~fpRoot` introduced in Task 5 and
used only there. `~ccScales` aliased to `~scales` (Task 3). `transpose div: 12`
converts the existing semitone `transpose` (already a multiple of 12) to the
octave arg. OK.