Files
STSheets/src/eval/score.ts
T
2026-10-30 15:00:55 +01:00

129 lines
6.1 KiB
TypeScript

import { secondsToTicks } from "../core/time.ts";
import type { Grid, Role, ScoreNote, TimedNote, TruthTrack } from "../core/types.ts";
import { matchNotes, maxAssignment, prf, type MatchResult } from "./notes.ts";
import { truthFromGp } from "../score/gp.ts";
import { alignGrid, rhythmScore } from "./rhythm.ts";
export interface OracleFlags { oracleStems: boolean; oracleTempo: boolean; oracleNotes: boolean }
export interface OutTrack { name: string; role: Role; notes: TimedNote[]; scoreNotes: ScoreNote[] }
export interface PipelineOutput { grid: Grid; tracks: OutTrack[] }
export type DrumClass = "kick" | "snare" | "hihat" | "cymbal" | "tom";
export interface SongScore {
flags: OracleFlags;
guitarMerged: MatchResult & { pass: boolean };
partSplit: { f1: number | null; outTracks: number; refTracks: number } | null;
perRole: Partial<Record<Role, MatchResult>>;
drumsPerClass: Record<DrumClass, MatchResult> | null;
rhythm: { factor: number; beatOffset: number; onBeat: number | null; durationOk: number | null } | null;
stringAgreement: number | null;
spuriousRate: number | null;
/** Merged-guitar pitch match computed from the re-imported output GP file (null without outGp). */
gpPitch: MatchResult | null;
/** Truth vs output-GP bar count and time signature (null without outGp). */
barAgreement: { refBars: number; outBars: number; timeSigMatch: boolean } | null;
}
export const ROLE_ORDER: Role[] = ["guitar", "vocals", "bass", "drums", "keys"];
export const DRUM_CLASSES: Record<DrumClass, number[]> = {
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],
};
const PASS_F1 = 0.85;
export function drumClass(pitch: number): DrumClass | null {
for (const [c, ps] of Object.entries(DRUM_CLASSES)) if (ps.includes(pitch)) return c as DrumClass;
return null;
}
// Drum pitches are compared by class: map to the class's first GM number; unknown pitches stay distinct.
export const toClassPitch = (n: TimedNote): TimedNote => {
const c = drumClass(n.pitch);
return { ...n, pitch: c ? DRUM_CLASSES[c][0]! : 1000 + n.pitch };
};
const notesOf = (tracks: { role: Role; notes: TimedNote[] }[], role: Role) =>
tracks.filter((t) => t.role === role).flatMap((t) => t.notes);
export function scoreSong(
truth: { bpm: number; timeSig: [number, number]; bars?: number; tracks: TruthTrack[] },
out: PipelineOutput, flags: OracleFlags, outGp?: Uint8Array,
): SongScore {
const refGuitar = notesOf(truth.tracks, "guitar");
const outGuitar = notesOf(out.tracks, "guitar");
const gm = matchNotes(refGuitar, outGuitar);
const guitarMerged = { ...gm, pass: gm.f1 !== null && gm.f1 >= PASS_F1 };
// partSplit (R3): null only with <2 ref guitar tracks
const refG = truth.tracks.filter((t) => t.role === "guitar");
const outG = out.tracks.filter((t) => t.role === "guitar");
let partSplit: SongScore["partSplit"] = null;
if (refG.length >= 2) {
const gain = refG.map((r) => outG.map((o) => matchNotes(r.notes, o.notes).tp));
const asg = outG.length ? maxAssignment(gain) : refG.map(() => -1);
let tp = 0;
asg.forEach((j, i) => { if (j >= 0) tp += gain[i]![j]!; });
const fn = refG.reduce((s, t) => s + t.notes.length, 0) - tp;
const fp = outG.reduce((s, t) => s + t.notes.length, 0) - tp;
partSplit = { f1: prf(tp, fp, fn).f1, outTracks: outG.length, refTracks: refG.length };
}
// per role (drums compared by class)
const perRole: Partial<Record<Role, MatchResult>> = {};
for (const role of ROLE_ORDER) {
if (role === "guitar") { perRole.guitar = gm; continue; }
let r = notesOf(truth.tracks, role), e = notesOf(out.tracks, role);
if (role === "drums") { r = r.map(toClassPitch); e = e.map(toClassPitch); }
perRole[role] = matchNotes(r, e);
}
let drumsPerClass: SongScore["drumsPerClass"] = null;
const refDrums = notesOf(truth.tracks, "drums"), outDrums = notesOf(out.tracks, "drums");
if (refDrums.length + outDrums.length > 0) {
drumsPerClass = {} as Record<DrumClass, MatchResult>;
for (const c of Object.keys(DRUM_CLASSES) as DrumClass[]) {
drumsPerClass[c] = matchNotes(
refDrums.filter((n) => drumClass(n.pitch) === c).map(toClassPitch),
outDrums.filter((n) => drumClass(n.pitch) === c).map(toClassPitch),
);
}
}
// rhythm on merged guitar (R2: truth seconds -> ticks)
let rhythm: SongScore["rhythm"] = null;
const outGuitarScore = outG.flatMap((t) => t.scoreNotes);
if (refGuitar.length > 0 && outGuitarScore.length > 0) {
const refScore: ScoreNote[] = refGuitar.map((n) => ({
tick: Math.round(secondsToTicks(n.onset, truth.bpm)),
dur: Math.round(secondsToTicks(n.offset - n.onset, truth.bpm)),
pitch: n.pitch, string: n.string, fret: n.fret,
}));
const align = alignGrid(refScore, outGuitarScore);
rhythm = { factor: align.factor, beatOffset: align.beatOffset, ...rhythmScore(refScore, outGuitarScore, align) };
}
let withString = 0, agree = 0;
for (const [i, j] of gm.matches) {
const a = refGuitar[i]!.string, b = outGuitar[j]!.string;
if (a !== undefined && b !== undefined) { withString++; if (a === b) agree++; }
}
let gpPitch: SongScore["gpPitch"] = null;
let barAgreement: SongScore["barAgreement"] = null;
if (outGp) {
const re = truthFromGp(outGp);
const shift = out.grid.firstDownbeat; // the GP's time zero is the grid's first downbeat
const gpGuitar = notesOf(re.tracks, "guitar").map((n) => ({ ...n, onset: n.onset + shift, offset: n.offset + shift }));
gpPitch = matchNotes(refGuitar, gpGuitar);
const barSec = (60 / truth.bpm) * truth.timeSig[0] * (4 / truth.timeSig[1]);
const lastEnd = Math.max(0, ...truth.tracks.flatMap((t) => t.notes.map((n) => n.offset)));
const refBars = truth.bars ?? Math.ceil(lastEnd / barSec - 1e-9);
barAgreement = { refBars, outBars: re.bars, timeSigMatch: truth.timeSig[0] === re.timeSig[0] && truth.timeSig[1] === re.timeSig[1] };
}
return {
flags, guitarMerged, partSplit, perRole, drumsPerClass, rhythm,
stringAgreement: withString ? agree / withString : null,
spuriousRate: outGuitar.length ? gm.fp / outGuitar.length : null,
gpPitch, barAgreement,
};
}