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]>
53 lines
2.5 KiB
TypeScript
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;
|
|
}
|