Files
STSheets/docs/superpowers/plans/2026-10-30-synthetic-benchmark.md
T

418 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Synthetic Benchmark Harness 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:** Generate original tier-1 songs as Guitar Pro files, render them to audio, run a baseline browser-portable transcription pipeline on the audio, and score the output against the source file.
**Architecture:** One shared symbolic `Song` model is written to alphaTex and turned into Guitar Pro / MIDI / audio by alphaTab. The pipeline (Demucs-6s ONNX → Basic Pitch ONNX / heuristic drums → tempo grid → quantise → tab assignment) produces timed notes plus a `Song` that goes back out through the same alphaTex writer. A scorer compares timed notes to the ground truth extracted from the GP file.
**Tech Stack:** Bun 1.3 + TypeScript (strict), `@coderline/alphatab@1.8.4` (MPL-2.0), `onnxruntime-node@1.31.1` (MIT), `@spotify/basic-pitch@1.0.1` post-processing only (Apache-2.0), ffmpeg CLI (mp3 encode only), models: `Kani95/htdemucs-6s-ort` (Apache-2.0, sha256 `35cb61d4d35173ff1d16df7393f41f05651ffdfe13d3c10e06abc26708bd6933`), Spotify `nmp.onnx` (Apache-2.0).
**Spec:** `docs/superpowers/specs/2026-10-30-synthetic-benchmark-design.md`
## Global Constraints
- Bun + TypeScript for everything; follow `CLAUDE.md` (bun test, `Bun.file`/`Bun.write`, `Bun.$`, `bunx`). No Python on any path in this plan.
- Models run via `onnxruntime-node`; code touching ORT must only use APIs that also exist in `onnxruntime-web` (`InferenceSession.create`, `Tensor`, `session.run`).
- CPU only. Use `executionProviders: ["cpu"]`.
- Pipeline code (`src/pipeline/**`, `src/dsp/**`, `src/score/**`) must not import `node:fs`/`Bun.*`; file I/O lives in `src/bench/**`, `src/audio/wav.ts`, `src/audio/mp3.ts`. (Keeps the pipeline browser-portable.)
- Generated material is original; no reference songs are recreated.
- Tier-1 benchmark songs: at most two guitars, standard tuning `[64,59,55,50,45,40]` (MIDI, highest string first), 4/4.
- Pitch score: onset tolerance ±50 ms, offsets ignored, string ignored; computed on timed notes **before** quantisation.
- Song pass: merged-guitar pitch F1 ≥ 0.85. Tier pass: median song passes.
- Rhythm alignment allows tempo factor ½×, 1×, 2× and whole-beat bar offsets; factor/offset reported separately.
- Every result is labelled with its oracle flags (`oracleStems`, `oracleTempo`, `oracleNotes`).
- Reports order instruments: guitar, vocals, then bass, drums, keys.
- Generated audio, `data/`, `runs/`, `models/*.onnx` are git-ignored; `results/*.md` + `results/*.json` summaries are committed.
- SoundFont: alphaTab's bundled `sonivox.sf2` (Apache-2.0).
- Commits end with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Out of Scope for This Plan
- The optional Python comparison track (spec: "optional") — deferred to a follow-up plan once baseline numbers exist.
- Website UI, YouTube ingestion, later tiers.
## Review Focus
1. **Durations that aren't a single note value or that cross a barline** (e.g. 5 sixteenths, a whole note starting on beat 3) — writer must emit tied notes so the GP round trip preserves every onset and pitch. Test in Task 2.
2. **Silent or absent stems** (no vocals in t1a; a stem Demucs leaves near-silent) — transcribers return `[]`, scorer reports `null` metrics (not NaN) and the report shows "n/a". Tests in Tasks 6, 8.
3. **Audio length not a multiple of the Demucs segment, or shorter than one segment** — separation output length must equal input length exactly. Test in Task 7.
4. **Pitches outside the instrument's fret range** (octave errors from transcription) — tab assigner drops them, counts them, never throws. Test in Task 11.
5. **Synth onset latency** — rendered audio must put note energy within 20 ms of ground-truth onset, otherwise every score is silently shifted. Test in Task 4.
---
## File Structure
```
scripts/fetch-models.ts download + sha256-verify models into models/
src/core/types.ts Song, Track, ScoreNote, TimedNote, TruthTrack, Role, Grid, Audio
src/core/time.ts tick/second conversion (TPQ = 960)
src/core/rng.ts seeded PRNG
src/score/alphatex.ts songToAlphaTex(song)
src/score/gp.ts songToScore / songToGp / truthFromGp (alphaTab)
src/gen/harmony.ts keys, progressions, guitar chord shapes
src/gen/parts.ts per-role part generators
src/gen/tiers.ts tier specs t1a..t1d
src/gen/generate.ts generateSong(tier, seed)
src/audio/wav.ts read/write WAV, toMono, resample
src/audio/mp3.ts ffmpeg encode
src/audio/render.ts per-track stem rendering via alphaSynth
src/audio/fx.ts drive, chorus, reverb, pan, gain
src/audio/mix.ts mix stems
src/dsp/fft.ts radix-2 FFT, torch-compatible stft/istft
src/pipeline/separate.ts Demucs 6s ONNX
src/pipeline/transcribe.ts Basic Pitch ONNX → TimedNote[]
src/pipeline/drums.ts heuristic kick/snare/hat transcriber
src/pipeline/tempo.ts tempo + downbeat grid
src/pipeline/quantize.ts TimedNote[] → ScoreNote[]
src/pipeline/tab.ts string/fret assignment (Viterbi)
src/pipeline/run.ts orchestrates pipeline incl. oracle modes
src/eval/notes.ts note matching / F1 / octave errors
src/eval/rhythm.ts grid alignment + rhythm score
src/eval/score.ts scoreSong
src/bench/dataset.ts build song dirs on disk
src/bench/report.ts markdown + JSON tier report
src/bench/cli.ts `bun run bench …` entry
tests/** bun tests mirroring src
```
On-disk layout: `data/<tier>/<seed>/{song.json, truth.gp, stems/<trackName>.wav, mix.wav, mix.mp3}`; `runs/<runId>/<tier>/<seed>/{notes.json, out.gp, score.json}`; `results/<runId>.{md,json}`.
---
### Task 1: Scaffolding, core types, time, RNG, model fetch
**Files:**
- Create: `src/core/types.ts`, `src/core/time.ts`, `src/core/rng.ts`, `scripts/fetch-models.ts`
- Modify: `package.json` (scripts), `.gitignore`, delete `index.ts`
- Test: `tests/core/time.test.ts`, `tests/core/rng.test.ts`
**Interfaces:**
- Produces (`src/core/types.ts`):
```ts
export type Role = "guitar" | "bass" | "keys" | "vocals" | "drums";
export type FxPreset = "none" | "clean" | "clean-chorus" | "light-drive";
export interface ScoreNote { tick: number; dur: number; pitch: number; string?: number; fret?: number }
// string numbering: 1 = highest-pitched string (alphaTex convention). Drums: pitch = GM drum number.
export interface Track { name: string; role: Role; program: number; tuning?: number[]; pan: number; fx: FxPreset; gainDb: number; notes: ScoreNote[] }
export interface Song { title: string; tier: string; seed: number; bpm: number; timeSig: [number, number]; bars: number; key: string; tracks: Track[] }
export interface TimedNote { onset: number; offset: number; pitch: number; velocity?: number; string?: number; fret?: number }
export interface TruthTrack { name: string; role: Role; tuning?: number[]; notes: TimedNote[] }
export interface Grid { bpm: number; firstDownbeat: number; beatsPerBar: number }
export interface Audio { sampleRate: number; channels: Float32Array[] }
export const TPQ = 960;
export const GUITAR_STANDARD = [64, 59, 55, 50, 45, 40];
export const BASS_STANDARD = [43, 38, 33, 28];
```
- Produces: `ticksToSeconds(tick: number, bpm: number): number`, `secondsToTicks(s: number, bpm: number): number` in `src/core/time.ts`; `createRng(seed: number): Rng` with `next(): number` (0..1), `int(lo: number, hi: number): number` (inclusive), `pick<T>(a: readonly T[]): T`, `chance(p: number): boolean` in `src/core/rng.ts` (mulberry32).
- [ ] **Step 1: Write failing tests**
```ts
test("ticks/seconds", () => {
expect(ticksToSeconds(960, 60)).toBeCloseTo(1);
expect(ticksToSeconds(3840, 120)).toBeCloseTo(2);
expect(secondsToTicks(0.5, 120)).toBeCloseTo(960);
});
test("rng deterministic", () => {
const a = createRng(42), b = createRng(42), c = createRng(43);
const sa = [a.next(), a.next(), a.next()];
expect([b.next(), b.next(), b.next()]).toEqual(sa);
expect(c.next()).not.toBe(sa[0]);
const r = createRng(1); for (let i = 0; i < 1000; i++) { const v = r.int(3, 5); expect(v >= 3 && v <= 5).toBe(true); }
});
```
- [ ] **Step 2:** `bun test tests/core` → FAIL (modules missing).
- [ ] **Step 3:** Implement types, time, rng. `package.json` scripts: `"test": "bun test"`, `"typecheck": "bunx tsc --noEmit"`, `"fetch-models": "bun scripts/fetch-models.ts"`, `"bench": "bun src/bench/cli.ts"`. `.gitignore` add `data/`, `runs/`, `models/**/*.onnx`. `scripts/fetch-models.ts` downloads `https://huggingface.co/Kani95/htdemucs-6s-ort/resolve/main/htdemucs_6s.onnx` → `models/demucs/htdemucs_6s.onnx` and `https://github.com/spotify/basic-pitch/raw/main/basic_pitch/saved_models/icassp_2022/nmp.onnx` → `models/basic-pitch/nmp.onnx`, skips existing files whose sha256 matches, fails loudly on mismatch (Demucs hash above; record the nmp hash from the current file on first run as a constant).
- [ ] **Step 4:** `bun test tests/core` → PASS; `bun run typecheck` → no errors.
- [ ] **Step 5:** Commit `feat: core types, time, rng, model fetch`.
### Task 2: alphaTex writer and Guitar Pro round trip
**Files:**
- Create: `src/score/alphatex.ts`, `src/score/gp.ts`
- Test: `tests/score/roundtrip.test.ts`
**Interfaces:**
- Consumes: Task 1 types.
- Produces: `songToAlphaTex(song: Song): string`; `songToScore(song: Song): alphaTab.model.Score`; `songToGp(song: Song): Uint8Array` (Gp7Exporter); `truthFromGp(bytes: Uint8Array): { bpm: number; timeSig: [number, number]; tracks: TruthTrack[] }`; `songToMidi(song: Song): alphaTab.midi.MidiFile`.
Writer rules (the implementer cannot infer these):
- Header `\title`, `\tempo <bpm>`, `\ts <n> <d>`; per track `\track "<name>"`, `\instrument <program>` (drums: `\instrument percussion \articulation defaults`, notes written as GM numbers), guitar/bass: `\staff {tabs}` + `\tuning (...)` written as note names highest string first, notes as `fret.string`; keys/vocals: `\staff {score}`, notes as note names (e.g. `C#4`).
- Each track is one voice. Notes with equal `tick` form a chord beat; their beat duration is the shortest `dur` in the group, clipped to the next onset in the track. Gaps become rests.
- Durations are decomposed greedily into {1, 2, 4, 8, 16, 32} with optional single dots, split at barlines, joined with ties (`-` tie syntax on the continuation note; verify alphaTex tie syntax against alphaTab 1.8 docs/diagnostics).
- `truthFromGp` uses absolute tick = `masterBar.start + beat.playbackStart`, seconds via `ticksToSeconds` with the score tempo (tier 1 is constant tempo). Tied continuation notes are merged into the original note (one TimedNote). String converted to our numbering: `ours = stringCount - model.string + 1`. Drum pitch = `track.percussionArticulations[n.percussionArticulation].outputMidiNumber`. Role is stored as the alphaTex track short name (`\track "<name>" "<role>"`) and recovered from `track.shortName`.
- [ ] **Step 1: Write failing tests** — build a hand-made `Song` (bpm 90) with: a guitar track containing a C-major chord at tick 0 (5 notes with string/fret), a single note of `dur = 5 * 240` (5 sixteenths), and a whole note starting at beat 3 of bar 1 (crosses barline); a vocals track with a rest gap; a drums track with kick 36 + hat 42 together and a snare 38. Assertions:
```ts
const truth = truthFromGp(songToGp(song));
// every source note appears exactly once with same pitch and onset (±1 ms), ties merged:
expect(sortNotes(truth.tracks[0].notes).map(n => [n.pitch, round3(n.onset)])).toEqual(expectedGuitar);
expect(truth.tracks[0].notes.find(n => n.pitch === tiedPitch)!.offset - onset).toBeCloseTo(4 * 60/90, 3);
expect(truth.tracks[0].notes.every(n => n.string! >= 1 && n.string! <= 6)).toBe(true);
expect(truth.tracks.find(t => t.role === "drums")!.notes.map(n => n.pitch).sort()).toEqual([36, 38, 42]);
expect(truth.tracks.map(t => t.role)).toEqual(["guitar", "vocals", "drums"]);
```
plus: alphaTex parsing emits no error diagnostics (capture `Logger` or use the importer's diagnostics API) for this song.
- [ ] **Step 2:** `bun test tests/score` → FAIL.
- [ ] **Step 3:** Implement writer + gp.ts.
- [ ] **Step 4:** `bun test tests/score` → PASS.
- [ ] **Step 5:** Commit `feat: alphaTex writer and GP round trip`.
### Task 3: Tier-1 song generator
**Files:**
- Create: `src/gen/harmony.ts`, `src/gen/parts.ts`, `src/gen/tiers.ts`, `src/gen/generate.ts`
- Test: `tests/gen/generate.test.ts`
**Interfaces:**
- Consumes: types, rng, `songToGp`/`truthFromGp` (test only).
- Produces: `type TierId = "t1a" | "t1b" | "t1c" | "t1d"`; `TIERS: Record<TierId, TierSpec>`; `generateSong(tier: TierId, seed: number): Song`.
Tier content (decided):
| Tier | Tracks | Notes |
|---|---|---|
| t1a | 1 clean guitar | solo; 66–96 bpm; mix of strummed open chords and arpeggios; one short single-note melodic phrase allowed |
| t1b | guitar + bass + drums | bass on chord roots (quarters/halves); drums: kick/snare/closed-hat groove, crash on section starts |
| t1c | t1b + keys (pad/piano chords) + vocals (program 53 "Voice Oohs", monophonic melody in C4–G5) | keys voiced in C3–C5 |
| t1d | t1c + second guitar | guitars get distinct roles (strum vs arpeggio/lead line), pans −0.6 / +0.6, one `clean`, one `clean-chorus` or `light-drive` |
Common: 4/4, 16 bars split into sections (e.g. A A B A, 4 bars each, repeats identical), keys from {C, G, D, A, E, F} major + relative minors, progressions from a fixed list of pop progressions (I–V–vi–IV, vi–IV–I–V, I–vi–IV–V, I–IV–vi–V). Guitar chords come from an open-chord shape dictionary in `harmony.ts` (each shape = per-string fret or mute, highest string first) so every guitar note carries a valid `string`/`fret`; arpeggios draw from the same shape. Guitar programs: 25 (steel) or 27 (clean electric). Bass program 33, keys 0 or 89, drums percussion.
- [ ] **Step 1: Write failing tests**
```ts
for (const tier of ["t1a","t1b","t1c","t1d"] as const) for (const seed of [1,2,3]) {
const s = generateSong(tier, seed);
expect(generateSong(tier, seed)).toEqual(s); // deterministic
const guitars = s.tracks.filter(t => t.role === "guitar");
expect(guitars.length).toBe(tier === "t1d" ? 2 : 1);
for (const g of guitars) for (const n of g.notes) expect(GUITAR_STANDARD[n.string! - 1]! + n.fret!).toBe(n.pitch);
expect(s.tracks.map(t => t.role).sort()).toEqual(EXPECTED_ROLES[tier]);
const end = s.bars * 4 * TPQ;
for (const t of s.tracks) for (const n of t.notes) expect(n.tick + n.dur).toBeLessThanOrEqual(end);
expect(truthFromGp(songToGp(s)).tracks.length).toBe(s.tracks.length); // valid GP
}
const d = generateSong("t1d", 7); const [g1, g2] = d.tracks.filter(t => t.role === "guitar");
expect(Math.sign(g1!.pan)).not.toBe(Math.sign(g2!.pan));
expect(generateSong("t1a", 1)).not.toEqual(generateSong("t1a", 2));
```
- [ ] **Step 2:** `bun test tests/gen` → FAIL.
- [ ] **Step 3:** Implement.
- [ ] **Step 4:** `bun test tests/gen` → PASS.
- [ ] **Step 5:** Commit `feat: tier-1 song generator`.
### Task 4: Audio I/O, stem rendering, effects, mixing
**Files:**
- Create: `src/audio/wav.ts`, `src/audio/mp3.ts`, `src/audio/render.ts`, `src/audio/fx.ts`, `src/audio/mix.ts`
- Test: `tests/audio/audio.test.ts`
**Interfaces:**
- Consumes: `songToMidi`, `songToScore`, types.
- Produces: `readWav(path: string): Promise<Audio>`; `writeWav(path: string, a: Audio): Promise<void>` (PCM16); `toMono(a: Audio): Float32Array`; `resample(x: Float32Array, from: number, to: number): Float32Array` (windowed-sinc, used for 44100→22050 for Basic Pitch); `encodeMp3(wavPath: string, mp3Path: string): Promise<void>` (`ffmpeg -y -loglevel error -i … -b:a 192k`); `renderStems(song: Song, sf2: Uint8Array, sampleRate = 44100): Map<string, Audio>` (stereo, centred, one per track name, all the same length = song length + 2 s tail); `applyFx(a: Audio, preset: FxPreset, sampleRate: number): Audio`; `pan(a: Audio, p: number): Audio` (constant-power); `mixStems(stems: { audio: Audio; gainDb: number }[]): Audio` (sum, then peak-normalise to −1 dBFS).
Rendering: one `AlphaSynth.exportAudio` per track with `AudioExportOptions.trackVolume` muting all other tracks (verify key semantics; fallback: build a single-track Score per track), `useSyncPoints = false`, `render(500)` until `undefined`, de-interleave. Fx are simple and deterministic: `light-drive` = tanh waveshaper with pre-gain 4 + one-pole low-pass 5 kHz; `clean-chorus` = 2 modulated delay lines (~15 ms, 0.8 Hz); every pitched track gets a light shared Freeverb-style reverb (wet 0.15). Oracle stems written to disk are post-fx, post-pan — exactly what went into the mix.
- [ ] **Step 1: Write failing tests**
- WAV round trip: random stereo 1 s → write → read → max abs diff < 1e-4.
- `pan(a, -1)`: right-channel RMS < 1e-3 × left RMS.
- Rendering `generateSong("t1b", 1)`: stems map has 3 entries, all equal length ≥ song seconds; each stem RMS > 1e-3.
- **Onset latency:** render a song with one guitar note at tick 960 (bpm 60 → 1.0 s): first sample where the 5 ms-RMS envelope exceeds 10% of its max lies in [0.98, 1.02] s.
- `mixStems` peak ≈ 10^(−1/20) ± 1e-3.
- [ ] **Step 2:** `bun test tests/audio` → FAIL.
- [ ] **Step 3:** Implement.
- [ ] **Step 4:** `bun test tests/audio` → PASS.
- [ ] **Step 5:** Commit `feat: stem rendering, fx and mixing`.
### Task 5: Dataset builder and CLI skeleton
**Files:**
- Create: `src/bench/dataset.ts`, `src/bench/cli.ts`
- Test: `tests/bench/dataset.test.ts`
**Interfaces:**
- Produces: `buildSong(tier: TierId, seed: number, root = "data"): Promise<string>` (returns song dir; writes `song.json`, `truth.gp`, `stems/<trackName>.wav`, `mix.wav`, `mix.mp3`; idempotent — skips if `mix.mp3` exists and `song.json` matches); `loadSongDir(dir: string): Promise<{ song: Song; truth: ReturnType<typeof truthFromGp>; mix: Audio; stems: Map<string, Audio> }>`.
- CLI: `bun run bench gen --tiers t1a,t1b,t1c,t1d --seeds 1-5`.
- [ ] **Step 1: Write failing test** — `buildSong("t1a", 1, tmpDir)` creates all five artefacts; `loadSongDir` returns `truth.tracks[0].notes.length === song.tracks[0].notes.length` (ties are not generated by the tier-1 generator, so counts match) and `mix.channels.length === 2`.
- [ ] **Step 2:** FAIL. **Step 3:** implement. **Step 4:** PASS; run `bun run bench gen --tiers t1a --seeds 1-2` and confirm files exist.
- [ ] **Step 5:** Commit `feat: dataset builder`.
### Task 6: Evaluation (notes, rhythm, song score)
**Files:**
- Create: `src/eval/notes.ts`, `src/eval/rhythm.ts`, `src/eval/score.ts`
- Test: `tests/eval/notes.test.ts`, `tests/eval/rhythm.test.ts`, `tests/eval/score.test.ts`
**Interfaces:**
- Produces:
```ts
interface MatchResult { tp: number; fp: number; fn: number; precision: number | null; recall: number | null; f1: number | null; octaveErrors: number; matches: [number, number][] }
matchNotes(ref: TimedNote[], est: TimedNote[], opts?: { onsetTol?: number /*0.05*/; key?: "pitch" | "pitchString" }): MatchResult
interface GridAlignment { factor: 0.5 | 1 | 2; beatOffset: number }
alignGrid(refNotes: ScoreNote[], estNotes: ScoreNote[]): GridAlignment
rhythmScore(ref: ScoreNote[], est: ScoreNote[], align: GridAlignment): { onBeat: number | null; durationOk: number | null }
scoreSong(truth: { bpm: number; timeSig: [number, number]; tracks: TruthTrack[] }, out: PipelineOutput, flags: OracleFlags): SongScore
```
`PipelineOutput` and `OracleFlags` are defined here in `src/eval/score.ts` (re-exported to Task 12):
```ts
interface OracleFlags { oracleStems: boolean; oracleTempo: boolean; oracleNotes: boolean }
interface OutTrack { name: string; role: Role; notes: TimedNote[]; scoreNotes: ScoreNote[] }
interface PipelineOutput { grid: Grid; tracks: OutTrack[] }
interface SongScore { flags: OracleFlags; guitarMerged: MatchResult & { pass: boolean }; partSplit: { f1: number | null; outTracks: number; refTracks: number } | null;
perRole: Partial<Record<Role, MatchResult>>; drumsPerClass: Record<"kick"|"snare"|"hihat"|"cymbal"|"tom", MatchResult> | null;
rhythm: { factor: number; beatOffset: number; onBeat: number | null; durationOk: number | null } | null;
stringAgreement: number | null; spuriousRate: number | null }
```
Rules: matching is maximum bipartite matching (onset within tol AND equal pitch); octave errors = unmatched est notes whose onset is within tol of an unmatched ref note with pitch ±12. F1 etc. are `null` when both ref and est are empty; precision `null` when est empty; recall `null` when ref empty. `guitarMerged` pools all guitar ref notes and all guitar out notes; `pass = f1 >= 0.85`. `partSplit`: when ≥2 guitar out tracks, Hungarian assignment maximising tp between out and ref guitar tracks, F1 over the assignment. `stringAgreement` = matched guitar notes with equal `string` / matched notes having a string. `spuriousRate` = fp / est count (guitar merged). Drum classes: kick {35,36}, snare {37,38,40}, hihat {42,44,46}, cymbal {49,51,52,53,55,57,59}, tom {41,43,45,47,48,50}; pitch compared by class. `alignGrid` tries factor ∈ {0.5,1,2} and beatOffset ∈ {−3..3} beats, picking the pair maximising exact tick-onset agreement of pitch-matched notes. Convention: `estTick = refTick * factor + beatOffset * TPQ`. `rhythmScore.onBeat` = share of pitch-matched notes landing on the identical aligned tick; `durationOk` = share with identical aligned duration.
- [ ] **Step 1: Write failing tests**
```ts
const ref = [n(1.0,60), n(1.5,62), n(2.0,64)];
expect(matchNotes(ref, [n(1.03,60), n(1.5,74), n(2.2,64)]).tp).toBe(1); // 74 = 62+12 → octave error; 2.2 s is outside tol
expect(matchNotes(ref, [n(1.03,60), n(1.5,74), n(2.2,64)]).octaveErrors).toBe(1);
expect(matchNotes(ref, ref).f1).toBe(1);
expect(matchNotes([], []).f1).toBeNull();
expect(matchNotes(ref, []).recall).toBe(0);
expect(matchNotes(ref, []).precision).toBeNull();
// grid: est written at double tempo and shifted one beat
const r = sn([[0,240,60],[960,960,62]]); const e = sn([[960,480,60],[2880,1920,62]]);
expect(alignGrid(r, e)).toEqual({ factor: 2, beatOffset: 1 });
expect(rhythmScore(r, e, { factor: 2, beatOffset: 1 })).toEqual({ onBeat: 1, durationOk: 1 });
```
plus a `scoreSong` test with two ref guitar tracks and one merged out track (partSplit `null`-free path: `outTracks: 1`), and one with an absent vocals track on both sides → `perRole.vocals.f1 === null`.
- [ ] **Step 2:** FAIL. **Step 3:** implement. **Step 4:** PASS.
- [ ] **Step 5:** Commit `feat: evaluation metrics`.
### Task 7: FFT/STFT and Demucs separation
**Files:**
- Create: `src/dsp/fft.ts`, `src/pipeline/separate.ts`
- Test: `tests/dsp/fft.test.ts`, `tests/pipeline/separate.test.ts`
**Interfaces:**
- Produces: `fft(re: Float32Array, im: Float32Array): void` (in-place radix-2); `stft(x: Float32Array, nFft: number, hop: number): { re: Float32Array[]; im: Float32Array[] }` (frames × bins, torch semantics: hann window, `center=true`, reflect pad, `normalized=true`); `istft(spec, nFft, hop, length): Float32Array`; `type Stem = "drums" | "bass" | "other" | "vocals" | "guitar" | "piano"`; `createSeparator(modelBytes: Uint8Array): Promise<(mix: Audio) => Promise<Record<Stem, Audio>>>`.
Demucs details (from `htdemucs.py`, MIT): segment = 343980 samples, overlap 0.25, linear-fade (triangular) weights for overlap-add, whole-mix normalisation `(mix − mean(mono)) / std(mono)` before and inverse after. Per segment: spectral input `x` = `_spec`: nfft 4096, hop 1024, pad `hop/2*3 = 1536` each side reflect (right pad extended to `le*hop - T`, `le = ceil(T/hop)`), stft, drop last bin (→ 2048), keep frames `[2, 2+le)` (→ 336), complex-as-channels `[re_L, im_L, re_R, im_R]` → `[1,4,2048,336]`. Outputs: `output` `[1,6,4,2048,336]` → `_ispec` (re-add zero bin, pad 2 frames both sides, istft, crop `pad … pad+T`) and add `add_67` `[1,6,2,T]`. Stem order from manifest: drums, bass, other, vocals, guitar, piano. Verify graph-internal normalisation by checking the result quality test below; if separation is garbage, the per-segment normalisation must be applied outside the graph.
- [ ] **Step 1: Write failing tests**
- `fft` of an impulse → all-ones magnitude; `istft(stft(x)) ≈ x` (max err < 1e-4) for random 50 000-sample x with nFft 4096, hop 1024.
- Separation on `data/t1b/1` (built via `buildSong`): output length === input length for every stem; SDR(guitar stem vs oracle guitar stem) > 3 dB and > SDR(mix vs oracle guitar stem); bass likewise.
- A 3-second slice of the mix (shorter than one segment) returns 3 s stems without throwing.
(Separation tests are slow; mark with a 300 s timeout.)
- [ ] **Step 2:** FAIL. **Step 3:** implement. **Step 4:** PASS (`bun test tests/dsp tests/pipeline/separate.test.ts --timeout 300000`).
- [ ] **Step 5:** Commit `feat: demucs 6-stem separation`.
### Task 8: Basic Pitch transcription
**Files:**
- Create: `src/pipeline/transcribe.ts`
- Test: `tests/pipeline/transcribe.test.ts`
**Interfaces:**
- Produces: `createTranscriber(modelBytes: Uint8Array): Promise<(audio: Audio, opts?: TranscribeOpts) => Promise<TimedNote[]>>` with `TranscribeOpts = { onsetThresh?: number /*0.5*/; frameThresh?: number /*0.3*/; minNoteMs?: number /*58*/; minPitch?: number; maxPitch?: number; monophonic?: boolean }`.
Approach: downmix + `resample` to 22050; window exactly like `@spotify/basic-pitch` `inference.js` (AUDIO_N_SAMPLES = 43844, overlap 30 frames, half-overlap zero pad, trim 15 frames each side, truncate to original frame count); ORT input `serving_default_input_2:0` `[n,43844,1]`; outputs map `StatefulPartitionedCall:{0,1,2}` → identify contour/note/onset by last-dim size (264 = contour, 88 = note & onset; disambiguate note vs onset by name order matching inference.js `OUTPUT_TO_TENSOR_NAME`). Post-process with `outputToNotesPoly`, `noteFramesToTime` imported from `@spotify/basic-pitch/esm/toMidi.js` (must not pull in tfjs — verify; if it does, vendor those functions with the Apache-2.0 header). `monophonic`: at each time keep only the note with highest amplitude, trimming overlaps. Silent input (RMS < 1e-4) returns `[]` without running the model.
- [ ] **Step 1: Write failing tests**
- On the oracle guitar stem of `data/t1a/1`: `matchNotes(truthGuitar, est).f1 > 0.5` (sanity floor; the real number goes in the benchmark).
- On a vocals-like monophonic synthetic line (from `data/t1c/1` oracle vocals stem) with `monophonic: true`: no two output notes overlap in time.
- 2 s of zeros → `[]`.
- [ ] **Step 2:** FAIL. **Step 3:** implement. **Step 4:** PASS.
- [ ] **Step 5:** Commit `feat: basic pitch transcription`.
### Task 9: Tempo and downbeat grid
**Files:**
- Create: `src/pipeline/tempo.ts`
- Test: `tests/pipeline/tempo.test.ts`
**Interfaces:**
- Produces: `estimateGrid(audio: Audio, opts?: { minBpm?: number /*60*/; maxBpm?: number /*180*/; beatsPerBar?: number /*4*/ }): Grid`; `gridFromTruth(bpm: number, beatsPerBar: number): Grid` (oracle; `firstDownbeat = 0`).
Approach: spectral-flux onset envelope (hop 512 @ 44.1 kHz, log-magnitude, half-wave rectified), tempo by autocorrelation with a log-Gaussian prior centred at 100 bpm, beat phase by maximising envelope sum on the beat comb, downbeat by the beat-phase (0..3) whose comb has the most low-frequency (< 150 Hz) flux energy. Constant tempo only (tier 1).
- [ ] **Step 1: Write failing test** — for `t1b` and `t1c` seeds 1–5 mixes: `|est.bpm/true − f|/f < 0.02` for some f ∈ {0.5,1,2} in ≥ 8 of 10 songs; `firstDownbeat` mod beat period within 50 ms of 0 in ≥ 8 of 10.
- [ ] **Step 2:** FAIL. **Step 3:** implement. **Step 4:** PASS.
- [ ] **Step 5:** Commit `feat: tempo grid estimation`.
### Task 10: Heuristic drum transcriber
**Files:**
- Create: `src/pipeline/drums.ts`
- Test: `tests/pipeline/drums.test.ts`
**Interfaces:**
- Produces: `transcribeDrums(audio: Audio): TimedNote[]` (pitches 36 kick, 38 snare, 42 hat, 49 cymbal).
Approach: band-split flux (kick < 150 Hz, snare 150 Hz–2 kHz with broadband noise, hats > 7 kHz, cymbal = hat-band onset with long decay > 300 ms), peak-pick each band with adaptive threshold (median + k·MAD), 30 ms min inter-onset per class. Labelled in reports as "heuristic baseline".
- [ ] **Step 1: Write failing test** — on oracle drum stems of `t1b` seeds 1–3: kick F1 > 0.7, snare F1 > 0.6 (class-wise via `scoreSong` drum classes or direct `matchNotes` on mapped pitches); silence → `[]`.
- [ ] **Step 2:** FAIL. **Step 3:** implement. **Step 4:** PASS.
- [ ] **Step 5:** Commit `feat: heuristic drum transcription`.
### Task 11: Quantisation and tab assignment
**Files:**
- Create: `src/pipeline/quantize.ts`, `src/pipeline/tab.ts`
- Test: `tests/pipeline/quantize.test.ts`, `tests/pipeline/tab.test.ts`
**Interfaces:**
- Produces: `quantize(notes: TimedNote[], grid: Grid, stepsPerBeat = 4): ScoreNote[]` (ticks relative to `grid.firstDownbeat`, notes before it dropped; onsets snapped to nearest step, duration snapped to ≥ 1 step; chord notes within 35 ms collapse to the same tick); `assignTab(notes: ScoreNote[], tuning: number[], maxFret = 20): { notes: ScoreNote[]; dropped: number }`.
Tab assignment: group notes by tick into chords; candidates = all injective note→string mappings with `0 ≤ fret ≤ maxFret`; cost = fret span within chord (penalise > 4) + movement from previous hand position (mean non-zero fret) + 0.1·mean fret (prefer low positions) − small bonus for open strings; Viterbi over chords. Notes with no valid position on any string are dropped and counted; if a chord has more notes than strings, keep the highest-pitched `tuning.length` notes and count the rest as dropped.
- [ ] **Step 1: Write failing tests**
```ts
const g = { bpm: 120, firstDownbeat: 0.1, beatsPerBar: 4 };
const q = quantize([t(0.1+0.5+0.02, 0.1+1.0, 60), t(0.1+0.75-0.015, 0.1+1.0, 64)], g);
expect(q.map(n => [n.tick, n.dur, n.pitch])).toEqual([[960, 960, 60], [1440, 480, 64]]);
const chordC = [48,52,55,60,64].map(p => sn(0, 960, p));
const a = assignTab(chordC, GUITAR_STANDARD);
expect(a.dropped).toBe(0);
for (const n of a.notes) expect(GUITAR_STANDARD[n.string!-1]! + n.fret!).toBe(n.pitch);
expect(new Set(a.notes.map(n => n.string)).size).toBe(5);
expect(assignTab([sn(0, 960, 30)], GUITAR_STANDARD)).toEqual({ notes: [], dropped: 1 }); // below low E
```
- [ ] **Step 2:** FAIL. **Step 3:** implement. **Step 4:** PASS.
- [ ] **Step 5:** Commit `feat: quantisation and tab assignment`.
### Task 12: Pipeline runner with oracle modes
**Files:**
- Create: `src/pipeline/run.ts`
- Test: `tests/pipeline/run.test.ts`
**Interfaces:**
- Consumes: separator (T7), transcriber (T8), `estimateGrid`/`gridFromTruth` (T9), `transcribeDrums` (T10), `quantize`/`assignTab` (T11), `songToGp` (T2), `PipelineOutput`/`OracleFlags` (T6).
- Produces: `createPipeline(models: { demucs: Uint8Array; basicPitch: Uint8Array }): Promise<(input: PipelineInput, flags: OracleFlags) => Promise<{ output: PipelineOutput; gp: Uint8Array }>>` where `PipelineInput = { mix: Audio; oracle?: { stems: Map<string, Audio>; truth: { bpm: number; timeSig: [number, number]; tracks: TruthTrack[] } } }`.
Behaviour: stems — non-oracle: Demucs; map guitar→one "Guitar" track (role guitar), bass→bass, piano→keys, vocals→vocals (monophonic, pitch 48–84), drums→drums; `other` is ignored (reported in notes.json as unused). Oracle stems: one output track per truth track, from its own oracle stem. Grid — `oracleTempo` ? `gridFromTruth` : `estimateGrid(mix)`. `oracleNotes`: pitched tracks take their truth notes instead of transcription (tab assignment still runs, measuring fingering only). Pitch ranges for Basic Pitch: guitar 40–88, bass 28–67, keys 21–108. Every pitched track is quantised; guitar/bass get `assignTab` with standard tunings; output `Song` (bpm from grid) → `songToGp`. Pipeline code does no file I/O.
- [ ] **Step 1: Write failing test** — on `data/t1a/1` with `{ oracleStems: true, oracleTempo: true, oracleNotes: false }`: output has 1 guitar track with > 0 notes; `truthFromGp(gp)` re-imports with a guitar track; with `oracleNotes: true` merged guitar F1 from `scoreSong` === 1 and `stringAgreement` is a number. On `data/t1c/1` with all flags false: output roles ⊇ {guitar, bass, keys, vocals, drums}.
- [ ] **Step 2:** FAIL. **Step 3:** implement. **Step 4:** PASS.
- [ ] **Step 5:** Commit `feat: pipeline runner with oracle modes`.
### Task 13: Benchmark orchestration and reports
**Files:**
- Create: `src/bench/report.ts`; Modify: `src/bench/cli.ts`
- Test: `tests/bench/report.test.ts`
**Interfaces:**
- Produces: `renderReport(run: BenchRun): string` (markdown), `BenchRun = { runId: string; date: string; modes: { name: string; flags: OracleFlags }[]; results: { tier: TierId; seed: number; mode: string; score: SongScore; seconds: number }[] }`.
- CLI: `bun run bench run --tiers t1a,t1b,t1c,t1d --seeds 1-5 --modes full,oracle-stems,oracle-tempo,oracle-notes --run-id <id>` builds missing songs, runs each mode, writes `runs/<id>/<tier>/<seed>/{notes.json,out.gp,score.json}` and `results/<id>.{md,json}`. Modes: `full` = all false; `oracle-stems` = stems only; `oracle-tempo` = stems + tempo; `oracle-notes` = all true.
Report layout: header (run id, date, machine, model hashes, SoundFont, vocals caveat); per tier a table of median/min/max per mode in this column order: guitar merged F1 (pass ✓/✗), guitar octave errors, spurious rate, part-split F1 (t1d), vocals F1, bass F1, drums kick/snare/hihat F1, keys F1, rhythm onBeat/durationOk with tempo factor/offset, string agreement; tier pass line (median song passes, `full` mode); per-song appendix. `null` renders as `n/a`.
- [ ] **Step 1: Write failing test** — `renderReport` on a fixture `BenchRun` (2 songs, 2 modes, one with vocals `null`): contains "Guitar" column before "Vocals" before "Bass"; contains "n/a"; tier pass line reads "PASS" when median guitar F1 ≥ 0.85 and "FAIL" otherwise.
- [ ] **Step 2:** FAIL. **Step 3:** implement. **Step 4:** PASS.
- [ ] **Step 5:** Commit `feat: benchmark runner and report`.
### Task 14: Baseline benchmark run
- [ ] **Step 1:** `bun run fetch-models && bun run typecheck && bun test --timeout 300000` → all pass.
- [ ] **Step 2:** `bun run bench run --tiers t1a,t1b,t1c,t1d --seeds 1-5 --modes full,oracle-stems,oracle-tempo,oracle-notes --run-id baseline-2026-10-30` → `results/baseline-2026-10-30.md` exists with 4 tier sections.
- [ ] **Step 3:** Listen-check is not possible; instead spot-check one `out.gp` per tier re-imports via `truthFromGp` and one `mix.mp3` is non-silent (ffprobe duration > 30 s).
- [ ] **Step 4:** Add a short `README.md` (what the harness is, commands, where results live). Commit `chore: baseline benchmark results` and push to `origin main`.