// 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; }