fix: final-review findings (median pass tick, R11 secondary metrics, R12 chord duration, render version, dsp resample)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
2b7d5ffbeb
commit
b631bd2c7e
+1
-26
@@ -68,30 +68,5 @@ export async function writeWav(path: string, a: Audio): Promise<void> {
|
||||
}
|
||||
|
||||
export { toMono } from "../dsp/mono.ts";
|
||||
export { resample } from "../dsp/resample.ts";
|
||||
|
||||
const sinc = (x: number) => (x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x));
|
||||
|
||||
/** Windowed-sinc (Blackman) resampler; low-passes at the lower Nyquist when downsampling. */
|
||||
export function resample(x: Float32Array, from: number, to: number): Float32Array {
|
||||
if (from === to) return x.slice();
|
||||
const ratio = to / from;
|
||||
const outLen = Math.round(x.length * ratio);
|
||||
const out = new Float32Array(outLen);
|
||||
const cutoff = Math.min(1, ratio); // in units of input Nyquist
|
||||
const half = 16 / cutoff; // kernel half-width in input samples (16 zero-crossings)
|
||||
for (let j = 0; j < outLen; j++) {
|
||||
const t = j / ratio;
|
||||
const lo = Math.max(0, Math.ceil(t - half)), hi = Math.min(x.length - 1, Math.floor(t + half));
|
||||
let acc = 0, wsum = 0;
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
const d = i - t;
|
||||
const w = 0.42 + 0.5 * Math.cos((Math.PI * d) / half) + 0.08 * Math.cos((2 * Math.PI * d) / half);
|
||||
const k = cutoff * sinc(cutoff * d) * w;
|
||||
acc += x[i]! * k;
|
||||
wsum += k;
|
||||
}
|
||||
// Normalise to unity DC gain so edges and non-integer ratios stay calibrated.
|
||||
out[j] = wsum !== 0 ? acc / wsum : 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+12
-2
@@ -14,7 +14,14 @@ const SF2_PATH = join(import.meta.dir, "../../node_modules/@coderline/alphatab/d
|
||||
const SR = 44100;
|
||||
|
||||
/**
|
||||
* Build one song into `<root>/<tier>/<seed>/`. Idempotent: skipped when mix.mp3 exists and song.json matches.
|
||||
* Bump whenever the rendered audio could change for the same song.json: i.e. any change to
|
||||
* src/audio/render.ts, fx.ts, mix.ts, the SoundFont, or the alphaTab version. Stored in <dir>/render-version
|
||||
* (written last, with song.json); buildSong rebuilds a dir whose stored version differs or is missing.
|
||||
*/
|
||||
export const RENDER_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Build one song into `<root>/<tier>/<seed>/`. Idempotent: skipped when mix.mp3 exists, song.json matches and render-version equals RENDER_VERSION.
|
||||
* stems/<track>.wav are exactly each track's contribution to mix.wav (post-fx, post-pan, gain, normalisation),
|
||||
* so the stems sum to the mix up to PCM16 rounding.
|
||||
*/
|
||||
@@ -23,7 +30,9 @@ export async function buildSong(tier: TierId, seed: number, root = "data"): Prom
|
||||
const song = generateSong(tier, seed);
|
||||
const songJson = JSON.stringify(song, null, 1);
|
||||
const jsonFile = Bun.file(join(dir, "song.json"));
|
||||
if ((await Bun.file(join(dir, "mix.mp3")).exists()) && (await jsonFile.exists()) && (await jsonFile.text()) === songJson) {
|
||||
const verFile = Bun.file(join(dir, "render-version"));
|
||||
if ((await Bun.file(join(dir, "mix.mp3")).exists()) && (await jsonFile.exists()) && (await jsonFile.text()) === songJson
|
||||
&& (await verFile.exists()) && (await verFile.text()).trim() === String(RENDER_VERSION)) {
|
||||
return dir;
|
||||
}
|
||||
await mkdir(join(dir, "stems"), { recursive: true });
|
||||
@@ -39,6 +48,7 @@ export async function buildSong(tier: TierId, seed: number, root = "data"): Prom
|
||||
for (let i = 0; i < song.tracks.length; i++) await writeWav(join(dir, "stems", `${song.tracks[i]!.name}.wav`), scaled[i]!);
|
||||
await writeWav(join(dir, "mix.wav"), mix);
|
||||
await encodeMp3(join(dir, "mix.wav"), join(dir, "mix.mp3"));
|
||||
await Bun.write(join(dir, "render-version"), String(RENDER_VERSION));
|
||||
await Bun.write(join(dir, "song.json"), songJson); // written last: marks the build complete
|
||||
return dir;
|
||||
}
|
||||
|
||||
+17
-5
@@ -13,6 +13,7 @@ export interface BenchRun {
|
||||
|
||||
const PASS_F1 = 0.85;
|
||||
const NA = "n/a";
|
||||
const TECHNIQUES_NA = "n/a (tier 1 has no technique marks)";
|
||||
|
||||
export function median(xs: (number | null | undefined)[]): number | null {
|
||||
const v = xs.filter((x): x is number => x != null && !Number.isNaN(x)).sort((a, b) => a - b);
|
||||
@@ -53,6 +54,9 @@ const COLS: Col[] = [
|
||||
{ head: "Tempo factor", get: (s) => s.rhythm?.factor ?? null },
|
||||
{ head: "Beat offset", get: (s) => s.rhythm?.beatOffset ?? null, d: 0 },
|
||||
{ head: "String agreement", get: (s) => s.stringAgreement },
|
||||
{ head: "GP-file guitar F1", get: (s) => f1OrNA(s.gpPitch) },
|
||||
{ head: "Bar count match", get: (s) => (s.barAgreement ? Number(s.barAgreement.refBars === s.barAgreement.outBars) : null), d: 0 },
|
||||
{ head: "Time sig match", get: (s) => (s.barAgreement ? Number(s.barAgreement.timeSigMatch) : null), d: 0 },
|
||||
];
|
||||
|
||||
const withSpur = (cell: string, c: Col, scores: SongScore[]) => {
|
||||
@@ -82,11 +86,14 @@ export function renderReport(run: BenchRun): string {
|
||||
out.push(`## Tier ${tier}`, "");
|
||||
const rows = run.modes.filter((m) => rs.some((r) => r.mode === m.name)).map((m) => {
|
||||
const ms = rs.filter((r) => r.mode === m.name);
|
||||
const guitarCell = `${stat(ms.map((r) => r.score.guitarMerged.f1))} ${ms.every((r) => r.score.guitarMerged.pass) ? "✓" : "✗"}`;
|
||||
const f1s = ms.map((r) => r.score.guitarMerged.f1);
|
||||
const medF1 = median(f1s);
|
||||
const nPass = ms.filter((r) => r.score.guitarMerged.pass).length;
|
||||
const guitarCell = `${stat(f1s)} ${medF1 != null && medF1 >= PASS_F1 ? "✓" : "✗"} ${nPass}/${ms.length}`;
|
||||
return [m.name, String(ms.length), guitarCell,
|
||||
...COLS.slice(1).map((c) => withSpur(stat(ms.map((r) => c.get(r.score)), c.d ?? 2), c, ms.map((r) => r.score)))];
|
||||
...COLS.slice(1).map((c) => withSpur(stat(ms.map((r) => c.get(r.score)), c.d ?? 2), c, ms.map((r) => r.score))), TECHNIQUES_NA];
|
||||
});
|
||||
out.push(table(["Mode", "Songs", ...COLS.map((c) => c.head)], rows), "");
|
||||
out.push(table(["Mode", "Songs", ...COLS.map((c) => c.head), "Techniques"], rows), "");
|
||||
const full = rs.filter((r) => r.mode === "full");
|
||||
const med = median(full.map((r) => r.score.guitarMerged.f1));
|
||||
out.push(med == null
|
||||
@@ -96,8 +103,13 @@ export function renderReport(run: BenchRun): string {
|
||||
|
||||
out.push("## Per-song appendix", "");
|
||||
const sorted = [...run.results].sort((a, b) => a.tier.localeCompare(b.tier) || a.seed - b.seed || a.mode.localeCompare(b.mode));
|
||||
out.push(table(["Tier", "Seed", "Mode", "Guitar pass", ...COLS.map((c) => c.head), "Seconds"],
|
||||
out.push(table(["Tier", "Seed", "Mode", "Guitar pass", ...COLS.map((c) => c.head), "Techniques", "Seconds"],
|
||||
sorted.map((r) => [r.tier, String(r.seed), r.mode, r.score.guitarMerged.pass ? "✓" : "✗",
|
||||
...COLS.map((c) => withSpur(fmt(c.get(r.score), c.d ?? 2), c, [r.score])), r.seconds.toFixed(1)])), "");
|
||||
...COLS.map((c) => withSpur(fmt(c.get(r.score), c.d ?? 2), c, [r.score])), TECHNIQUES_NA, r.seconds.toFixed(1)])), "");
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** Serialisable copy of a run for results/<id>.json: per-note `matches` arrays are dropped (they stay in runs/.../score.json). */
|
||||
export function resultsJson(run: BenchRun): string {
|
||||
return JSON.stringify(run, (k, v) => (k === "matches" ? undefined : v), 1);
|
||||
}
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ import type { TierId } from "../gen/tiers.ts";
|
||||
import { buildSong, loadSongDir } from "./dataset.ts";
|
||||
import { createPipeline } from "../pipeline/run.ts";
|
||||
import { scoreSong, type OracleFlags } from "../eval/score.ts";
|
||||
import { renderReport, type BenchRun } from "./report.ts";
|
||||
import { renderReport, resultsJson, type BenchRun } from "./report.ts";
|
||||
|
||||
export const MODES: Record<string, OracleFlags> = {
|
||||
"full": { oracleStems: false, oracleTempo: false, oracleNotes: false },
|
||||
@@ -49,7 +49,7 @@ export async function runBench(opts: {
|
||||
const t0 = performance.now();
|
||||
const res = await pipeline({ mix, oracle: { stems, truth } }, mode.flags);
|
||||
const seconds = (performance.now() - t0) / 1000;
|
||||
const score = scoreSong(truth, res.output, mode.flags);
|
||||
const score = scoreSong(truth, res.output, mode.flags, res.gp);
|
||||
const d = join(outDir, mode.name);
|
||||
await mkdir(d, { recursive: true });
|
||||
await Bun.write(join(d, "notes.json"), JSON.stringify({
|
||||
@@ -63,7 +63,7 @@ export async function runBench(opts: {
|
||||
}
|
||||
const rdir = opts.resultsDir ?? "results";
|
||||
await mkdir(rdir, { recursive: true });
|
||||
await Bun.write(join(rdir, `${opts.runId}.json`), JSON.stringify(run, null, 1));
|
||||
await Bun.write(join(rdir, `${opts.runId}.json`), resultsJson(run));
|
||||
await Bun.write(join(rdir, `${opts.runId}.md`), renderReport(run));
|
||||
return run;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Pure windowed-sinc resampler. Re-exported from src/audio/wav.ts for existing importers.
|
||||
|
||||
const sinc = (x: number) => (x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x));
|
||||
|
||||
/** Windowed-sinc (Blackman) resampler; low-passes at the lower Nyquist when downsampling. */
|
||||
export function resample(x: Float32Array, from: number, to: number): Float32Array {
|
||||
if (from === to) return x.slice();
|
||||
const ratio = to / from;
|
||||
const outLen = Math.round(x.length * ratio);
|
||||
const out = new Float32Array(outLen);
|
||||
const cutoff = Math.min(1, ratio); // in units of input Nyquist
|
||||
const half = 16 / cutoff; // kernel half-width in input samples (16 zero-crossings)
|
||||
for (let j = 0; j < outLen; j++) {
|
||||
const t = j / ratio;
|
||||
const lo = Math.max(0, Math.ceil(t - half)), hi = Math.min(x.length - 1, Math.floor(t + half));
|
||||
let acc = 0, wsum = 0;
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
const d = i - t;
|
||||
const w = 0.42 + 0.5 * Math.cos((Math.PI * d) / half) + 0.08 * Math.cos((2 * Math.PI * d) / half);
|
||||
const k = cutoff * sinc(cutoff * d) * w;
|
||||
acc += x[i]! * k;
|
||||
wsum += k;
|
||||
}
|
||||
// Normalise to unity DC gain so edges and non-integer ratios stay calibrated.
|
||||
out[j] = wsum !== 0 ? acc / wsum : 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+21
-2
@@ -1,6 +1,7 @@
|
||||
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 }
|
||||
@@ -16,6 +17,10 @@ export interface SongScore {
|
||||
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"];
|
||||
@@ -39,8 +44,8 @@ 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]; tracks: TruthTrack[] },
|
||||
out: PipelineOutput, flags: OracleFlags,
|
||||
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");
|
||||
@@ -101,9 +106,23 @@ export function scoreSong(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function snapTicks(notes: TimedNote[], grid: Grid, stepsPerBeat = 4): num
|
||||
/**
|
||||
* Snap timed notes to a step grid (default 16ths) relative to grid.firstDownbeat.
|
||||
* Notes starting within 35 ms of a cluster's first onset share one tick; every note in a
|
||||
* tick group gets the group's shortest duration (clipping to the next onset is left to the score writer); exact
|
||||
* tick group gets the group's median duration (R12: one short ghost note cannot collapse a chord) (clipping to the next onset is left to the score writer); exact
|
||||
* duplicates (same tick + pitch) are dropped; dur is always >= 1 step.
|
||||
*/
|
||||
export function quantize(notes: TimedNote[], grid: Grid, stepsPerBeat = 4): ScoreNote[] {
|
||||
@@ -50,7 +50,7 @@ export function quantize(notes: TimedNote[], grid: Grid, stepsPerBeat = 4): Scor
|
||||
qs.push({ step: s0, durSteps: Math.max(1, s1 - s0), pitch: n.pitch });
|
||||
});
|
||||
|
||||
// Group by step; dedupe by pitch; chord duration = shortest.
|
||||
// Group by step; dedupe by pitch; chord duration = median.
|
||||
const groups = new Map<number, Q[]>();
|
||||
for (const q of qs) {
|
||||
const g = groups.get(q.step);
|
||||
@@ -61,8 +61,9 @@ export function quantize(notes: TimedNote[], grid: Grid, stepsPerBeat = 4): Scor
|
||||
const out: ScoreNote[] = [];
|
||||
steps.forEach((s) => {
|
||||
const g = groups.get(s)!;
|
||||
let d = Math.min(...g.map((x) => x.durSteps));
|
||||
d = Math.max(1, d);
|
||||
const ds = g.map((x) => x.durSteps).sort((a, b) => a - b);
|
||||
const m = ds.length >> 1;
|
||||
const d = Math.max(1, ds.length % 2 ? ds[m]! : Math.round((ds[m - 1]! + ds[m]!) / 2));
|
||||
for (const x of g.sort((a, b) => a.pitch - b.pitch)) out.push({ tick: s * step, dur: d * step, pitch: x.pitch });
|
||||
});
|
||||
return out;
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// Pure: no fs / Bun APIs; only ORT APIs that also exist in onnxruntime-web (InferenceSession.create, Tensor, run).
|
||||
import * as ort from "onnxruntime-node";
|
||||
import type { Audio, TimedNote } from "../core/types.ts";
|
||||
import { toMono, resample } from "../audio/wav.ts";
|
||||
import { toMono } from "../dsp/mono.ts";
|
||||
import { resample } from "../dsp/resample.ts";
|
||||
|
||||
export interface TranscribeOpts {
|
||||
onsetThresh?: number; // 0.5
|
||||
|
||||
+2
-2
@@ -43,7 +43,7 @@ export function songToMidi(song: Song): at.midi.MidiFile {
|
||||
|
||||
const ROLES: readonly string[] = ["guitar", "bass", "keys", "vocals", "drums"];
|
||||
|
||||
export function truthFromGp(bytes: Uint8Array): { bpm: number; timeSig: [number, number]; tracks: TruthTrack[] } {
|
||||
export function truthFromGp(bytes: Uint8Array): { bpm: number; timeSig: [number, number]; bars: number; tracks: TruthTrack[] } {
|
||||
const score = at.importer.ScoreLoader.loadScoreFromBytes(bytes, new at.Settings());
|
||||
const bpm = score.tempo;
|
||||
const mb0 = score.masterBars[0]!;
|
||||
@@ -80,5 +80,5 @@ export function truthFromGp(bytes: Uint8Array): { bpm: number; timeSig: [number,
|
||||
if (!track.isPercussion) tr.tuning = [...staff.tuning];
|
||||
return tr;
|
||||
});
|
||||
return { bpm, timeSig: [mb0.timeSignatureNumerator, mb0.timeSignatureDenominator], tracks };
|
||||
return { bpm, timeSig: [mb0.timeSignatureNumerator, mb0.timeSignatureDenominator], bars: score.masterBars.length, tracks };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user