perf(align): render previews on worker threads in parallel chunks, send Opus
The MIDI preview render (~2 s for a 5 min song) and auto-align ran synchronously on the server's event loop, and each preview was a 12.4 MB WAV. Renders now run as 4 parallel time chunks (2 s pre-roll, 10 ms cross-fade) on worker threads, auto-align on its own worker, and previews are mono Ogg Opus 48 kb/s via ffmpeg (1.5 MB, same timing as the WAV in Chrome). ?format=wav keeps the PCM output. Swimming: render 2.0 s -> 0.65 s + 0.6 s encode; event loop blocked 2.0 s -> <0.1 s per render. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7b7a1cc6c9
commit
3ce58e1e05
7 files changed
+319
-30
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { planChunks, stitchChunks, MIN_CHUNK_SEC, PREROLL_SEC } from "../../src/align/preview.ts";
|
||||
import { renderMidi, normalizePeak } from "../../src/align/render.ts";
|
||||
import { makeWarp } from "../../src/align/warp.ts";
|
||||
import type { RefNote, RefTrack } from "../../src/align/smf.ts";
|
||||
|
||||
test("planChunks covers [0, dur) contiguously, with pre-roll after the first chunk", () => {
|
||||
const c = planChunks(280, 4);
|
||||
expect(c).toHaveLength(4);
|
||||
expect(c[0]!.from).toBe(0);
|
||||
expect(c[3]!.to).toBe(280);
|
||||
for (let k = 1; k < 4; k++) {
|
||||
expect(c[k]!.from).toBe(c[k - 1]!.to);
|
||||
expect(c[k]!.renderStart).toBeCloseTo(c[k]!.from - PREROLL_SEC, 9);
|
||||
}
|
||||
expect(planChunks(MIN_CHUNK_SEC * 1.5, 4)).toHaveLength(1);
|
||||
expect(planChunks(5, 4)).toEqual([{ renderStart: 0, renderDur: 5, from: 0, to: 5 }]);
|
||||
});
|
||||
|
||||
test("stitchChunks reproduces a signal from overlapping slices of it", () => {
|
||||
const sr = 1000, dur = 90, n = dur * sr;
|
||||
const x = Float32Array.from({ length: n }, (_, i) => Math.sin(i / 37) * 0.5);
|
||||
const chunks = planChunks(dur, 3);
|
||||
const pcm = chunks.map((c) => x.slice(Math.round(c.renderStart * sr), Math.round((c.renderStart + c.renderDur) * sr)));
|
||||
const y = stitchChunks(chunks, pcm, sr, dur);
|
||||
expect(y.length).toBe(n);
|
||||
let maxErr = 0;
|
||||
for (let i = 0; i < n; i++) maxErr = Math.max(maxErr, Math.abs(y[i]! - x[i]!));
|
||||
expect(maxErr).toBeLessThan(1e-6);
|
||||
});
|
||||
|
||||
test("a chunked render matches the whole render (onsets in place, small seam error)", async () => {
|
||||
const SF2 = new Uint8Array(await Bun.file("node_modules/@coderline/alphatab/dist/soundfont/sonivox.sf2").arrayBuffer());
|
||||
const notes: RefNote[] = [];
|
||||
for (let t = 0.25; t < 58; t += 0.5) notes.push({ onset: t, offset: t + 0.4, pitch: 52 + ((t * 2) % 12), velocity: 100, channel: 0 });
|
||||
notes.push({ onset: 18, offset: 23, pitch: 40, velocity: 100, channel: 0 }); // sustains across the first seam (20 s)
|
||||
const tracks: RefTrack[] = [{ index: 0, name: "g", program: 25, isDrums: false, notes }];
|
||||
const warp = makeWarp([{ midiSec: 0, audioSec: 0.3, kind: "manual" }, { midiSec: 50, audioSec: 51, kind: "manual" }]);
|
||||
const sr = 22050, dur = 60;
|
||||
const whole = renderMidi(tracks, warp, SF2, { sampleRate: sr, mono: true, durationSec: dur }).channels[0]!;
|
||||
const chunks = planChunks(dur, 3);
|
||||
const pcm = chunks.map((c) => renderMidi(tracks, warp, SF2, { sampleRate: sr, mono: true, startSec: c.renderStart, durationSec: c.renderDur, normalize: false }).channels[0]!);
|
||||
const y = stitchChunks(chunks, pcm, sr, dur);
|
||||
normalizePeak([y]);
|
||||
// energy per 50 ms frame agrees closely everywhere (identical away from seams)
|
||||
const frame = Math.floor(sr / 20);
|
||||
let worst = 0;
|
||||
for (let f = 0; f + frame <= y.length; f += frame) {
|
||||
let a = 0, b = 0;
|
||||
for (let i = f; i < f + frame; i++) { a += whole[i]! ** 2; b += y[i]! ** 2; }
|
||||
worst = Math.max(worst, Math.abs(Math.sqrt(a / frame) - Math.sqrt(b / frame)));
|
||||
}
|
||||
expect(worst).toBeLessThan(0.05);
|
||||
}, 60_000);
|
||||
@@ -151,10 +151,23 @@ describe("API", () => {
|
||||
expect(String.fromCharCode(...b.subarray(0, 4))).toBe("OggS");
|
||||
expect(await Bun.file(join(root, "fix", "audio.ogg")).exists()).toBe(true);
|
||||
}, BIG);
|
||||
test("render: RIFF WAV, mono 22.05 kHz, cached by anchors", async () => {
|
||||
test("render: Ogg Opus by default (small), cached by anchors", async () => {
|
||||
const body = json({ anchors: first.anchors });
|
||||
const r = await api("/api/song/fix/render", body);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.headers.get("content-type")).toBe("audio/ogg");
|
||||
const b = new Uint8Array(await r.arrayBuffer());
|
||||
expect(String.fromCharCode(...b.subarray(0, 4))).toBe("OggS");
|
||||
expect(new TextDecoder().decode(b.subarray(28, 36))).toBe("OpusHead");
|
||||
expect(b.length).toBeLessThan(first.audioDur * 22050 * 2 / 4); // far below 16-bit PCM
|
||||
const again = await api("/api/song/fix/render", body);
|
||||
expect(again.headers.get("x-cache")).toBe("hit");
|
||||
expect(again.headers.get("x-anchors-hash")).toBe(r.headers.get("x-anchors-hash"));
|
||||
}, BIG);
|
||||
test("render ?format=wav: RIFF WAV, mono 22.05 kHz, cached by anchors", async () => {
|
||||
const body = json({ anchors: first.anchors });
|
||||
const r = await api("/api/song/fix/render?format=wav", body);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.headers.get("content-type")).toBe("audio/wav");
|
||||
const b = new Uint8Array(await r.arrayBuffer());
|
||||
const dv = new DataView(b.buffer);
|
||||
@@ -162,7 +175,7 @@ describe("API", () => {
|
||||
expect(dv.getUint16(22, true)).toBe(1);
|
||||
expect(dv.getUint32(24, true)).toBe(22050);
|
||||
expect(b.length).toBeGreaterThan(first.audioDur * 22050 * 2);
|
||||
const again = await api("/api/song/fix/render", body);
|
||||
const again = await api("/api/song/fix/render?format=wav", body);
|
||||
expect(again.headers.get("x-cache")).toBe("hit");
|
||||
expect(again.headers.get("x-anchors-hash")).toBe(r.headers.get("x-anchors-hash"));
|
||||
}, BIG);
|
||||
|
||||
Reference in new issue
Block a user