Files
hyperframes/packages/engine/src/services/audioVolumeEnvelope.ts
T
Miguel Ángel 0f938841cd fix(core,engine): guard volume probe cache and restore PCM cursor (#1119)
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.
2026-05-29 10:33:10 -04:00

139 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
}