// Rendering a recorded performance offline. // // The WAV you can download is not a recording of what came out of the speakers: it is the same // hits played again through the same AudioWorklet in an OfflineAudioContext, which runs as fast // as the machine allows and is exactly reproducible. The whole performance is handed to the // processor in processorOptions, because a port message posted before startRendering is not // guaranteed to arrive before the first block. import type { SimulationConfig } from "./config/schema.ts"; import type { Derived } from "./derived.ts"; import type { LoggedHit } from "./hits.ts"; import type { ModesData } from "./modes.ts"; import { buildHitPatch } from "./patch.ts"; export interface RenderOptions { sampleRate: number; leadIn: number; // silence before the first hit [s] tail: number; // ring-out after the last hit [s] maxPoints: number; unbaffled: boolean; } /** The hits as worklet messages, with release frames relative to ``baseFrame``. */ export function hitMessages(modes: ModesData, derived: Derived, hits: LoggedHit[], sampleRate: number, baseFrame: number, maxPointsOf: (h: LoggedHit) => number) { const sorted = hits.filter((h) => h.time >= 0).slice().sort((a, b) => a.time - b.time); const t0 = sorted.length ? sorted[0].time : 0; const out: Record[] = []; for (const h of sorted) { const patch = buildHitPatch(modes, h.exciter, h.r, h.phi, h.velocity, maxPointsOf(h), derived.phiFactor); if (!patch) continue; out.push({ type: "hit", id: h.id, phi: patch.phi, offsets: patch.offsets, kPoint: patch.kPoint, alpha: patch.alpha, mass: patch.mass, gap: patch.gap, velocity: h.velocity, atFrame: baseFrame + Math.round((h.time - t0) * sampleRate), silent: true, }); } return { messages: out, span: sorted.length ? sorted[sorted.length - 1].time - t0 : 0 }; } export function gainArray(modes: ModesData, unbaffled: boolean): Float32Array { const g = new Float32Array(2 * modes.nModes); for (let i = 0; i < g.length; i++) g[i] = modes.gainFront[i] - (unbaffled ? modes.gainBack[i] : 0); return g; } export async function renderRecording(workletUrl: string, modes: ModesData, _config: SimulationConfig, derived: Derived, hits: LoggedHit[], opts: RenderOptions): Promise { const fs = opts.sampleRate; const lead = Math.round(opts.leadIn * fs); const { messages, span } = hitMessages(modes, derived, hits, fs, lead, () => opts.maxPoints); if (!messages.length) throw new Error("there are no hits to render"); const frames = lead + Math.ceil(span * fs) + Math.ceil(opts.tail * fs); const ctx = new OfflineAudioContext({ numberOfChannels: 1, length: frames, sampleRate: fs }); await ctx.audioWorklet.addModule(workletUrl); const node = new AudioWorkletNode(ctx, "cymbal-modal", { numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [1], processorOptions: { init: { type: "init", freq: derived.freq, sigma: derived.sigma, gain: gainArray(modes, opts.unbaffled) }, hits: messages, }, }); node.connect(ctx.destination); const buffer = await ctx.startRendering(); return buffer.getChannelData(0).slice(); }