feat(pipeline): transcribe guitar from guitar+piano+other stems

Demucs-6s scatters guitar energy across its guitar, piano and other stems.
Synthetic t1 median guitar F1 rises from 0.39-0.60 to 0.72-0.87; the real
'Last Night on Earth' reference goes 0.390 -> 0.403.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 20:46:31 +01:00
co-authored by Claude Opus 5.5
parent c24c7c979f
commit 0f14fbc574
2 changed files with 28 additions and 5 deletions
+16 -2
View File
@@ -40,6 +40,19 @@ export function copyFingering(timed: TimedNote[], assigned: ScoreNote[], grid: G
}); });
} }
/** Sample-wise sum of equally shaped audio buffers. */
export function sumAudio(...as: Audio[]): Audio {
const first = as[0]!;
return {
sampleRate: first.sampleRate,
channels: first.channels.map((c, k) => {
const o = new Float32Array(c.length);
for (const a of as) { const x = a.channels[k]!; for (let i = 0; i < o.length; i++) o[i]! += x[i]!; }
return o;
}),
};
}
export async function createPipeline( export async function createPipeline(
models: { demucs: Uint8Array; basicPitch: Uint8Array }, models: { demucs: Uint8Array; basicPitch: Uint8Array },
): Promise<(input: PipelineInput, flags: OracleFlags) => Promise<PipelineResult>> { ): Promise<(input: PipelineInput, flags: OracleFlags) => Promise<PipelineResult>> {
@@ -63,9 +76,10 @@ export async function createPipeline(
} }
} else { } else {
const s = await separate(input.mix); const s = await separate(input.mix);
warnings.push('demucs "other" stem unused');
const mk = (name: string, role: Role, audio: Audio): Job => ({ name, role, audio, truthNotes: null }); const mk = (name: string, role: Role, audio: Audio): Job => ({ name, role, audio, truthNotes: null });
jobs.push(mk("Guitar", "guitar", s.guitar), mk("Bass", "bass", s.bass), mk("Keys", "keys", s.piano), // Guitar is transcribed from guitar + piano + other: Demucs-6s scatters guitar energy across all three
// (synthetic t1 median guitar F1 0.39–0.60 → 0.72–0.87; real "Last Night on Earth" 0.390 → 0.403).
jobs.push(mk("Guitar", "guitar", sumAudio(s.guitar, s.piano, s.other)), mk("Bass", "bass", s.bass), mk("Keys", "keys", s.piano),
mk("Vocals", "vocals", s.vocals), mk("Drums", "drums", s.drums)); mk("Vocals", "vocals", s.vocals), mk("Drums", "drums", s.drums));
} }
+12 -3
View File
@@ -1,7 +1,7 @@
import { test, expect, beforeAll } from "bun:test"; import { test, expect, beforeAll } from "bun:test";
import { join } from "node:path"; import { join } from "node:path";
import { buildSong, loadSongDir } from "../../src/bench/dataset.ts"; import { buildSong, loadSongDir } from "../../src/bench/dataset.ts";
import { createPipeline, copyFingering } from "../../src/pipeline/run.ts"; import { createPipeline, copyFingering, sumAudio } from "../../src/pipeline/run.ts";
import { snapTicks, quantize } from "../../src/pipeline/quantize.ts"; import { snapTicks, quantize } from "../../src/pipeline/quantize.ts";
import type { TimedNote } from "../../src/core/types.ts"; import type { TimedNote } from "../../src/core/types.ts";
import { truthFromGp } from "../../src/score/gp.ts"; import { truthFromGp } from "../../src/score/gp.ts";
@@ -47,9 +47,9 @@ test("t1c/1 full pipeline yields all roles", async () => {
const roles = new Set(output.tracks.map((t) => t.role)); const roles = new Set(output.tracks.map((t) => t.role));
for (const r of ["guitar", "bass", "keys", "vocals", "drums"]) expect(roles.has(r as never)).toBe(true); for (const r of ["guitar", "bass", "keys", "vocals", "drums"]) expect(roles.has(r as never)).toBe(true);
expect(gp.length).toBeGreaterThan(0); expect(gp.length).toBeGreaterThan(0);
expect(warnings.some((w) => w.includes("other"))).toBe(true); expect(warnings.some((w) => w.includes("other"))).toBe(false);
expect(Math.abs(output.grid.bpm - d.truth.bpm)).toBeLessThan(2); expect(Math.abs(output.grid.bpm - d.truth.bpm)).toBeLessThan(2);
expect(s.guitarMerged.f1!).toBeGreaterThan(0.3); expect(s.guitarMerged.f1!).toBeGreaterThan(0.6); // guitar from guitar+piano+other stems (was 0.57 from the guitar stem alone)
}, SLOW); }, SLOW);
test("t1d/1 all oracle flags: two guitar tracks, partSplit and merged F1 === 1", async () => { test("t1d/1 all oracle flags: two guitar tracks, partSplit and merged F1 === 1", async () => {
@@ -90,3 +90,12 @@ test("copyFingering matches by snapped tick + pitch (merged cluster, adjacent re
copyFingering(timed, assigned, grid); copyFingering(timed, assigned, grid);
expect(timed.map((x) => [x.string, x.fret])).toEqual([[2, 0], [2, 0], [3, 240], [1, 0]]); expect(timed.map((x) => [x.string, x.fret])).toEqual([[2, 0], [2, 0], [3, 240], [1, 0]]);
}); });
test("sumAudio adds buffers sample-wise", () => {
const a = { sampleRate: 10, channels: [new Float32Array([1, 2]), new Float32Array([0, 1])] };
const b = { sampleRate: 10, channels: [new Float32Array([3, -2]), new Float32Array([1, 1])] };
const s = sumAudio(a, b);
expect([...s.channels[0]!]).toEqual([4, 0]);
expect([...s.channels[1]!]).toEqual([1, 2]);
expect(s.sampleRate).toBe(10);
});