Files
hyperframes/packages/core/stubs/audio-fx-runtime-entry.ts
Jerrai 8f7906b337 fix(audio): keep the pitch shifter's tail; no silence after a bitcrush seek (#5478)
The offline render now pads the source with silence for the whole render
(clip + chain tail + latency) instead of only the true-peak lookahead, so the
pitch shifter's ring drains instead of cutting off about 90 ms early.

A bitcrush node built part-way into a clip seeded its hold counter but started
its held value at 0, playing up to samples-1 frames of silence on a preview
seek. The held value is now primed from the first input sample; render windows
still line up with a whole render.
2026-10-12 01:29:50 +00:00

134 lines
4.8 KiB
TypeScript

/**
* Entry point for the injectable audio-FX runtime.
*
* The engine renders audio by running the *same* Web Audio graph the studio
* previews with, inside an OfflineAudioContext in the headless browser it
* already drives. Bundling the graph builders into one IIFE is what lets both
* ends share a single implementation rather than two that have to be kept in
* agreement.
*
* Exposes `window.__HF_AUDIO_FX.render(pcm, sampleRate, chain, automation, elapsed)`,
* which returns the processed samples.
*/
import {
buildFxChain,
chainNeedsWorklets,
ensureAudioFxWorklets,
} from "../src/audio/audioFxGraph.js";
import { scheduleChainAutomation } from "../src/audio/audioFxAutomation.js";
import { parseAutomation, resolveAutomation } from "../src/audioAutomation.js";
import { parseAudioFxChain, type HfAudioFxChain } from "../src/audioFx.js";
import { chainLatencySamples, chainTailSeconds } from "../src/audio/audioFxTail.js";
declare global {
interface Window {
__HF_AUDIO_FX?: {
render(
planes: Float32Array[],
sampleRate: number,
chainJson: string,
automationJson?: string,
elapsed?: number,
): Promise<Float32Array[]>;
};
}
}
/**
* Run the chain over one clip's audio, a plane per channel.
*
* Channel count is preserved: folding to mono here collapsed a stereo bed's
* width for the render only, while preview kept it stereo.
*
* The context runs past the input by the chain's own tail, so a reverb or a
* delay decays out instead of being cut at the last input sample. The length
* comes from the settings (`chainTailSeconds`), capped, and the returned planes
* are correspondingly longer than what came in — the mixer decides how much of
* that it lets through past the clip's end.
*
* `elapsed` is where in the file `planes` begins, for a window cut from a longer
* input: modulated effects follow the file's clock, not the window's.
*/
/** The clip's audio as an AudioBuffer, a plane per channel. */
function toBuffer(
ctx: OfflineAudioContext,
planes: readonly Float32Array[],
channels: number,
frames: number,
sampleRate: number,
): AudioBuffer {
const buffer = ctx.createBuffer(channels, frames, sampleRate);
for (let c = 0; c < channels; c++) {
const plane = planes[c];
if (plane) buffer.getChannelData(c).set(plane);
}
return buffer;
}
/** Every rendered channel past `skip` leading samples, copied out of the result buffer. */
function toPlanes(rendered: AudioBuffer, skip: number): Float32Array[] {
const out: Float32Array[] = [];
for (let c = 0; c < rendered.numberOfChannels; c++) {
out.push(new Float32Array(rendered.getChannelData(c).subarray(skip)));
}
return out;
}
async function render(
planes: Float32Array[],
sampleRate: number,
chainJson: string,
automationJson?: string,
elapsed = 0,
): Promise<Float32Array[]> {
const chain: HfAudioFxChain = parseAudioFxChain(chainJson);
const channels = Math.max(1, planes.length);
const frames = planes[0]?.length ?? 0;
// Nothing to process, and `new OfflineAudioContext(ch, 0, rate)` throws — an
// error the render treats as fatal. applyAudioFxChain screens empty tracks
// out before they reach the browser; this is the same guard at the point the
// constructor would actually blow up.
if (frames === 0) return planes;
const parsedAutomation = automationJson
? resolveAutomation(parseAutomation(automationJson), chain)
: null;
const tail = Math.ceil(chainTailSeconds(chain, parsedAutomation ?? undefined) * sampleRate);
// A lookahead limiter delays its output; render that much longer and drop the
// lead-in so the clip stays sample-aligned with its source.
const latency = chainLatencySamples(chain, sampleRate);
const total = frames + tail + latency;
const ctx = new OfflineAudioContext(channels, total, sampleRate);
if (chainNeedsWorklets(chain)) await ensureAudioFxWorklets(ctx);
const source = ctx.createBufferSource();
// Padded with silence for the whole render: once a source ends its worklets stop being fed, so
// anything still held in a lookahead line or a grain ring would never leave it.
source.buffer = toBuffer(ctx, planes, channels, total, sampleRate);
const fx = buildFxChain(ctx, chain, elapsed);
// The input WAV is the clip's own audio from its first sample, so clip-local
// time is offline time — the envelope needs no offset here. Same scheduler as
// preview, which is what makes the two agree.
if (parsedAutomation) {
scheduleChainAutomation(
parsedAutomation,
chain,
fx.nodes,
{ scheduledAt: 0, elapsed: 0, rate: 1 },
fx.presets,
);
}
source.connect(fx.input);
fx.output.connect(ctx.destination);
source.start();
return toPlanes(await ctx.startRendering(), latency);
}
window.__HF_AUDIO_FX = { render };