diff --git a/apps/backend/fundamentals_evaluation.py b/apps/backend/fundamentals_evaluation.py index 1b06856d..505057d3 100644 --- a/apps/backend/fundamentals_evaluation.py +++ b/apps/backend/fundamentals_evaluation.py @@ -53,6 +53,7 @@ def run_fundamentals_evaluation( tracks_dir: Path = DEFAULT_TRACKS_DIR, report_path: Path = DEFAULT_REPORT_PATH, runner: Runner | None = None, + fail_on_skip: bool = False, ) -> dict[str, Any]: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) tracks = manifest.get("tracks", []) @@ -90,7 +91,8 @@ def run_fundamentals_evaluation( "tracksAnalyzeFailed": failed_subprocess, "checksPassed": passed_checks, "checksFailed": failed_checks, - "allPassed": failed_checks == 0, + "failOnSkip": fail_on_skip, + "allPassed": failed_checks == 0 and (not fail_on_skip or skipped == 0), } report: dict[str, Any] = { "schemaVersion": "fundamentals-eval-report.v1", @@ -445,11 +447,25 @@ def _chord_segment_accuracy(actual: list[Any], expected: list[Any]) -> float: if actual_start is None or actual_end is None: continue overlap = max(0.0, min(end, actual_end) - max(start, actual_start)) - if overlap > 0.0 and actual_label == label: + if ( + overlap > 0.0 + and _normalize_chord_label_for_compare(actual_label) + == _normalize_chord_label_for_compare(label) + ): matching_duration += overlap return matching_duration / total_duration if total_duration > 0 else 0.0 +def _normalize_chord_label_for_compare(label: str | None) -> str: + """Normalize ASA triad labels for fixture comparisons without enharmonic folding.""" + value = (label or "").strip() + value = re.sub(r"\s+", "", value) + value = value.replace(":maj", "").replace(":min", "m") + value = re.sub(r"(?i)major$", "", value) + value = re.sub(r"(?i)minor$", "m", value) + return value + + def _note_f1(actual: list[Any], expected: list[Any]) -> float: actual_notes = [entry for entry in actual if isinstance(entry, dict)] expected_notes = [entry for entry in expected if isinstance(entry, dict)] diff --git a/apps/backend/scripts/build_synthetic_corpus.py b/apps/backend/scripts/build_synthetic_corpus.py new file mode 100755 index 00000000..6a235db9 --- /dev/null +++ b/apps/backend/scripts/build_synthetic_corpus.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +"""Build the generated-on-demand fundamentals synthetic audio corpus. + +Audio is intentionally not committed. This script emits deterministic WAV files +under tests/fixtures/fundamentals_tracks plus a JSON manifest that activates the +fundamentals evaluation harness without relying on owner-provided audio. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable + +import numpy as np + +BACKEND_DIR = Path(__file__).resolve().parent.parent +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +import sample_drums # noqa: E402 +from sample_drums import SAMPLE_RATE, synth_hat, synth_kick, synth_snare # noqa: E402 +from sample_synthesis import render_clip, write_wav # noqa: E402 +from sample_theory import ( # noqa: E402 + ClipPlan, + NoteEvent, + build_context, + plan_bass_root, + _diatonic_triad_midi, +) + +SCHEMA_VERSION = "fundamentals-eval.v1" +TARGET_PROFILE = "electronic_ableton_v1" + + +@dataclass(frozen=True) +class RenderedClip: + samples: np.ndarray + truth: dict[str, Any] + + +def _meter_beats_per_bar(meter: str) -> int: + numerator, _slash, denominator = meter.partition("/") + if denominator == "8" and numerator == "6": + return 6 + return int(numerator or 4) + + +def _duration_for(bpm: float, beats: float) -> float: + return float(beats) * 60.0 / float(bpm) + + +def _overlay( + target: np.ndarray, source: np.ndarray, start_sec: float, gain: float = 1.0 +) -> None: + start = int(round(start_sec * SAMPLE_RATE)) + end = min(target.size, start + source.size) + if start < target.size and end > start: + target[start:end] += source[: end - start] * gain + + +def render_drum_pattern( + bpm: float, + meter: str, + bars: int, + kick_positions: Iterable[float], + snare_positions: Iterable[float], + hat_positions: Iterable[float], + swing_percent: float = 50.0, +) -> RenderedClip: + beats_per_bar = _meter_beats_per_bar(meter) + total_beats = bars * beats_per_bar + duration = _duration_for(bpm, total_beats) + 0.7 + samples = np.zeros(int(round(duration * SAMPLE_RATE)), dtype=np.float32) + kick = synth_kick().samples + snare = synth_snare().samples + hat = synth_hat().samples + + beat_seconds = 60.0 / bpm + swing_offset = max(0.0, (float(swing_percent) - 50.0) / 100.0) * beat_seconds + + def beat_to_seconds(beat: float) -> float: + # Delay off eighths for swung hats while preserving downbeat truth. + eighth_index = round(beat * 2.0) + is_off_eighth = eighth_index % 2 == 1 and abs(beat * 2.0 - eighth_index) < 1e-6 + return beat * beat_seconds + (swing_offset if is_off_eighth else 0.0) + + kick_positions = list(kick_positions) + snare_positions = list(snare_positions) + hat_positions = list(hat_positions) + for beat in kick_positions: + _overlay(samples, kick, beat_to_seconds(beat), 0.95) + for beat in snare_positions: + _overlay(samples, snare, beat_to_seconds(beat), 0.65) + for beat in hat_positions: + _overlay(samples, hat, beat_to_seconds(beat), 0.35) + peak = float(np.max(np.abs(samples))) if samples.size else 0.0 + if peak > 0.98: + samples *= 0.98 / peak + beat_grid = [round(index * beat_seconds, 6) for index in range(total_beats)] + downbeats = [round(index * beats_per_bar * beat_seconds, 6) for index in range(bars)] + truth = { + "bpm": bpm, + "timeSignature": meter, + "beatGrid": beat_grid, + "downbeats": downbeats, + "percussion": { + "kickCount": len(kick_positions), + "snareCount": len(snare_positions), + "hihatCount": len(hat_positions), + }, + "hitTimes": { + "kick": [round(beat_to_seconds(beat), 6) for beat in kick_positions], + "snare": [round(beat_to_seconds(beat), 6) for beat in snare_positions], + "hihat": [round(beat_to_seconds(beat), 6) for beat in hat_positions], + }, + "swingPercent": swing_percent, + } + return RenderedClip(samples.astype(np.float32), truth) + + +def _label_for_degree(root_name: str, mode: str, degree: int) -> str: + major = { + "C": ["C", "Am", "F", "G"], "Db": ["Db", "Bbm", "Gb", "Ab"], + "D": ["D", "Bm", "G", "A"], "Eb": ["Eb", "Cm", "Ab", "Bb"], + "E": ["E", "C#m", "A", "B"], "F": ["F", "Dm", "Bb", "C"], + "F#": ["F#", "D#m", "B", "C#"], "G": ["G", "Em", "C", "D"], + "Ab": ["Ab", "Fm", "Db", "Eb"], "A": ["A", "F#m", "D", "E"], + "Bb": ["Bb", "Gm", "Eb", "F"], "B": ["B", "G#m", "E", "F#"], + } + minor = { + "C": ["Cm", "Ab", "Bb", "Gm"], "Db": ["Dbm", "A", "B", "Abm"], + "D": ["Dm", "Bb", "C", "Am"], "Eb": ["Ebm", "B", "Db", "Bbm"], + "E": ["Em", "C", "D", "Bm"], "F": ["Fm", "Db", "Eb", "Cm"], + "F#": ["F#m", "D", "E", "C#m"], "G": ["Gm", "Eb", "F", "Dm"], + "Ab": ["Abm", "E", "Gb", "Ebm"], "A": ["Am", "F", "G", "Em"], + "Bb": ["Bbm", "Gb", "Ab", "Fm"], "B": ["Bm", "G", "A", "F#m"], + } + order = [1, 6, 4, 5] if mode == "major" else [1, 6, 7, 5] + table = major if mode == "major" else minor + return table[root_name][order.index(degree)] + + +def render_chord_progression( + key_root: str, + mode: str, + degrees: list[int], + bpm: float, + beats_per_chord: float, +) -> RenderedClip: + ctx = build_context(key=f"{key_root} {mode}", bpm=bpm) + notes: list[NoteEvent] = [] + for index, degree in enumerate(degrees): + for pitch in _diatonic_triad_midi(ctx.root_pc, ctx.mode, degree, base_octave=4): + notes.append( + NoteEvent( + pitch_midi=pitch, + start_beat=index * beats_per_chord, + duration_beats=beats_per_chord, + velocity=88, + ) + ) + plan = ClipPlan( + tempo_bpm=bpm, + duration_beats=len(degrees) * beats_per_chord, + notes=notes, + program=0, + ) + rendered = render_clip(plan, allow_soundfont_backends=False) + beat_seconds = 60.0 / bpm + timeline = [] + for index, degree in enumerate(degrees): + start = index * beats_per_chord * beat_seconds + end = (index + 1) * beats_per_chord * beat_seconds + timeline.append( + { + "startSec": round(start, 6), + "endSec": round(end, 6), + "label": _label_for_degree(key_root, mode, degree), + } + ) + return RenderedClip( + rendered.samples, + {"bpm": bpm, "key": f"{key_root} {mode}", "chordTimeline": timeline}, + ) + + +def render_multi_layer(key_root: str, mode: str, bpm: float) -> RenderedClip: + chords = render_chord_progression(key_root, mode, [1, 6, 4 if mode == "major" else 7, 5], bpm, 4.0) + ctx = build_context(key=f"{key_root} {mode}", bpm=bpm) + bass = render_clip(plan_bass_root(ctx, bars=4), allow_soundfont_backends=False).samples + drums = render_drum_pattern( + bpm, "4/4", 4, range(0, 16, 1), [2, 6, 10, 14], [i * 0.5 for i in range(32)] + ) + n = max(chords.samples.size, bass.size, drums.samples.size) + mix = np.zeros(n, dtype=np.float32) + mix[: chords.samples.size] += chords.samples * 0.55 + mix[: bass.size] += bass * 0.28 + mix[: drums.samples.size] += drums.samples * 0.45 + peak = float(np.max(np.abs(mix))) if mix.size else 0.0 + if peak > 0.98: + mix *= 0.98 / peak + truth = {**chords.truth, "timeSignature": "4/4", "percussion": drums.truth["percussion"]} + return RenderedClip(mix, truth) + + +def _default_specs() -> list[dict[str, Any]]: + return [ + {"id": "four_on_floor_clear_128", "kind": "drums", "bpm": 128, "meter": "4/4", "bars": 4}, + {"id": "tonal_minor_static_chords", "kind": "chords", "root": "A", "mode": "minor", "bpm": 120, "degrees": [1, 6], "beats_per_chord": 8}, + {"id": "drum_stem_known_counts", "kind": "drums", "bpm": 128, "meter": "4/4", "bars": 4}, + {"id": "mono_bass_transcription", "kind": "bass", "bpm": 120}, + ] + + +def _synthetic_specs() -> list[dict[str, Any]]: + specs = list(_default_specs()) + for bpm, meter in [(70, "4/4"), (90, "3/4"), (110, "6/8"), (128, "4/4"), (140, "7/8"), (174, "4/4")]: + specs.append({"id": f"grid_{meter.replace('/', '_')}_{bpm}", "kind": "drums", "bpm": bpm, "meter": meter, "bars": 4}) + roots = ["C", "Db", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"] + for i, root in enumerate(roots): + mode = "major" if i % 2 == 0 else "minor" + degrees = [1, 6, 4, 5] if mode == "major" else [1, 6, 7, 5] + specs.append({"id": f"chords_{root.replace('#','sharp').replace('b','flat')}_{mode}", "kind": "chords", "root": root, "mode": mode, "bpm": 96 + i * 4, "degrees": degrees, "beats_per_chord": 4}) + for swing in [50, 54, 58, 62, 66]: + specs.append({"id": f"swing_hats_{swing}", "kind": "swing", "bpm": 124, "meter": "4/4", "bars": 4, "swing": swing}) + for root, mode, bpm in [("A", "minor", 128), ("F", "major", 122)]: + specs.append({"id": f"multi_{root}_{mode}", "kind": "multi", "root": root, "mode": mode, "bpm": bpm}) + return specs[:28] + + +def _render_spec(spec: dict[str, Any]) -> RenderedClip: + kind = spec["kind"] + if kind == "drums": + meter = spec.get("meter", "4/4") + bars = int(spec.get("bars", 4)) + beats = bars * _meter_beats_per_bar(meter) + if spec["id"] in {"four_on_floor_clear_128", "drum_stem_known_counts"}: + kicks = list(range(0, beats, 1)) + else: + kicks = list(range(0, beats, max(1, _meter_beats_per_bar(meter)))) + if spec["id"] == "drum_stem_known_counts": + snares = list(range(1, beats, 2)) + else: + snares = [beat for beat in range(beats) if beat % max(2, _meter_beats_per_bar(meter)) == 2] + hats = [i * 0.5 for i in range(beats * 2)] + return render_drum_pattern(float(spec["bpm"]), meter, bars, kicks, snares, hats) + if kind == "swing": + beats = int(spec.get("bars", 4)) * 4 + return render_drum_pattern(float(spec["bpm"]), "4/4", int(spec["bars"]), range(0, beats, 4), [2, 6, 10, 14], [i * 0.5 for i in range(beats * 2)], float(spec["swing"])) + if kind == "chords": + return render_chord_progression(spec["root"], spec["mode"], list(spec["degrees"]), float(spec["bpm"]), float(spec["beats_per_chord"])) + if kind == "multi": + return render_multi_layer(spec["root"], spec["mode"], float(spec["bpm"])) + if kind == "bass": + notes = [ + NoteEvent(48, 0.0, 1.0), + NoteEvent(51, 1.0, 1.0), + NoteEvent(55, 2.0, 1.0), + ] + plan = ClipPlan(tempo_bpm=float(spec["bpm"]), duration_beats=3.0, notes=notes, program=38) + rendered = render_clip(plan, allow_soundfont_backends=False) + return RenderedClip( + rendered.samples, + { + "transcriptionNotes": [ + {"pitchMidi": 48, "onsetSeconds": 0.0, "durationSeconds": 0.5}, + {"pitchMidi": 51, "onsetSeconds": 0.5, "durationSeconds": 0.5}, + {"pitchMidi": 55, "onsetSeconds": 1.0, "durationSeconds": 0.5}, + ] + }, + ) + raise ValueError(f"unknown spec kind: {kind}") + + +def _manifest_track(spec: dict[str, Any], rendered: RenderedClip, *, synthetic_subdir: bool) -> dict[str, Any]: + audio_name = f"{spec['id']}.wav" + expected = dict(rendered.truth) + track = { + "id": spec["id"], + "audioPath": f"synthetic/{audio_name}" if synthetic_subdir else audio_name, + "category": spec["kind"], + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture.", + "expected": expected, + "thresholds": {"bpmTolerance": 1.0, "beatF1": 0.9, "downbeatF1": 0.75, "chordSegmentAccuracy": 0.65, "percussionCountTolerance": 1, "transcriptionNoteF1": 0.75}, + } + if spec["kind"] == "multi": + track["analyzeFlags"] = ["--separate"] + return track + + +def emit_manifest(manifest_path: Path, tracks: list[dict[str, Any]]) -> None: + manifest = {"schemaVersion": SCHEMA_VERSION, "targetProfile": TARGET_PROFILE, "gates": {"clearTempoWithinBpm": 1.0, "beatF1": 0.9, "downbeatF1": 0.75, "chordSegmentAccuracy": 0.65}, "tracks": tracks} + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + +def _reset_drum_rng() -> None: + sample_drums._RNG = np.random.default_rng(seed=42) + + +def build_corpus( + out_dir: Path, + manifest_path: Path, + *, + check: bool = False, + write_manifest: bool = True, +) -> dict[str, Any]: + _reset_drum_rng() + synthetic_manifest = manifest_path.name.endswith(".synthetic.json") + specs = _synthetic_specs() if synthetic_manifest else _default_specs() + audio_root = out_dir / "synthetic" if synthetic_manifest else out_dir + tracks: list[dict[str, Any]] = [] + fingerprints: dict[str, bytes] = {} + for spec in specs: + rendered = _render_spec(spec) + path = audio_root / f"{spec['id']}.wav" + write_wav(rendered.samples, path=path, sample_rate=SAMPLE_RATE) + fingerprints[spec["id"]] = rendered.samples.tobytes() + tracks.append(_manifest_track(spec, rendered, synthetic_subdir=synthetic_manifest)) + if write_manifest: + emit_manifest(manifest_path, tracks) + if check: + _reset_drum_rng() + for spec in specs: + second = _render_spec(spec) + if second.samples.tobytes() != fingerprints[spec["id"]]: + raise SystemExit(f"non-deterministic render for {spec['id']}") + return { + "tracks": len(tracks), + "manifest": str(manifest_path) if write_manifest else None, + "outDir": str(out_dir), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build ASA's deterministic synthetic fundamentals corpus.") + parser.add_argument("--out-dir", type=Path, default=BACKEND_DIR / "tests" / "fixtures" / "fundamentals_tracks") + parser.add_argument("--manifest", type=Path, default=BACKEND_DIR / "tests" / "fixtures" / "fundamentals_eval_manifest.synthetic.json") + parser.add_argument("--check", action="store_true", help="Double-render every clip and fail if bytes differ.") + parser.add_argument( + "--audio-only", + action="store_true", + help="Render WAV files without rewriting the committed manifest.", + ) + args = parser.parse_args() + print( + json.dumps( + build_corpus( + args.out_dir, + args.manifest, + check=args.check, + write_manifest=not args.audio_only, + ), + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/apps/backend/scripts/evaluate_fundamentals.py b/apps/backend/scripts/evaluate_fundamentals.py index f9681ed7..44f54795 100644 --- a/apps/backend/scripts/evaluate_fundamentals.py +++ b/apps/backend/scripts/evaluate_fundamentals.py @@ -31,6 +31,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST_PATH) parser.add_argument("--tracks-dir", type=Path, default=DEFAULT_TRACKS_DIR) parser.add_argument("--report", type=Path, default=DEFAULT_REPORT_PATH) + parser.add_argument("--fail-on-skip", action="store_true", help="Fail if any manifest track is skipped because audio is missing.") return parser.parse_args() @@ -40,6 +41,7 @@ def main() -> None: manifest_path=args.manifest, tracks_dir=args.tracks_dir, report_path=args.report, + fail_on_skip=args.fail_on_skip, ) summary: dict[str, Any] = { "summary": report["summary"], diff --git a/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json b/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json new file mode 100644 index 00000000..2c287891 --- /dev/null +++ b/apps/backend/tests/fixtures/fundamentals_eval_manifest.synthetic.json @@ -0,0 +1,1952 @@ +{ + "schemaVersion": "fundamentals-eval.v1", + "targetProfile": "electronic_ableton_v1", + "gates": { + "clearTempoWithinBpm": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65 + }, + "tracks": [ + { + "id": "four_on_floor_clear_128", + "audioPath": "synthetic/four_on_floor_clear_128.wav", + "category": "drums", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 128, + "timeSignature": "4/4", + "beatGrid": [ + 0.0, + 0.46875, + 0.9375, + 1.40625, + 1.875, + 2.34375, + 2.8125, + 3.28125, + 3.75, + 4.21875, + 4.6875, + 5.15625, + 5.625, + 6.09375, + 6.5625, + 7.03125 + ], + "downbeats": [ + 0.0, + 1.875, + 3.75, + 5.625 + ], + "percussion": { + "kickCount": 16, + "snareCount": 4, + "hihatCount": 32 + }, + "hitTimes": { + "kick": [ + 0.0, + 0.46875, + 0.9375, + 1.40625, + 1.875, + 2.34375, + 2.8125, + 3.28125, + 3.75, + 4.21875, + 4.6875, + 5.15625, + 5.625, + 6.09375, + 6.5625, + 7.03125 + ], + "snare": [ + 0.9375, + 2.8125, + 4.6875, + 6.5625 + ], + "hihat": [ + 0.0, + 0.234375, + 0.46875, + 0.703125, + 0.9375, + 1.171875, + 1.40625, + 1.640625, + 1.875, + 2.109375, + 2.34375, + 2.578125, + 2.8125, + 3.046875, + 3.28125, + 3.515625, + 3.75, + 3.984375, + 4.21875, + 4.453125, + 4.6875, + 4.921875, + 5.15625, + 5.390625, + 5.625, + 5.859375, + 6.09375, + 6.328125, + 6.5625, + 6.796875, + 7.03125, + 7.265625 + ] + }, + "swingPercent": 50 + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "tonal_minor_static_chords", + "audioPath": "synthetic/tonal_minor_static_chords.wav", + "category": "chords", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 120, + "key": "A minor", + "chordTimeline": [ + { + "startSec": 0.0, + "endSec": 4.0, + "label": "Am" + }, + { + "startSec": 4.0, + "endSec": 8.0, + "label": "F" + } + ] + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "drum_stem_known_counts", + "audioPath": "synthetic/drum_stem_known_counts.wav", + "category": "drums", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 128, + "timeSignature": "4/4", + "beatGrid": [ + 0.0, + 0.46875, + 0.9375, + 1.40625, + 1.875, + 2.34375, + 2.8125, + 3.28125, + 3.75, + 4.21875, + 4.6875, + 5.15625, + 5.625, + 6.09375, + 6.5625, + 7.03125 + ], + "downbeats": [ + 0.0, + 1.875, + 3.75, + 5.625 + ], + "percussion": { + "kickCount": 16, + "snareCount": 8, + "hihatCount": 32 + }, + "hitTimes": { + "kick": [ + 0.0, + 0.46875, + 0.9375, + 1.40625, + 1.875, + 2.34375, + 2.8125, + 3.28125, + 3.75, + 4.21875, + 4.6875, + 5.15625, + 5.625, + 6.09375, + 6.5625, + 7.03125 + ], + "snare": [ + 0.46875, + 1.40625, + 2.34375, + 3.28125, + 4.21875, + 5.15625, + 6.09375, + 7.03125 + ], + "hihat": [ + 0.0, + 0.234375, + 0.46875, + 0.703125, + 0.9375, + 1.171875, + 1.40625, + 1.640625, + 1.875, + 2.109375, + 2.34375, + 2.578125, + 2.8125, + 3.046875, + 3.28125, + 3.515625, + 3.75, + 3.984375, + 4.21875, + 4.453125, + 4.6875, + 4.921875, + 5.15625, + 5.390625, + 5.625, + 5.859375, + 6.09375, + 6.328125, + 6.5625, + 6.796875, + 7.03125, + 7.265625 + ] + }, + "swingPercent": 50 + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "mono_bass_transcription", + "audioPath": "synthetic/mono_bass_transcription.wav", + "category": "bass", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "transcriptionNotes": [ + { + "pitchMidi": 48, + "onsetSeconds": 0.0, + "durationSeconds": 0.5 + }, + { + "pitchMidi": 51, + "onsetSeconds": 0.5, + "durationSeconds": 0.5 + }, + { + "pitchMidi": 55, + "onsetSeconds": 1.0, + "durationSeconds": 0.5 + } + ] + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "grid_4_4_70", + "audioPath": "synthetic/grid_4_4_70.wav", + "category": "drums", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 70, + "timeSignature": "4/4", + "beatGrid": [ + 0.0, + 0.857143, + 1.714286, + 2.571429, + 3.428571, + 4.285714, + 5.142857, + 6.0, + 6.857143, + 7.714286, + 8.571429, + 9.428571, + 10.285714, + 11.142857, + 12.0, + 12.857143 + ], + "downbeats": [ + 0.0, + 3.428571, + 6.857143, + 10.285714 + ], + "percussion": { + "kickCount": 4, + "snareCount": 4, + "hihatCount": 32 + }, + "hitTimes": { + "kick": [ + 0.0, + 3.428571, + 6.857143, + 10.285714 + ], + "snare": [ + 1.714286, + 5.142857, + 8.571429, + 12.0 + ], + "hihat": [ + 0.0, + 0.428571, + 0.857143, + 1.285714, + 1.714286, + 2.142857, + 2.571429, + 3.0, + 3.428571, + 3.857143, + 4.285714, + 4.714286, + 5.142857, + 5.571429, + 6.0, + 6.428571, + 6.857143, + 7.285714, + 7.714286, + 8.142857, + 8.571429, + 9.0, + 9.428571, + 9.857143, + 10.285714, + 10.714286, + 11.142857, + 11.571429, + 12.0, + 12.428571, + 12.857143, + 13.285714 + ] + }, + "swingPercent": 50 + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "grid_3_4_90", + "audioPath": "synthetic/grid_3_4_90.wav", + "category": "drums", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 90, + "timeSignature": "3/4", + "beatGrid": [ + 0.0, + 0.666667, + 1.333333, + 2.0, + 2.666667, + 3.333333, + 4.0, + 4.666667, + 5.333333, + 6.0, + 6.666667, + 7.333333 + ], + "downbeats": [ + 0.0, + 2.0, + 4.0, + 6.0 + ], + "percussion": { + "kickCount": 4, + "snareCount": 4, + "hihatCount": 24 + }, + "hitTimes": { + "kick": [ + 0.0, + 2.0, + 4.0, + 6.0 + ], + "snare": [ + 1.333333, + 3.333333, + 5.333333, + 7.333333 + ], + "hihat": [ + 0.0, + 0.333333, + 0.666667, + 1.0, + 1.333333, + 1.666667, + 2.0, + 2.333333, + 2.666667, + 3.0, + 3.333333, + 3.666667, + 4.0, + 4.333333, + 4.666667, + 5.0, + 5.333333, + 5.666667, + 6.0, + 6.333333, + 6.666667, + 7.0, + 7.333333, + 7.666667 + ] + }, + "swingPercent": 50 + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "grid_6_8_110", + "audioPath": "synthetic/grid_6_8_110.wav", + "category": "drums", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 110, + "timeSignature": "6/8", + "beatGrid": [ + 0.0, + 0.545455, + 1.090909, + 1.636364, + 2.181818, + 2.727273, + 3.272727, + 3.818182, + 4.363636, + 4.909091, + 5.454545, + 6.0, + 6.545455, + 7.090909, + 7.636364, + 8.181818, + 8.727273, + 9.272727, + 9.818182, + 10.363636, + 10.909091, + 11.454545, + 12.0, + 12.545455 + ], + "downbeats": [ + 0.0, + 3.272727, + 6.545455, + 9.818182 + ], + "percussion": { + "kickCount": 4, + "snareCount": 4, + "hihatCount": 48 + }, + "hitTimes": { + "kick": [ + 0.0, + 3.272727, + 6.545455, + 9.818182 + ], + "snare": [ + 1.090909, + 4.363636, + 7.636364, + 10.909091 + ], + "hihat": [ + 0.0, + 0.272727, + 0.545455, + 0.818182, + 1.090909, + 1.363636, + 1.636364, + 1.909091, + 2.181818, + 2.454545, + 2.727273, + 3.0, + 3.272727, + 3.545455, + 3.818182, + 4.090909, + 4.363636, + 4.636364, + 4.909091, + 5.181818, + 5.454545, + 5.727273, + 6.0, + 6.272727, + 6.545455, + 6.818182, + 7.090909, + 7.363636, + 7.636364, + 7.909091, + 8.181818, + 8.454545, + 8.727273, + 9.0, + 9.272727, + 9.545455, + 9.818182, + 10.090909, + 10.363636, + 10.636364, + 10.909091, + 11.181818, + 11.454545, + 11.727273, + 12.0, + 12.272727, + 12.545455, + 12.818182 + ] + }, + "swingPercent": 50 + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "grid_4_4_128", + "audioPath": "synthetic/grid_4_4_128.wav", + "category": "drums", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 128, + "timeSignature": "4/4", + "beatGrid": [ + 0.0, + 0.46875, + 0.9375, + 1.40625, + 1.875, + 2.34375, + 2.8125, + 3.28125, + 3.75, + 4.21875, + 4.6875, + 5.15625, + 5.625, + 6.09375, + 6.5625, + 7.03125 + ], + "downbeats": [ + 0.0, + 1.875, + 3.75, + 5.625 + ], + "percussion": { + "kickCount": 4, + "snareCount": 4, + "hihatCount": 32 + }, + "hitTimes": { + "kick": [ + 0.0, + 1.875, + 3.75, + 5.625 + ], + "snare": [ + 0.9375, + 2.8125, + 4.6875, + 6.5625 + ], + "hihat": [ + 0.0, + 0.234375, + 0.46875, + 0.703125, + 0.9375, + 1.171875, + 1.40625, + 1.640625, + 1.875, + 2.109375, + 2.34375, + 2.578125, + 2.8125, + 3.046875, + 3.28125, + 3.515625, + 3.75, + 3.984375, + 4.21875, + 4.453125, + 4.6875, + 4.921875, + 5.15625, + 5.390625, + 5.625, + 5.859375, + 6.09375, + 6.328125, + 6.5625, + 6.796875, + 7.03125, + 7.265625 + ] + }, + "swingPercent": 50 + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "grid_7_8_140", + "audioPath": "synthetic/grid_7_8_140.wav", + "category": "drums", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 140, + "timeSignature": "7/8", + "beatGrid": [ + 0.0, + 0.428571, + 0.857143, + 1.285714, + 1.714286, + 2.142857, + 2.571429, + 3.0, + 3.428571, + 3.857143, + 4.285714, + 4.714286, + 5.142857, + 5.571429, + 6.0, + 6.428571, + 6.857143, + 7.285714, + 7.714286, + 8.142857, + 8.571429, + 9.0, + 9.428571, + 9.857143, + 10.285714, + 10.714286, + 11.142857, + 11.571429 + ], + "downbeats": [ + 0.0, + 3.0, + 6.0, + 9.0 + ], + "percussion": { + "kickCount": 4, + "snareCount": 4, + "hihatCount": 56 + }, + "hitTimes": { + "kick": [ + 0.0, + 3.0, + 6.0, + 9.0 + ], + "snare": [ + 0.857143, + 3.857143, + 6.857143, + 9.857143 + ], + "hihat": [ + 0.0, + 0.214286, + 0.428571, + 0.642857, + 0.857143, + 1.071429, + 1.285714, + 1.5, + 1.714286, + 1.928571, + 2.142857, + 2.357143, + 2.571429, + 2.785714, + 3.0, + 3.214286, + 3.428571, + 3.642857, + 3.857143, + 4.071429, + 4.285714, + 4.5, + 4.714286, + 4.928571, + 5.142857, + 5.357143, + 5.571429, + 5.785714, + 6.0, + 6.214286, + 6.428571, + 6.642857, + 6.857143, + 7.071429, + 7.285714, + 7.5, + 7.714286, + 7.928571, + 8.142857, + 8.357143, + 8.571429, + 8.785714, + 9.0, + 9.214286, + 9.428571, + 9.642857, + 9.857143, + 10.071429, + 10.285714, + 10.5, + 10.714286, + 10.928571, + 11.142857, + 11.357143, + 11.571429, + 11.785714 + ] + }, + "swingPercent": 50 + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "grid_4_4_174", + "audioPath": "synthetic/grid_4_4_174.wav", + "category": "drums", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 174, + "timeSignature": "4/4", + "beatGrid": [ + 0.0, + 0.344828, + 0.689655, + 1.034483, + 1.37931, + 1.724138, + 2.068966, + 2.413793, + 2.758621, + 3.103448, + 3.448276, + 3.793103, + 4.137931, + 4.482759, + 4.827586, + 5.172414 + ], + "downbeats": [ + 0.0, + 1.37931, + 2.758621, + 4.137931 + ], + "percussion": { + "kickCount": 4, + "snareCount": 4, + "hihatCount": 32 + }, + "hitTimes": { + "kick": [ + 0.0, + 1.37931, + 2.758621, + 4.137931 + ], + "snare": [ + 0.689655, + 2.068966, + 3.448276, + 4.827586 + ], + "hihat": [ + 0.0, + 0.172414, + 0.344828, + 0.517241, + 0.689655, + 0.862069, + 1.034483, + 1.206897, + 1.37931, + 1.551724, + 1.724138, + 1.896552, + 2.068966, + 2.241379, + 2.413793, + 2.586207, + 2.758621, + 2.931034, + 3.103448, + 3.275862, + 3.448276, + 3.62069, + 3.793103, + 3.965517, + 4.137931, + 4.310345, + 4.482759, + 4.655172, + 4.827586, + 5.0, + 5.172414, + 5.344828 + ] + }, + "swingPercent": 50 + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "chords_C_major", + "audioPath": "synthetic/chords_C_major.wav", + "category": "chords", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 96, + "key": "C major", + "chordTimeline": [ + { + "startSec": 0.0, + "endSec": 2.5, + "label": "C" + }, + { + "startSec": 2.5, + "endSec": 5.0, + "label": "Am" + }, + { + "startSec": 5.0, + "endSec": 7.5, + "label": "F" + }, + { + "startSec": 7.5, + "endSec": 10.0, + "label": "G" + } + ] + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "chords_Dflat_minor", + "audioPath": "synthetic/chords_Dflat_minor.wav", + "category": "chords", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 100, + "key": "Db minor", + "chordTimeline": [ + { + "startSec": 0.0, + "endSec": 2.4, + "label": "Dbm" + }, + { + "startSec": 2.4, + "endSec": 4.8, + "label": "A" + }, + { + "startSec": 4.8, + "endSec": 7.2, + "label": "B" + }, + { + "startSec": 7.2, + "endSec": 9.6, + "label": "Abm" + } + ] + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "chords_D_major", + "audioPath": "synthetic/chords_D_major.wav", + "category": "chords", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 104, + "key": "D major", + "chordTimeline": [ + { + "startSec": 0.0, + "endSec": 2.307692, + "label": "D" + }, + { + "startSec": 2.307692, + "endSec": 4.615385, + "label": "Bm" + }, + { + "startSec": 4.615385, + "endSec": 6.923077, + "label": "G" + }, + { + "startSec": 6.923077, + "endSec": 9.230769, + "label": "A" + } + ] + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "chords_Eflat_minor", + "audioPath": "synthetic/chords_Eflat_minor.wav", + "category": "chords", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 108, + "key": "Eb minor", + "chordTimeline": [ + { + "startSec": 0.0, + "endSec": 2.222222, + "label": "Ebm" + }, + { + "startSec": 2.222222, + "endSec": 4.444444, + "label": "B" + }, + { + "startSec": 4.444444, + "endSec": 6.666667, + "label": "Db" + }, + { + "startSec": 6.666667, + "endSec": 8.888889, + "label": "Bbm" + } + ] + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "chords_E_major", + "audioPath": "synthetic/chords_E_major.wav", + "category": "chords", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 112, + "key": "E major", + "chordTimeline": [ + { + "startSec": 0.0, + "endSec": 2.142857, + "label": "E" + }, + { + "startSec": 2.142857, + "endSec": 4.285714, + "label": "C#m" + }, + { + "startSec": 4.285714, + "endSec": 6.428571, + "label": "A" + }, + { + "startSec": 6.428571, + "endSec": 8.571429, + "label": "B" + } + ] + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "chords_F_minor", + "audioPath": "synthetic/chords_F_minor.wav", + "category": "chords", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 116, + "key": "F minor", + "chordTimeline": [ + { + "startSec": 0.0, + "endSec": 2.068966, + "label": "Fm" + }, + { + "startSec": 2.068966, + "endSec": 4.137931, + "label": "Db" + }, + { + "startSec": 4.137931, + "endSec": 6.206897, + "label": "Eb" + }, + { + "startSec": 6.206897, + "endSec": 8.275862, + "label": "Cm" + } + ] + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "chords_Fsharp_major", + "audioPath": "synthetic/chords_Fsharp_major.wav", + "category": "chords", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 120, + "key": "F# major", + "chordTimeline": [ + { + "startSec": 0.0, + "endSec": 2.0, + "label": "F#" + }, + { + "startSec": 2.0, + "endSec": 4.0, + "label": "D#m" + }, + { + "startSec": 4.0, + "endSec": 6.0, + "label": "B" + }, + { + "startSec": 6.0, + "endSec": 8.0, + "label": "C#" + } + ] + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "chords_G_minor", + "audioPath": "synthetic/chords_G_minor.wav", + "category": "chords", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 124, + "key": "G minor", + "chordTimeline": [ + { + "startSec": 0.0, + "endSec": 1.935484, + "label": "Gm" + }, + { + "startSec": 1.935484, + "endSec": 3.870968, + "label": "Eb" + }, + { + "startSec": 3.870968, + "endSec": 5.806452, + "label": "F" + }, + { + "startSec": 5.806452, + "endSec": 7.741935, + "label": "Dm" + } + ] + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "chords_Aflat_major", + "audioPath": "synthetic/chords_Aflat_major.wav", + "category": "chords", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 128, + "key": "Ab major", + "chordTimeline": [ + { + "startSec": 0.0, + "endSec": 1.875, + "label": "Ab" + }, + { + "startSec": 1.875, + "endSec": 3.75, + "label": "Fm" + }, + { + "startSec": 3.75, + "endSec": 5.625, + "label": "Db" + }, + { + "startSec": 5.625, + "endSec": 7.5, + "label": "Eb" + } + ] + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "chords_A_minor", + "audioPath": "synthetic/chords_A_minor.wav", + "category": "chords", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 132, + "key": "A minor", + "chordTimeline": [ + { + "startSec": 0.0, + "endSec": 1.818182, + "label": "Am" + }, + { + "startSec": 1.818182, + "endSec": 3.636364, + "label": "F" + }, + { + "startSec": 3.636364, + "endSec": 5.454545, + "label": "G" + }, + { + "startSec": 5.454545, + "endSec": 7.272727, + "label": "Em" + } + ] + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "chords_Bflat_major", + "audioPath": "synthetic/chords_Bflat_major.wav", + "category": "chords", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 136, + "key": "Bb major", + "chordTimeline": [ + { + "startSec": 0.0, + "endSec": 1.764706, + "label": "Bb" + }, + { + "startSec": 1.764706, + "endSec": 3.529412, + "label": "Gm" + }, + { + "startSec": 3.529412, + "endSec": 5.294118, + "label": "Eb" + }, + { + "startSec": 5.294118, + "endSec": 7.058824, + "label": "F" + } + ] + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "chords_B_minor", + "audioPath": "synthetic/chords_B_minor.wav", + "category": "chords", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 140, + "key": "B minor", + "chordTimeline": [ + { + "startSec": 0.0, + "endSec": 1.714286, + "label": "Bm" + }, + { + "startSec": 1.714286, + "endSec": 3.428571, + "label": "G" + }, + { + "startSec": 3.428571, + "endSec": 5.142857, + "label": "A" + }, + { + "startSec": 5.142857, + "endSec": 6.857143, + "label": "F#m" + } + ] + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "swing_hats_50", + "audioPath": "synthetic/swing_hats_50.wav", + "category": "swing", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 124, + "timeSignature": "4/4", + "beatGrid": [ + 0.0, + 0.483871, + 0.967742, + 1.451613, + 1.935484, + 2.419355, + 2.903226, + 3.387097, + 3.870968, + 4.354839, + 4.83871, + 5.322581, + 5.806452, + 6.290323, + 6.774194, + 7.258065 + ], + "downbeats": [ + 0.0, + 1.935484, + 3.870968, + 5.806452 + ], + "percussion": { + "kickCount": 4, + "snareCount": 4, + "hihatCount": 32 + }, + "hitTimes": { + "kick": [ + 0.0, + 1.935484, + 3.870968, + 5.806452 + ], + "snare": [ + 0.967742, + 2.903226, + 4.83871, + 6.774194 + ], + "hihat": [ + 0.0, + 0.241935, + 0.483871, + 0.725806, + 0.967742, + 1.209677, + 1.451613, + 1.693548, + 1.935484, + 2.177419, + 2.419355, + 2.66129, + 2.903226, + 3.145161, + 3.387097, + 3.629032, + 3.870968, + 4.112903, + 4.354839, + 4.596774, + 4.83871, + 5.080645, + 5.322581, + 5.564516, + 5.806452, + 6.048387, + 6.290323, + 6.532258, + 6.774194, + 7.016129, + 7.258065, + 7.5 + ] + }, + "swingPercent": 50 + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "swing_hats_54", + "audioPath": "synthetic/swing_hats_54.wav", + "category": "swing", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 124, + "timeSignature": "4/4", + "beatGrid": [ + 0.0, + 0.483871, + 0.967742, + 1.451613, + 1.935484, + 2.419355, + 2.903226, + 3.387097, + 3.870968, + 4.354839, + 4.83871, + 5.322581, + 5.806452, + 6.290323, + 6.774194, + 7.258065 + ], + "downbeats": [ + 0.0, + 1.935484, + 3.870968, + 5.806452 + ], + "percussion": { + "kickCount": 4, + "snareCount": 4, + "hihatCount": 32 + }, + "hitTimes": { + "kick": [ + 0.0, + 1.935484, + 3.870968, + 5.806452 + ], + "snare": [ + 0.967742, + 2.903226, + 4.83871, + 6.774194 + ], + "hihat": [ + 0.0, + 0.26129, + 0.483871, + 0.745161, + 0.967742, + 1.229032, + 1.451613, + 1.712903, + 1.935484, + 2.196774, + 2.419355, + 2.680645, + 2.903226, + 3.164516, + 3.387097, + 3.648387, + 3.870968, + 4.132258, + 4.354839, + 4.616129, + 4.83871, + 5.1, + 5.322581, + 5.583871, + 5.806452, + 6.067742, + 6.290323, + 6.551613, + 6.774194, + 7.035484, + 7.258065, + 7.519355 + ] + }, + "swingPercent": 54 + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "swing_hats_58", + "audioPath": "synthetic/swing_hats_58.wav", + "category": "swing", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 124, + "timeSignature": "4/4", + "beatGrid": [ + 0.0, + 0.483871, + 0.967742, + 1.451613, + 1.935484, + 2.419355, + 2.903226, + 3.387097, + 3.870968, + 4.354839, + 4.83871, + 5.322581, + 5.806452, + 6.290323, + 6.774194, + 7.258065 + ], + "downbeats": [ + 0.0, + 1.935484, + 3.870968, + 5.806452 + ], + "percussion": { + "kickCount": 4, + "snareCount": 4, + "hihatCount": 32 + }, + "hitTimes": { + "kick": [ + 0.0, + 1.935484, + 3.870968, + 5.806452 + ], + "snare": [ + 0.967742, + 2.903226, + 4.83871, + 6.774194 + ], + "hihat": [ + 0.0, + 0.280645, + 0.483871, + 0.764516, + 0.967742, + 1.248387, + 1.451613, + 1.732258, + 1.935484, + 2.216129, + 2.419355, + 2.7, + 2.903226, + 3.183871, + 3.387097, + 3.667742, + 3.870968, + 4.151613, + 4.354839, + 4.635484, + 4.83871, + 5.119355, + 5.322581, + 5.603226, + 5.806452, + 6.087097, + 6.290323, + 6.570968, + 6.774194, + 7.054839, + 7.258065, + 7.53871 + ] + }, + "swingPercent": 58 + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "swing_hats_62", + "audioPath": "synthetic/swing_hats_62.wav", + "category": "swing", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 124, + "timeSignature": "4/4", + "beatGrid": [ + 0.0, + 0.483871, + 0.967742, + 1.451613, + 1.935484, + 2.419355, + 2.903226, + 3.387097, + 3.870968, + 4.354839, + 4.83871, + 5.322581, + 5.806452, + 6.290323, + 6.774194, + 7.258065 + ], + "downbeats": [ + 0.0, + 1.935484, + 3.870968, + 5.806452 + ], + "percussion": { + "kickCount": 4, + "snareCount": 4, + "hihatCount": 32 + }, + "hitTimes": { + "kick": [ + 0.0, + 1.935484, + 3.870968, + 5.806452 + ], + "snare": [ + 0.967742, + 2.903226, + 4.83871, + 6.774194 + ], + "hihat": [ + 0.0, + 0.3, + 0.483871, + 0.783871, + 0.967742, + 1.267742, + 1.451613, + 1.751613, + 1.935484, + 2.235484, + 2.419355, + 2.719355, + 2.903226, + 3.203226, + 3.387097, + 3.687097, + 3.870968, + 4.170968, + 4.354839, + 4.654839, + 4.83871, + 5.13871, + 5.322581, + 5.622581, + 5.806452, + 6.106452, + 6.290323, + 6.590323, + 6.774194, + 7.074194, + 7.258065, + 7.558065 + ] + }, + "swingPercent": 62 + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "swing_hats_66", + "audioPath": "synthetic/swing_hats_66.wav", + "category": "swing", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 124, + "timeSignature": "4/4", + "beatGrid": [ + 0.0, + 0.483871, + 0.967742, + 1.451613, + 1.935484, + 2.419355, + 2.903226, + 3.387097, + 3.870968, + 4.354839, + 4.83871, + 5.322581, + 5.806452, + 6.290323, + 6.774194, + 7.258065 + ], + "downbeats": [ + 0.0, + 1.935484, + 3.870968, + 5.806452 + ], + "percussion": { + "kickCount": 4, + "snareCount": 4, + "hihatCount": 32 + }, + "hitTimes": { + "kick": [ + 0.0, + 1.935484, + 3.870968, + 5.806452 + ], + "snare": [ + 0.967742, + 2.903226, + 4.83871, + 6.774194 + ], + "hihat": [ + 0.0, + 0.319355, + 0.483871, + 0.803226, + 0.967742, + 1.287097, + 1.451613, + 1.770968, + 1.935484, + 2.254839, + 2.419355, + 2.73871, + 2.903226, + 3.222581, + 3.387097, + 3.706452, + 3.870968, + 4.190323, + 4.354839, + 4.674194, + 4.83871, + 5.158065, + 5.322581, + 5.641935, + 5.806452, + 6.125806, + 6.290323, + 6.609677, + 6.774194, + 7.093548, + 7.258065, + 7.577419 + ] + }, + "swingPercent": 66 + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + } + }, + { + "id": "multi_A_minor", + "audioPath": "synthetic/multi_A_minor.wav", + "category": "multi", + "description": "Deterministic NumPy-rendered synthetic fundamentals fixture. Regenerate audio on demand with scripts/build_synthetic_corpus.py.", + "expected": { + "bpm": 128, + "key": "A minor", + "chordTimeline": [ + { + "startSec": 0.0, + "endSec": 1.875, + "label": "Am" + }, + { + "startSec": 1.875, + "endSec": 3.75, + "label": "F" + }, + { + "startSec": 3.75, + "endSec": 5.625, + "label": "G" + }, + { + "startSec": 5.625, + "endSec": 7.5, + "label": "Em" + } + ], + "timeSignature": "4/4", + "percussion": { + "kickCount": 16, + "snareCount": 4, + "hihatCount": 32 + }, + "beatGrid": [ + 0.0, + 0.46875, + 0.9375, + 1.40625, + 1.875, + 2.34375, + 2.8125, + 3.28125, + 3.75, + 4.21875, + 4.6875, + 5.15625, + 5.625, + 6.09375, + 6.5625, + 7.03125 + ], + "downbeats": [ + 0.0, + 1.875, + 3.75, + 5.625 + ] + }, + "thresholds": { + "bpmTolerance": 1.0, + "beatF1": 0.9, + "downbeatF1": 0.75, + "chordSegmentAccuracy": 0.65, + "percussionCountTolerance": 1, + "transcriptionNoteF1": 0.75 + }, + "analyzeFlags": [ + "--separate" + ] + } + ] +} diff --git a/apps/backend/tests/fixtures/fundamentals_tracks/README.md b/apps/backend/tests/fixtures/fundamentals_tracks/README.md index 5d597ba3..f78a72b7 100644 --- a/apps/backend/tests/fixtures/fundamentals_tracks/README.md +++ b/apps/backend/tests/fixtures/fundamentals_tracks/README.md @@ -1,9 +1,25 @@ # Fundamentals Benchmark Audio -Put owned, licensed, or locally rendered reference audio here to activate -`scripts/evaluate_fundamentals.py`. +Audio in this directory is generated on demand and is never committed. -The manifest is committed at `../fundamentals_eval_manifest.json`; audio files -in this directory are gitignored. When a listed file is absent, the harness -reports a skip. When it is present, the declared tempo, beat, meter, key, -chord, percussion, and transcription gates must pass. +Generate the default verify corpus with: + +```bash +./venv/bin/python scripts/build_synthetic_corpus.py \ + --out-dir tests/fixtures/fundamentals_tracks \ + --manifest tests/fixtures/fundamentals_eval_manifest.json \ + --check +``` + +Generate the expanded synthetic corpus with: + +```bash +./venv/bin/python scripts/build_synthetic_corpus.py \ + --out-dir tests/fixtures/fundamentals_tracks \ + --manifest tests/fixtures/fundamentals_eval_manifest.synthetic.json \ + --check +``` + +The committed manifests live one directory up. When a listed file is absent, +`scripts/evaluate_fundamentals.py` reports a skip; pass `--fail-on-skip` to make +missing audio fail the gate instead of producing a vacuous green run. diff --git a/apps/backend/tests/test_build_synthetic_corpus.py b/apps/backend/tests/test_build_synthetic_corpus.py new file mode 100644 index 00000000..752dcc10 --- /dev/null +++ b/apps/backend/tests/test_build_synthetic_corpus.py @@ -0,0 +1,72 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from fundamentals_evaluation import ( + _chord_segment_accuracy, + _normalize_chord_label_for_compare, +) +from scripts.build_synthetic_corpus import build_corpus, render_chord_progression, render_drum_pattern + + +class BuildSyntheticCorpusTests(unittest.TestCase): + def test_check_build_is_deterministic_and_manifest_has_expected_shape(self) -> None: + with tempfile.TemporaryDirectory(prefix="asa_synth_corpus_") as temp_dir: + root = Path(temp_dir) + manifest = root / "fundamentals_eval_manifest.synthetic.json" + + result = build_corpus(root / "tracks", manifest, check=True) + + self.assertEqual(result["tracks"], 28) + data = json.loads(manifest.read_text(encoding="utf-8")) + self.assertEqual(data["schemaVersion"], "fundamentals-eval.v1") + self.assertEqual(data["targetProfile"], "electronic_ableton_v1") + self.assertEqual(len(data["tracks"]), 28) + first = data["tracks"][0] + self.assertIn("id", first) + self.assertIn("audioPath", first) + self.assertIn("expected", first) + self.assertTrue((root / "tracks" / first["audioPath"]).exists()) + + + def test_audio_only_does_not_rewrite_manifest(self) -> None: + with tempfile.TemporaryDirectory(prefix="asa_synth_audio_only_") as temp_dir: + root = Path(temp_dir) + manifest = root / "manifest.json" + manifest.write_text("sentinel", encoding="utf-8") + + result = build_corpus(root / "tracks", manifest, check=True, write_manifest=False) + + self.assertEqual(result["tracks"], 4) + self.assertIsNone(result["manifest"]) + self.assertEqual(manifest.read_text(encoding="utf-8"), "sentinel") + self.assertTrue((root / "tracks" / "four_on_floor_clear_128.wav").exists()) + + def test_drum_truth_counts_and_grid_match_requested_pattern(self) -> None: + rendered = render_drum_pattern( + 120, + "4/4", + 2, + kick_positions=[0, 4], + snare_positions=[2, 6], + hat_positions=[index * 0.5 for index in range(16)], + swing_percent=58, + ) + + self.assertEqual(rendered.truth["percussion"], {"kickCount": 2, "snareCount": 2, "hihatCount": 16}) + self.assertEqual(len(rendered.truth["beatGrid"]), 8) + self.assertEqual(rendered.truth["downbeats"], [0.0, 2.0]) + self.assertEqual(rendered.truth["swingPercent"], 58) + + def test_chord_labels_survive_segment_accuracy_compare(self) -> None: + rendered = render_chord_progression("A", "minor", [1, 6, 7, 5], 120, 4.0) + expected = rendered.truth["chordTimeline"] + + self.assertEqual([segment["label"] for segment in expected], ["Am", "F", "G", "Em"]) + self.assertEqual(_normalize_chord_label_for_compare("A minor"), "Am") + self.assertEqual(_chord_segment_accuracy(expected, expected), 1.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/backend/tests/test_fundamentals_evaluation.py b/apps/backend/tests/test_fundamentals_evaluation.py index cef34f97..ad01c2ef 100644 --- a/apps/backend/tests/test_fundamentals_evaluation.py +++ b/apps/backend/tests/test_fundamentals_evaluation.py @@ -27,6 +27,24 @@ def test_default_manifest_skips_missing_local_audio(self) -> None: self.assertGreaterEqual(report["summary"]["tracksSkipped"], 1) self.assertTrue(report_path.exists()) + + def test_fail_on_skip_marks_missing_audio_report_failed(self) -> None: + with tempfile.TemporaryDirectory(prefix="asa_fundamentals_eval_fail_skip_") as temp_dir: + report_path = Path(temp_dir) / "fundamentals_report.json" + tracks_dir = Path(temp_dir) / "empty_tracks" + tracks_dir.mkdir() + + report = run_fundamentals_evaluation( + manifest_path=DEFAULT_MANIFEST_PATH, + tracks_dir=tracks_dir, + report_path=report_path, + fail_on_skip=True, + ) + + self.assertFalse(report["summary"]["allPassed"]) + self.assertTrue(report["summary"]["failOnSkip"]) + self.assertGreaterEqual(report["summary"]["tracksSkipped"], 1) + def test_present_track_enforces_declared_fundamental_gates(self) -> None: with tempfile.TemporaryDirectory(prefix="asa_fundamentals_eval_present_") as temp_dir: temp_root = Path(temp_dir) diff --git a/bin/asa b/bin/asa index 2f9bdce3..5ae46389 100755 --- a/bin/asa +++ b/bin/asa @@ -296,9 +296,13 @@ cmd_verify() { echo "== Backend unittest discover ==" # Must run from apps/backend: test modules do `import server` and rely on CWD. ( cd "$BACKEND_DIR" && ./venv/bin/python -m unittest discover -s tests ) || be=$? + if [[ "$be" -eq 0 ]]; then + echo "== Backend synthetic fundamentals corpus ==" + ( cd "$BACKEND_DIR" && ./venv/bin/python scripts/build_synthetic_corpus.py --out-dir tests/fixtures/fundamentals_tracks --manifest tests/fixtures/fundamentals_eval_manifest.json --check --audio-only ) || be=$? + fi if [[ "$be" -eq 0 ]]; then echo "== Backend fundamentals evaluation ==" - ( cd "$BACKEND_DIR" && ./venv/bin/python scripts/evaluate_fundamentals.py ) || be=$? + ( cd "$BACKEND_DIR" && ./venv/bin/python scripts/evaluate_fundamentals.py --fail-on-skip ) || be=$? fi fi