From 2aac8f57f647f24b307adccdaf8e1b97bc657ec4 Mon Sep 17 00:00:00 2001 From: clement Date: Sun, 28 Jun 2026 15:26:34 +0200 Subject: [PATCH] docs: lot A plan, unified note helpers --- ...26-06-28-sc-refactor-lot-a-note-helpers.md | 569 ++++++++++++++++++ 1 file changed, 569 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-sc-refactor-lot-a-note-helpers.md diff --git a/docs/superpowers/plans/2026-06-28-sc-refactor-lot-a-note-helpers.md b/docs/superpowers/plans/2026-06-28-sc-refactor-lot-a-note-helpers.md new file mode 100644 index 0000000..6d3ca04 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-sc-refactor-lot-a-note-helpers.md @@ -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}' ` + +--- + +### 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.