fix: support animated audio volume

This commit is contained in:
Miguel Ángel
2026-05-24 16:39:04 -04:00
parent 7461f1df30
commit 167a222318
10 changed files with 324 additions and 8 deletions
+7 -2
View File
@@ -1241,6 +1241,10 @@ export function initSandboxRuntimeModular(): void {
for (const mediaEl of mediaEls) {
if (metadataBoundMedia.has(mediaEl)) continue;
metadataBoundMedia.add(mediaEl);
const parsedVolume = Number.parseFloat(mediaEl.dataset.volume ?? "");
if (Number.isFinite(parsedVolume)) {
mediaEl.volume = Math.max(0, Math.min(1, parsedVolume));
}
mediaEl.addEventListener("loadedmetadata", scheduleMetadataDurationHydration);
mediaEl.addEventListener("durationchange", scheduleMetadataDurationHydration);
@@ -1312,6 +1316,7 @@ export function initSandboxRuntimeModular(): void {
userMuted: state.bridgeMuted,
userVolume: state.bridgeVolume,
forceSync,
onElementVolume: (el, volume) => webAudio.setElementVolume(el, volume),
onAutoplayBlocked: () => {
if (state.mediaAutoplayBlockedPosted) return;
state.mediaAutoplayBlockedPosted = true;
@@ -1461,8 +1466,8 @@ export function initSandboxRuntimeModular(): void {
externalCompositionsReady = true;
bindRootTimelineIfAvailable();
window.__renderReady = true;
runAdapters("discover", state.currentTime);
bindMediaMetadataListeners();
runAdapters("discover", state.currentTime);
installAssetFailureDiagnostics();
applyCaptionOverrides();
postTimeline();
@@ -1667,8 +1672,8 @@ export function initSandboxRuntimeModular(): void {
] as RuntimeDeterministicAdapter[];
patchVideoTextureCompat();
installRuntimeErrorDiagnostics();
runAdapters("discover");
bindMediaMetadataListeners();
runAdapters("discover");
// ── Single-clock transport ──
//
// TransportClock is the sole time authority. GSAP is always paused —
+35
View File
@@ -343,6 +343,41 @@ describe("syncRuntimeMedia", () => {
expect(clip.el.volume).toBeCloseTo(0.3);
});
it("preserves authored volume changes made between sync ticks", () => {
const clip = createMockClip({ start: 0, end: 10, volume: 0 });
syncRuntimeMedia({ clips: [clip], timeSeconds: 0, playing: false, playbackRate: 1 });
expect(clip.el.volume).toBe(0);
clip.el.volume = 0.5;
syncRuntimeMedia({ clips: [clip], timeSeconds: 0.5, playing: false, playbackRate: 1 });
expect(clip.el.volume).toBe(0.5);
});
it("reports the effective element volume to external audio transports", () => {
const clip = createMockClip({ start: 0, end: 10, volume: 0 });
const onElementVolume = vi.fn();
syncRuntimeMedia({
clips: [clip],
timeSeconds: 0,
playing: false,
playbackRate: 1,
onElementVolume,
});
clip.el.volume = 0.75;
syncRuntimeMedia({
clips: [clip],
timeSeconds: 1,
playing: false,
playbackRate: 1,
userVolume: 0.5,
onElementVolume,
});
expect(clip.el.volume).toBeCloseTo(0.375);
expect(onElementVolume).toHaveBeenLastCalledWith(clip.el, 0.375);
});
it("hard-syncs on the first active tick (sub-composition activation, mediaStart offsets)", () => {
const clip = createMockClip({ start: 0, end: 10, mediaStart: 0 });
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
+22 -2
View File
@@ -103,6 +103,13 @@ function markPlayRequested(el: HTMLMediaElement): void {
el.addEventListener("error", clear, { once: true });
}
const lastRuntimeAppliedVolume = new WeakMap<HTMLMediaElement, number>();
function clampVolume(volume: number): number {
if (!Number.isFinite(volume)) return 1;
return Math.max(0, Math.min(1, volume));
}
export function syncRuntimeMedia(params: {
clips: RuntimeMediaClip[];
timeSeconds: number;
@@ -132,6 +139,7 @@ export function syncRuntimeMedia(params: {
* outbound message; further invocations are suppressed by the caller.
*/
onAutoplayBlocked?: () => void;
onElementVolume?: (el: HTMLMediaElement, volume: number) => void;
forceSync?: boolean;
}): void {
// Either flag silences output. Combined up front so the per-clip loop is
@@ -151,8 +159,19 @@ export function syncRuntimeMedia(params: {
relTime = clip.mediaStart + ((relTime - clip.mediaStart) % loopLength);
}
}
const userVol = params.userVolume ?? 1;
el.volume = (clip.volume ?? 1) * userVol;
const userVol = clampVolume(params.userVolume ?? 1);
const fallbackAuthorVolume = clampVolume(clip.volume ?? 1);
const previousRuntimeVolume = lastRuntimeAppliedVolume.get(el);
const currentElementVolume = clampVolume(el.volume);
const authorVolume =
previousRuntimeVolume !== undefined &&
Math.abs(currentElementVolume - previousRuntimeVolume) > 0.0001
? currentElementVolume
: fallbackAuthorVolume;
const effectiveVolume = clampVolume(authorVolume * userVol);
el.volume = effectiveVolume;
lastRuntimeAppliedVolume.set(el, effectiveVolume);
params.onElementVolume?.(el, effectiveVolume);
if (shouldMute) el.muted = true;
// Ensure full preload for every active media element. Streaming
// formats (MP3) may arrive with preload="metadata", which only
@@ -283,6 +302,7 @@ export function syncRuntimeMedia(params: {
lastOffset.delete(el);
strictDriftSamples.delete(el);
seekLoadRetried.delete(el);
lastRuntimeAppliedVolume.delete(el);
if (!el.paused) el.pause();
}
}
@@ -191,6 +191,18 @@ export class WebAudioTransport {
}
}
setElementVolume(el: HTMLMediaElement, volume: number): void {
const safeVolume = Math.max(0, Math.min(1, volume));
for (const source of this._activeSources) {
if (source.el !== el) continue;
try {
source.gainNode.gain.value = safeVolume;
} catch (err) {
swallow("webAudioTransport.setElementVolume", err);
}
}
}
setMuted(muted: boolean): void {
if (this._masterGain) {
this._masterGain.gain.value = muted ? 0 : 1;