mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
Two perf fixes caught in #1118 review: 1. Cache guard: probeAndCacheVolumeKeyframes now short-circuits when the element is already in volumeKeyframeCache. Without the guard every bindMediaMetadataListeners call (every 30 RAF ticks) re-probed all bound elements — N elements × full-composition timeline seeks at 60 Hz regardless of whether keyframes were already known. bindRootTimelineIfAvailable still clears the cache on a new timeline capture so keyframes stay fresh when the composition is rebound. 2. PCM cursor: audioVolumeEnvelope.ts had the incremental segment cursor (O(N+M) overall) before #1118 extracted the interpolation into interpolateVolumeGain. The shared function restarts from segment=0 on each call — fine for the preview path (one call per RAF tick) but O(N×M) for the PCM path (one call per sample: 48 kHz × duration). Napkin math: a 10-min render went from ~30M to ~460M ops. Restored the inline incremental scan in the engine bake loop; engine now only imports normaliseEnvelope from core.
139 lines
5.6 KiB
TypeScript
139 lines
5.6 KiB
TypeScript
/**
|
||
* Sample-accurate volume automation.
|
||
*
|
||
* The audio mixer's primary path for time-varying volume bakes the envelope
|
||
* directly into the prepared PCM rather than encoding it as an FFmpeg `volume`
|
||
* expression. The expression approach nests one `if(lt(t,...))` per keyframe and
|
||
* overflows FFmpeg's expression evaluator past ~95 levels (a dense GSAP fade
|
||
* emits hundreds of keyframes), which fails the whole mix and drops the audio
|
||
* track. Multiplying the samples in-house has no such ceiling, is exact at every
|
||
* sample, and keeps the downstream ffmpeg `amix`/AAC encode untouched — so the
|
||
* output (and the golden baselines) only change where a fade is actually applied.
|
||
*
|
||
* The prepared tracks are always `pcm_s16le`, 48 kHz, stereo (see
|
||
* `prepareAudioTrack` / `extractAudioFromVideo`). Anything else is rejected so
|
||
* the caller can fall back to the expression path rather than corrupting audio.
|
||
*/
|
||
|
||
import { readFileSync, renameSync, writeFileSync } from "fs";
|
||
import { randomBytes } from "crypto";
|
||
import type { AudioVolumeKeyframe } from "./audioMixer.types.js";
|
||
import { normaliseEnvelope } from "@hyperframes/core/media-volume-envelope";
|
||
|
||
const PCM_FORMAT = 1; // WAVE_FORMAT_PCM
|
||
const SUPPORTED_BITS = 16;
|
||
|
||
interface WavLayout {
|
||
numChannels: number;
|
||
sampleRate: number;
|
||
dataOffset: number;
|
||
dataSize: number;
|
||
}
|
||
|
||
/**
|
||
* Locate the `fmt ` and `data` chunks and validate the format we know how to edit.
|
||
*
|
||
* Scans every chunk rather than assuming an ordering: the loop always advances
|
||
* past a chunk's body (using its declared size), so `data` may precede `fmt `
|
||
* and trailing chunks (LIST/fact/etc.) are skipped harmlessly. Returns null on
|
||
* anything unexpected so the caller falls back to the expression path.
|
||
*/
|
||
function parseWavLayout(buffer: Buffer): WavLayout | null {
|
||
if (buffer.length < 12 || buffer.toString("ascii", 0, 4) !== "RIFF") return null;
|
||
if (buffer.toString("ascii", 8, 12) !== "WAVE") return null;
|
||
|
||
let offset = 12;
|
||
let fmt: { numChannels: number; sampleRate: number; bitsPerSample: number } | null = null;
|
||
let data: { offset: number; size: number } | null = null;
|
||
|
||
while (offset + 8 <= buffer.length) {
|
||
const chunkId = buffer.toString("ascii", offset, offset + 4);
|
||
const chunkSize = buffer.readUInt32LE(offset + 4);
|
||
const body = offset + 8;
|
||
if (chunkId === "fmt " && body + 16 <= buffer.length) {
|
||
if (buffer.readUInt16LE(body) !== PCM_FORMAT) return null;
|
||
fmt = {
|
||
numChannels: buffer.readUInt16LE(body + 2),
|
||
sampleRate: buffer.readUInt32LE(body + 4),
|
||
bitsPerSample: buffer.readUInt16LE(body + 14),
|
||
};
|
||
} else if (chunkId === "data") {
|
||
data = { offset: body, size: Math.min(chunkSize, buffer.length - body) };
|
||
}
|
||
// Chunks are word-aligned: an odd size carries a trailing pad byte.
|
||
offset = body + chunkSize + (chunkSize % 2);
|
||
}
|
||
|
||
if (!fmt || !data) return null;
|
||
if (fmt.bitsPerSample !== SUPPORTED_BITS || fmt.numChannels < 1) return null;
|
||
return {
|
||
numChannels: fmt.numChannels,
|
||
sampleRate: fmt.sampleRate,
|
||
dataOffset: data.offset,
|
||
dataSize: data.size,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Multiply a prepared WAV's samples by a time-varying gain envelope in place.
|
||
*
|
||
* @returns `true` if the envelope was applied; `false` if the file isn't the
|
||
* expected 16-bit PCM (caller should fall back to the expression path).
|
||
*/
|
||
export function applyVolumeEnvelopeToWav(
|
||
wavPath: string,
|
||
keyframes: AudioVolumeKeyframe[],
|
||
trackStart: number,
|
||
baseVolume: number,
|
||
): boolean {
|
||
const envelope = normaliseEnvelope(keyframes, trackStart, baseVolume);
|
||
if (envelope.length === 0) return false;
|
||
|
||
try {
|
||
const buffer = readFileSync(wavPath);
|
||
const layout = parseWavLayout(buffer);
|
||
if (!layout) return false;
|
||
|
||
const { numChannels, sampleRate, dataOffset, dataSize } = layout;
|
||
const bytesPerSample = SUPPORTED_BITS / 8;
|
||
const frameBytes = numChannels * bytesPerSample;
|
||
const frameCount = Math.floor(dataSize / frameBytes);
|
||
|
||
// Maintain an incremental segment cursor so the per-frame envelope lookup
|
||
// is O(N+M) overall, not O(N×M). interpolateVolumeGain restarts from 0 on
|
||
// each call — fine for the preview path (one call per RAF tick) but not for
|
||
// the PCM path (one call per sample, 48k×duration frames total).
|
||
let segment = 0;
|
||
for (let frame = 0; frame < frameCount; frame += 1) {
|
||
const time = frame / sampleRate;
|
||
while (segment < envelope.length - 2 && time >= envelope[segment + 1]!.time) segment += 1;
|
||
|
||
const a = envelope[segment]!;
|
||
const b = envelope[segment + 1] ?? a;
|
||
const span = b.time - a.time;
|
||
const progress = span <= 0 ? 0 : Math.min(1, Math.max(0, (time - a.time) / span));
|
||
const gain = a.volume + (b.volume - a.volume) * progress;
|
||
|
||
const base = dataOffset + frame * frameBytes;
|
||
for (let channel = 0; channel < numChannels; channel += 1) {
|
||
const at = base + channel * bytesPerSample;
|
||
const scaled = Math.round(buffer.readInt16LE(at) * gain);
|
||
buffer.writeInt16LE(scaled < -32768 ? -32768 : scaled > 32767 ? 32767 : scaled, at);
|
||
}
|
||
}
|
||
|
||
// Write to a uniquely-named sibling then atomically rename over the
|
||
// original. The random name avoids following a pre-planted symlink at a
|
||
// predictable path, and the rename means a crash mid-write can't leave a
|
||
// truncated WAV for the downstream mix.
|
||
const tempPath = `${wavPath}.${randomBytes(6).toString("hex")}.tmp`;
|
||
writeFileSync(tempPath, buffer);
|
||
renameSync(tempPath, wavPath);
|
||
return true;
|
||
} catch {
|
||
// Any read/parse/write failure → leave the file untouched and let the
|
||
// caller fall back to the ffmpeg expression path rather than losing audio.
|
||
return false;
|
||
}
|
||
}
|