diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index eff5802ba..64c046427 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -967,9 +967,10 @@ export function initSandboxRuntimeModular(): void { mediaDurationFloorSeconds: resolution.mediaDurationFloorSeconds ?? null, }, }); - // (Re-)probe all already-bound media elements now that a timeline is available. - // Elements bound before this point couldn't be probed in bindMediaMetadataListeners. + // (Re-)probe all already-bound media elements against the new timeline. + // Clear the cache first so elements probed against a prior timeline get fresh keyframes. for (const el of metadataBoundMedia) { + volumeKeyframeCache.delete(el); probeAndCacheVolumeKeyframes(el); } return true; @@ -1283,6 +1284,7 @@ export function initSandboxRuntimeModular(): void { }; const probeAndCacheVolumeKeyframes = (mediaEl: HTMLMediaElement) => { + if (volumeKeyframeCache.has(mediaEl)) return; probeAndCacheElementVolume( mediaEl, state.capturedTimeline, diff --git a/packages/engine/src/services/audioVolumeEnvelope.ts b/packages/engine/src/services/audioVolumeEnvelope.ts index 60cc7218d..9d4d0aa73 100644 --- a/packages/engine/src/services/audioVolumeEnvelope.ts +++ b/packages/engine/src/services/audioVolumeEnvelope.ts @@ -18,7 +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"; +import { normaliseEnvelope } from "@hyperframes/core/media-volume-envelope"; const PCM_FORMAT = 1; // WAVE_FORMAT_PCM const SUPPORTED_BITS = 16; @@ -99,9 +99,20 @@ export function applyVolumeEnvelopeToWav( 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; - const gain = interpolateVolumeGain(envelope, time); + 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) {