35 KiB
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 inonnxruntime-web(InferenceSession.create,Tensor,session.run). - CPU only. Use
executionProviders: ["cpu"]. - Pipeline code (
src/pipeline/**,src/dsp/**,src/score/**) must not importnode:fs/Bun.*; file I/O lives insrc/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/*.onnxare git-ignored;results/*.md+results/*.jsonsummaries 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
- 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.
- Silent or absent stems (no vocals in t1a; a stem Demucs leaves near-silent) — transcribers return
[], scorer reportsnullmetrics (not NaN) and the report shows "n/a". Tests in Tasks 6, 8. - 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.
- Pitches outside the instrument's fret range (octave errors from transcription) — tab assigner drops them, counts them, never throws. Test in Task 11.
- 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, deleteindex.ts - Test:
tests/core/time.test.ts,tests/core/rng.test.ts
Interfaces:
-
Produces (
src/core/types.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): numberinsrc/core/time.ts;createRng(seed: number): Rngwithnext(): number(0..1),int(lo: number, hi: number): number(inclusive),pick<T>(a: readonly T[]): T,chance(p: number): booleaninsrc/core/rng.ts(mulberry32). -
Step 1: Write failing tests
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.jsonscripts:"test": "bun test","typecheck": "bunx tsc --noEmit","fetch-models": "bun scripts/fetch-models.ts","bench": "bun src/bench/cli.ts"..gitignoreadddata/,runs/,models/**/*.onnx.scripts/fetch-models.tsdownloadshttps://huggingface.co/Kani95/htdemucs-6s-ort/resolve/main/htdemucs_6s.onnx→models/demucs/htdemucs_6s.onnxandhttps://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 asfret.string; keys/vocals:\staff {score}, notes as note names (e.g.C#4). -
Each track is one voice. Notes with equal
tickform a chord beat; their beat duration is the shortestdurin 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). -
truthFromGpuses absolute tick =masterBar.start + beat.playbackStart, seconds viaticksToSecondswith 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 fromtrack.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 ofdur = 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: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
Loggeror 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
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.
mixStemspeak ≈ 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; writessong.json,truth.gp,stems/<trackName>.wav,mix.wav,mix.mp3; idempotent — skips ifmix.mp3exists andsong.jsonmatches);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;loadSongDirreturnstruth.tracks[0].notes.length === song.tracks[0].notes.length(ties are not generated by the tier-1 generator, so counts match) andmix.channels.length === 2. -
Step 2: FAIL. Step 3: implement. Step 4: PASS; run
bun run bench gen --tiers t1a --seeds 1-2and 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:
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): SongScorePipelineOutputandOracleFlagsare defined here insrc/eval/score.ts(re-exported to Task 12):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
plus a
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 });scoreSongtest with two ref guitar tracks and one merged out track (partSplitnull-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
fftof 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 viabuildSong): 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[]>>withTranscribeOpts = { 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/1oracle vocals stem) withmonophonic: true: no two output notes overlap in time. - 2 s of zeros →
[].
- On the oracle guitar stem of
- 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
t1bandt1cseeds 1–5 mixes:|est.bpm/true − f|/f < 0.02for some f ∈ {0.5,1,2} in ≥ 8 of 10 songs;firstDownbeatmod 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
t1bseeds 1–3: kick F1 > 0.7, snare F1 > 0.6 (class-wise viascoreSongdrum classes or directmatchNoteson 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 togrid.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
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 }>>wherePipelineInput = { 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/1with{ oracleStems: true, oracleTempo: true, oracleNotes: false }: output has 1 guitar track with > 0 notes;truthFromGp(gp)re-imports with a guitar track; withoracleNotes: truemerged guitar F1 fromscoreSong=== 1 andstringAgreementis a number. Ondata/t1c/1with 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, writesruns/<id>/<tier>/<seed>/{notes.json,out.gp,score.json}andresults/<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 —
renderReporton a fixtureBenchRun(2 songs, 2 modes, one with vocalsnull): 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.mdexists with 4 tier sections. - Step 3: Listen-check is not possible; instead spot-check one
out.gpper tier re-imports viatruthFromGpand onemix.mp3is non-silent (ffprobe duration > 30 s). - Step 4: Add a short
README.md(what the harness is, commands, where results live). Commitchore: baseline benchmark resultsand push toorigin main.