mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-02 19:57:13 +00:00
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.
134 lines
4.8 KiB
TypeScript
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 };
|