mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user