Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
382 changes: 381 additions & 1 deletion apps/backend/phase1_evaluation.py

Large diffs are not rendered by default.

17 changes: 12 additions & 5 deletions apps/backend/polyphonic_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,17 +100,27 @@ def summarize_midi_file(midi_path: Path, audio_duration_seconds: float) -> dict[
duration_denominator = max(float(audio_duration_seconds), 1e-9)
note_count = len(notes)
note_density = note_count / duration_denominator
distinct_pitch_count = len(set(pitch_values))
mean_active_polyphony = weighted_active_polyphony / max(active_time, 1e-9)
flags: list[str] = []
if max_polyphony <= 1:
flags.append("monophonic_output")
if note_density >= 12.0:
flags.append("high_note_density")
if note_density > 15.0:
flags.append("note_clutter")
if distinct_pitch_count > 30 and mean_active_polyphony < 3.0:
flags.append("octave_junk")
if max_polyphony > 8:
flags.append("dense_chords_unusable")
if note_density < 1.0 and float(audio_duration_seconds) >= 10.0:
flags.append("sparse_likely_undertranscribed")

min_pitch = min(pitch_values)
max_pitch = max(pitch_values)
return {
"noteCount": note_count,
"distinctPitchCount": len(set(pitch_values)),
"distinctPitchCount": distinct_pitch_count,
"pitchRange": {
"minMidi": min_pitch,
"maxMidi": max_pitch,
Expand All @@ -119,10 +129,7 @@ def summarize_midi_file(midi_path: Path, audio_duration_seconds: float) -> dict[
},
"maxPolyphony": max_polyphony,
"meanTimelinePolyphony": round(weighted_timeline_polyphony / duration_denominator, 4),
"meanActivePolyphony": round(
weighted_active_polyphony / max(active_time, 1e-9),
4,
),
"meanActivePolyphony": round(mean_active_polyphony, 4),
"averageNoteDurationSeconds": round(total_note_duration / note_count, 4),
"noteDensityPerSecond": round(note_density, 4),
"flags": flags,
Expand Down
36 changes: 35 additions & 1 deletion apps/backend/scripts/evaluate_phase1.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
DEFAULT_BENCH_TRACKS_DIR,
DEFAULT_MANIFEST_PATH,
DEFAULT_REPORT_PATH,
DEFAULT_TRANSCRIPTION_TRACKS_DIR,
run_phase1_evaluation,
)
from phase1_report_html import default_html_report_path, render_html_report
Expand Down Expand Up @@ -66,6 +67,25 @@ def parse_args() -> argparse.Namespace:
f"(default: {DEFAULT_BENCH_TRACKS_DIR})"
),
)
parser.add_argument(
"--include-transcription",
action="store_true",
help=(
"Opt in to evaluating Layer 2 (torchcrepe) transcription against "
"manifest.transcriptionTracks. Always runs the stepped-sine self-"
"test even when the corpus is empty. Missing audio files are "
"skipped with a clear notice rather than failing the run."
),
)
parser.add_argument(
"--transcription-tracks-dir",
type=Path,
default=DEFAULT_TRANSCRIPTION_TRACKS_DIR,
help=(
f"Directory holding local transcription reference tracks "
f"(default: {DEFAULT_TRANSCRIPTION_TRACKS_DIR})"
),
)
parser.add_argument(
"--html-report",
type=Path,
Expand All @@ -89,16 +109,30 @@ def main() -> None:
runs_per_fixture=max(args.runs, 1),
include_real=args.include_real,
real_tracks_dir=args.real_tracks_dir,
include_transcription=args.include_transcription,
transcription_tracks_dir=args.transcription_tracks_dir,
)
summary_line: dict[str, Any] = {
"summary": report["summary"],
"reportPath": report["reportPath"],
}
notes: list[str] = []
if args.include_real and report["summary"]["realTracksSkipped"] > 0:
summary_line["note"] = (
notes.append(
"Some real tracks were skipped because audio files were not present "
f"in {args.real_tracks_dir}. See report for per-track skipReason."
)
if (
args.include_transcription
and report["summary"]["transcriptionTracksSkipped"] > 0
):
notes.append(
"Some transcription tracks were skipped because audio files were "
f"not present in {args.transcription_tracks_dir}. See report for "
"per-track skipReason."
)
if notes:
summary_line["note"] = " ".join(notes)

if args.html_report is not None:
if isinstance(args.html_report, Path):
Expand Down
134 changes: 134 additions & 0 deletions apps/backend/scripts/import_midi_to_ground_truth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Convert a reference MIDI file into a `groundTruthNotes` JSON fragment.

Used to bootstrap entries in `tests/fixtures/phase1_eval_manifest.json` under
`transcriptionTracks[].groundTruthNotes`. Emits a JSON array to stdout so the
output can be pasted directly into the manifest.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

import pretty_midi


def _flatten_notes(midi: pretty_midi.PrettyMIDI, offset_seconds: float) -> list[dict]:
notes: list[dict] = []
for instrument in midi.instruments:
for note in instrument.notes:
start = float(note.start) + offset_seconds
end = float(note.end) + offset_seconds
duration = max(0.0, end - start)
notes.append(
{
"pitchMidi": int(note.pitch),
"onsetSeconds": round(start, 4),
"durationSeconds": round(duration, 4),
}
)
notes.sort(key=lambda entry: (entry["onsetSeconds"], entry["pitchMidi"]))
return notes


def _detect_overlaps(notes: list[dict]) -> list[tuple[int, int]]:
overlaps: list[tuple[int, int]] = []
sorted_indices = sorted(range(len(notes)), key=lambda i: notes[i]["onsetSeconds"])
for i_pos in range(len(sorted_indices)):
i = sorted_indices[i_pos]
i_end = notes[i]["onsetSeconds"] + notes[i]["durationSeconds"]
for j_pos in range(i_pos + 1, len(sorted_indices)):
j = sorted_indices[j_pos]
if notes[j]["onsetSeconds"] >= i_end:
break
overlaps.append((i, j))
return overlaps


def _collapse_monophonic_highest(notes: list[dict]) -> list[dict]:
if len(notes) == 0:
return notes
indices_by_onset = sorted(range(len(notes)), key=lambda i: notes[i]["onsetSeconds"])
keep: set[int] = set(indices_by_onset)
for pos_i in range(len(indices_by_onset)):
i = indices_by_onset[pos_i]
if i not in keep:
continue
i_end = notes[i]["onsetSeconds"] + notes[i]["durationSeconds"]
for pos_j in range(pos_i + 1, len(indices_by_onset)):
j = indices_by_onset[pos_j]
if j not in keep:
continue
if notes[j]["onsetSeconds"] >= i_end:
break
if notes[j]["pitchMidi"] > notes[i]["pitchMidi"]:
keep.discard(i)
break
keep.discard(j)
return [notes[i] for i in sorted(keep, key=lambda i: notes[i]["onsetSeconds"])]


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Convert a MIDI file into the groundTruthNotes JSON fragment for a "
"transcriptionTracks manifest entry."
)
)
parser.add_argument("midi_path", type=Path, help="Path to the .mid file to convert.")
parser.add_argument(
"--monophonic-collapse",
choices=("highest", "reject"),
default="reject",
help=(
"Behavior when notes overlap. 'reject' (default) exits non-zero on "
"any overlap. 'highest' keeps the highest pitch in any overlapping "
"group and drops the rest."
),
)
parser.add_argument(
"--offset-seconds",
type=float,
default=0.0,
help=(
"Add N seconds to every onset. Use when DAW MIDI export starts at a "
"different point than the audio (e.g. negative track delay)."
),
)
return parser.parse_args()


def main() -> None:
args = parse_args()
if not args.midi_path.exists():
print(f"error: MIDI file not found at {args.midi_path}", file=sys.stderr)
raise SystemExit(2)

try:
midi = pretty_midi.PrettyMIDI(str(args.midi_path))
except Exception as exc:
print(f"error: failed to parse MIDI: {exc}", file=sys.stderr)
raise SystemExit(2) from exc

notes = _flatten_notes(midi, args.offset_seconds)
overlaps = _detect_overlaps(notes)

if len(overlaps) > 0:
if args.monophonic_collapse == "reject":
print(
f"error: {len(overlaps)} overlapping note pair(s) detected. "
"Pass --monophonic-collapse highest to keep the higher pitch in "
"each overlapping group.",
file=sys.stderr,
)
raise SystemExit(2)
notes = _collapse_monophonic_highest(notes)

print(json.dumps(notes, indent=2))


if __name__ == "__main__":
main()
Loading