Files
STSheets/src/align/preview.ts
T
Lucas WintherandClaude Opus 5.5 3ce58e1e05 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 <[email protected]>
2026-10-30 22:04:46 +01:00

53 lines
2.5 KiB
TypeScript

// MIDI preview rendering split into time chunks that render in parallel (one per worker thread), then stitched.
// Each chunk is rendered with a pre-roll (notes already sounding are re-attacked inside it and it is discarded) and a
// short overlap that is cross-faded into the next chunk, so seams are inaudible for a preview. Pure: no I/O.
export interface Chunk {
/** Audio time of the chunk's first output sample (includes the pre-roll). */
renderStart: number;
/** Seconds to render from `renderStart`. */
renderDur: number;
/** The part of the timeline this chunk owns: [from, to). */
from: number; to: number;
}
export const PREROLL_SEC = 2;
export const XFADE_SEC = 0.01;
/** Chunks shorter than this are not worth a worker (synth start-up, pre-roll overhead). */
export const MIN_CHUNK_SEC = 20;
/** Splits [0, durSec) into at most `maxChunks` equal chunks of at least MIN_CHUNK_SEC (always at least one). */
export function planChunks(durSec: number, maxChunks: number): Chunk[] {
const n = Math.max(1, Math.min(maxChunks, Math.floor(durSec / MIN_CHUNK_SEC)));
const out: Chunk[] = [];
for (let k = 0; k < n; k++) {
const from = (durSec * k) / n, to = k === n - 1 ? durSec : (durSec * (k + 1)) / n;
const renderStart = k === 0 ? 0 : from - PREROLL_SEC;
const end = k === n - 1 ? durSec : to + XFADE_SEC;
out.push({ renderStart, renderDur: end - renderStart, from, to });
}
return out;
}
/**
* Joins rendered chunks (mono, `sampleRate`, as planned by `planChunks`) into one signal of round(durSec·sr) samples:
* each chunk contributes [from, to), and its XFADE_SEC overlap past `to` fades out while the next chunk fades in.
*/
export function stitchChunks(chunks: Chunk[], pcm: Float32Array[], sampleRate: number, durSec: number): Float32Array {
const n = Math.round(durSec * sampleRate), out = new Float32Array(n);
const xf = Math.max(1, Math.round(XFADE_SEC * sampleRate));
chunks.forEach((c, k) => {
const x = pcm[k]!, s0 = Math.round(c.from * sampleRate), s1 = k === chunks.length - 1 ? n : Math.round(c.to * sampleRate);
const off = Math.round(c.renderStart * sampleRate); // output index of x[0]
for (let i = s0; i < s1; i++) {
const v = x[i - off] ?? 0;
// first xf samples of a later chunk fade in; the previous chunk's overlap was already added (fading out)
out[i] = k > 0 && i - s0 < xf ? out[i]! + v * ((i - s0) / xf) : v;
}
if (k < chunks.length - 1) {
for (let j = 0; j < xf && s1 + j < n; j++) out[s1 + j] = (x[s1 + j - off] ?? 0) * (1 - j / xf);
}
});
return out;
}