fix(core): apply renderer volume-automation solution to preview (#1118)

Preview audio with GSAP volume fades (e.g. data-volume="0" with a
gsap.to("#bgm", {volume:0.25, ...})) played ~1s then silenced. Root
cause: syncRuntimeMedia used fallbackAuthorVolume (data-volume) on the
first tick after a clip became active, clobbering the GSAP-seeked value.
The single-clock transport seeks GSAP before syncRuntimeMedia runs, so
el.volume already holds the animated value — we just need to trust it.

Fix — three layers, matching the renderer's approach (PR #1117):

1. First-tick tracking: on the first tick a clip is active
   (previousRuntimeVolume===undefined), use currentElementVolume (GSAP's
   seeked value) instead of fallbackAuthorVolume. In production the
   transport always seeks GSAP before syncRuntimeMedia, so el.volume is
   already at the correct animated position.

2. Probed keyframes: new probeElementVolumeKeyframes() runs the same
   offline probe the renderer uses (discoverAudioVolumeAutomationFromTimeline)
   directly in the browser. init.ts calls probeAndCacheElementVolume() when
   an element is bound and a timeline is available. When keyframes are present,
   syncRuntimeMedia drives volume from the interpolated envelope — no
   GSAP-change tracking needed, no first-tick edge case, same data source
   as the renderer.

3. Shared utilities: normaliseEnvelope(), interpolateVolumeGain(), and
   probeAndCacheElementVolume() extracted to mediaVolumeEnvelope.ts and
   exported from @hyperframes/core/media-volume-envelope. The engine's
   audioVolumeEnvelope.ts imports from there — no duplicate logic between
   the renderer and the new preview path.

Fallow audit exits non-zero on inherited complexity/duplication in init.ts
functions that shifted line numbers (applyClipLayout, transportTick, etc.),
unchanged by this PR — same known false-positive pattern noted in #1117.
Lint, format, typecheck, and unit tests all pass.

53 core/media tests pass (3 updated to pre-set el.volume to match the
runtime's bindMediaMetadataListeners — corrects a missing setup step).
audioVolumeEnvelope tests (6) still pass.
This commit is contained in:
Miguel Ángel
2026-05-29 10:10:12 -04:00
committed by GitHub
parent bc3701f590
commit d3c333b383
6 changed files with 243 additions and 46 deletions
@@ -18,6 +18,7 @@
import { readFileSync, renameSync, writeFileSync } from "fs";
import { randomBytes } from "crypto";
import type { AudioVolumeKeyframe } from "./audioMixer.types.js";
import { normaliseEnvelope, interpolateVolumeGain } from "@hyperframes/core/media-volume-envelope";
const PCM_FORMAT = 1; // WAVE_FORMAT_PCM
const SUPPORTED_BITS = 16;
@@ -73,38 +74,6 @@ function parseWavLayout(buffer: Buffer): WavLayout | null {
};
}
/**
* Normalise keyframes to track-relative seconds, sorted and de-duplicated, with
* `baseVolume` filling any gap before the first keyframe. Returns the breakpoints
* the gain envelope is linearly interpolated between.
*/
function toRelativeEnvelope(
keyframes: AudioVolumeKeyframe[],
trackStart: number,
baseVolume: number,
): { time: number; volume: number }[] {
const points = keyframes
.filter((k) => Number.isFinite(k.time) && Number.isFinite(k.volume))
.map((k) => ({
time: Math.max(0, k.time - trackStart),
volume: Math.max(0, Math.min(1, k.volume)),
}))
.sort((a, b) => a.time - b.time);
const deduped: { time: number; volume: number }[] = [];
for (const point of points) {
const previous = deduped.at(-1);
if (previous && Math.abs(previous.time - point.time) < 1e-9) previous.volume = point.volume;
else deduped.push(point);
}
if (deduped.length === 0) return deduped;
if (deduped[0]!.time > 0) {
deduped.unshift({ time: 0, volume: Math.max(0, Math.min(1, baseVolume)) });
}
return deduped;
}
/**
* Multiply a prepared WAV's samples by a time-varying gain envelope in place.
*
@@ -117,7 +86,7 @@ export function applyVolumeEnvelopeToWav(
trackStart: number,
baseVolume: number,
): boolean {
const envelope = toRelativeEnvelope(keyframes, trackStart, baseVolume);
const envelope = normaliseEnvelope(keyframes, trackStart, baseVolume);
if (envelope.length === 0) return false;
try {
@@ -130,16 +99,9 @@ export function applyVolumeEnvelopeToWav(
const frameBytes = numChannels * bytesPerSample;
const frameCount = Math.floor(dataSize / frameBytes);
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 gain = interpolateVolumeGain(envelope, time);
const base = dataOffset + frame * frameBytes;
for (let channel = 0; channel < numChannels; channel += 1) {