fix(core): separate author and user audio gain (#3328)

This commit is contained in:
Miguel Ángel
2026-08-18 10:59:12 -04:00
committed by GitHub
parent afafca4b96
commit 995c9e346e
6 changed files with 93 additions and 7 deletions
+6
View File
@@ -1,9 +1,15 @@
// fallow-ignore-file code-duplication // fallow-ignore-file code-duplication
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { initSandboxRuntimeModular } from "./init"; import { initSandboxRuntimeModular } from "./init";
import { TYPEGPU_PRESENT_HEARTBEAT_MS } from "./adapters/typegpu"; import { TYPEGPU_PRESENT_HEARTBEAT_MS } from "./adapters/typegpu";
import type { RuntimeTimelineLike } from "./types"; import type { RuntimeTimelineLike } from "./types";
it("schedules WebAudio element gain from author volume without bridge volume", () => {
const source = readFileSync("src/runtime/init.ts", "utf8");
expect(source).not.toMatch(/vol\s*\*\s*state\.bridgeVolume/);
});
function createMockTimeline(duration: number): RuntimeTimelineLike { function createMockTimeline(duration: number): RuntimeTimelineLike {
const state = { time: 0, paused: true, duration }; const state = { time: 0, paused: true, duration };
return { return {
+5 -3
View File
@@ -2031,8 +2031,10 @@ export function initSandboxRuntimeModular(): void {
userMuted: state.bridgeMuted, userMuted: state.bridgeMuted,
userVolume: state.bridgeVolume, userVolume: state.bridgeVolume,
forceSync, forceSync,
onElementVolume: (el, volume) => webAudio.setElementVolume(el, volume), onElementVolume: (el, _effectiveVolume, authorVolume) =>
webAudio.setElementVolume(el, authorVolume),
isWebAudioOwned: (el) => webAudio.ownsElement(el), isWebAudioOwned: (el) => webAudio.ownsElement(el),
isWebAudioRouted: (el) => webAudio.routesElement(el),
onAutoplayBlocked: () => { onAutoplayBlocked: () => {
if (state.mediaAutoplayBlockedPosted) return; if (state.mediaAutoplayBlockedPosted) return;
state.mediaAutoplayBlockedPosted = true; state.mediaAutoplayBlockedPosted = true;
@@ -3047,7 +3049,7 @@ export function initSandboxRuntimeModular(): void {
compStart, compStart,
mediaStart, mediaStart,
clock.now(), clock.now(),
vol * state.bridgeVolume, vol,
gen, gen,
state.playbackRate, state.playbackRate,
) )
@@ -3071,7 +3073,7 @@ export function initSandboxRuntimeModular(): void {
compStart, compStart,
mediaStart, mediaStart,
clock.now(), clock.now(),
vol * state.bridgeVolume, vol,
gen, gen,
state.playbackRate, state.playbackRate,
clipDuration, clipDuration,
+59 -2
View File
@@ -407,6 +407,22 @@ describe("syncRuntimeMedia", () => {
expect(ducked).toBeCloseTo(0.1, 5); expect(ducked).toBeCloseTo(0.1, 5);
}); });
it("keeps automation author-only while user volume remains a separate layer", () => {
const clip = createMockClip({ start: 0, end: 10, volume: 0.55 });
clip.el.setAttribute("data-automation", DUCK);
const onElementVolume = vi.fn();
syncRuntimeMedia({
clips: [clip],
timeSeconds: 1,
playing: true,
playbackRate: 1,
userVolume: 0.5,
onElementVolume,
});
expect(onElementVolume).toHaveBeenLastCalledWith(clip.el, 0.4, 0.8);
});
it("ramps between points across ticks", () => { it("ramps between points across ticks", () => {
const [a, b, c] = volumesAt([2, 2.5, 3], DUCK); const [a, b, c] = volumesAt([2, 2.5, 3], DUCK);
expect(a).toBeCloseTo(0.8, 5); expect(a).toBeCloseTo(0.8, 5);
@@ -749,7 +765,7 @@ describe("syncRuntimeMedia", () => {
expect(clip.el.volume).toBe(0.5); expect(clip.el.volume).toBe(0.5);
}); });
it("reports the effective element volume to external audio transports", () => { it("reports effective and author-only volume to external audio transports", () => {
const clip = createMockClip({ start: 0, end: 10, volume: 0 }); const clip = createMockClip({ start: 0, end: 10, volume: 0 });
const onElementVolume = vi.fn(); const onElementVolume = vi.fn();
syncRuntimeMedia({ syncRuntimeMedia({
@@ -770,7 +786,48 @@ describe("syncRuntimeMedia", () => {
}); });
expect(clip.el.volume).toBeCloseTo(0.375); expect(clip.el.volume).toBeCloseTo(0.375);
expect(onElementVolume).toHaveBeenLastCalledWith(clip.el, 0.375); expect(onElementVolume).toHaveBeenLastCalledWith(clip.el, 0.375, 0.75);
});
it("preserves author volume through user zero then restore", () => {
const clip = createMockClip({ start: 0, end: 10, volume: 0.8 });
const onElementVolume = vi.fn();
syncRuntimeMedia({
clips: [clip],
timeSeconds: 1,
playing: false,
playbackRate: 1,
userVolume: 0,
onElementVolume,
});
syncRuntimeMedia({
clips: [clip],
timeSeconds: 2,
playing: false,
playbackRate: 1,
userVolume: 0.5,
onElementVolume,
});
expect(onElementVolume.mock.calls.at(-2)).toEqual([clip.el, 0, 0.8]);
expect(onElementVolume.mock.calls.at(-1)).toEqual([clip.el, 0.4, 0.8]);
});
it("does not mistake routed upstream unity for an authored volume change", () => {
const clip = createMockClip({ start: 0, end: 10, volume: 0.8 });
clip.el.volume = 1;
const onElementVolume = vi.fn();
syncRuntimeMedia({
clips: [clip],
timeSeconds: 1,
playing: true,
playbackRate: 1,
userVolume: 0.5,
isWebAudioRouted: (el) => el === clip.el,
onElementVolume,
});
expect(onElementVolume).toHaveBeenLastCalledWith(clip.el, 0.4, 0.8);
}); });
describe("per-element mute (Web Audio ownership)", () => { describe("per-element mute (Web Audio ownership)", () => {
+7 -2
View File
@@ -207,11 +207,14 @@ export function syncRuntimeMedia(params: {
* outbound message; further invocations are suppressed by the caller. * outbound message; further invocations are suppressed by the caller.
*/ */
onAutoplayBlocked?: () => void; onAutoplayBlocked?: () => void;
onElementVolume?: (el: HTMLMediaElement, volume: number) => void; onElementVolume?: (el: HTMLMediaElement, effectiveVolume: number, authorVolume: number) => void;
/** Is THIS element owned by the Web Audio transport? Owned mute it (transport /** Is THIS element owned by the Web Audio transport? Owned mute it (transport
* plays it); not owned leave audible (HTMLMedia fallback). Per-element, not a * plays it); not owned leave audible (HTMLMedia fallback). Per-element, not a
* global flag, so a not-yet-claimed track isn't muted by other tracks. */ * global flag, so a not-yet-claimed track isn't muted by other tracks. */
isWebAudioOwned?: (el: HTMLMediaElement) => boolean; isWebAudioOwned?: (el: HTMLMediaElement) => boolean;
/** Native media routed through WebAudio keeps its upstream element volume at
* unity; do not mistake that transport write for an authored volume edit. */
isWebAudioRouted?: (el: HTMLMediaElement) => boolean;
forceSync?: boolean; forceSync?: boolean;
}): void { }): void {
const forceMuteAll = !!(params.outputMuted || params.userMuted); const forceMuteAll = !!(params.outputMuted || params.userMuted);
@@ -281,6 +284,8 @@ export function syncRuntimeMedia(params: {
// for an untrimmed clip playing at 1x from t=0. // for an untrimmed clip playing at 1x from t=0.
const elapsedInClip = params.timeSeconds - clip.start; const elapsedInClip = params.timeSeconds - clip.start;
authorVolume = clampVolume(interpolateVolumeGain(clip.volumeKeyframes, elapsedInClip)); authorVolume = clampVolume(interpolateVolumeGain(clip.volumeKeyframes, elapsedInClip));
} else if (params.isWebAudioRouted?.(el)) {
authorVolume = fallbackAuthorVolume;
} else if (previousRuntimeVolume === undefined) { } else if (previousRuntimeVolume === undefined) {
// First tick this clip is active. The transport has already seeked GSAP // First tick this clip is active. The transport has already seeked GSAP
// to the current time (seekTimelineAndAdapters runs before syncRuntimeMedia), // to the current time (seekTimelineAndAdapters runs before syncRuntimeMedia),
@@ -298,7 +303,7 @@ export function syncRuntimeMedia(params: {
const effectiveVolume = clampVolume(authorVolume * userVol); const effectiveVolume = clampVolume(authorVolume * userVol);
el.volume = effectiveVolume; el.volume = effectiveVolume;
lastRuntimeAppliedVolume.set(el, effectiveVolume); lastRuntimeAppliedVolume.set(el, effectiveVolume);
params.onElementVolume?.(el, effectiveVolume); params.onElementVolume?.(el, effectiveVolume, authorVolume);
// Mute only when force-muted or the transport owns this element; an unclaimed // Mute only when force-muted or the transport owns this element; an unclaimed
// track stays audible via the HTMLMedia fallback. // track stays audible via the HTMLMedia fallback.
if (forceMuteAll || params.isWebAudioOwned?.(el)) el.muted = true; if (forceMuteAll || params.isWebAudioOwned?.(el)) el.muted = true;
@@ -80,6 +80,7 @@ describe("WebAudioTransport", () => {
expect(mockEl.volume).toBe(1); expect(mockEl.volume).toBe(1);
expect(mock.gainNode.gain.value).toBe(0.8); expect(mock.gainNode.gain.value).toBe(0.8);
expect(transport.ownsElement(mockEl)).toBe(false); expect(transport.ownsElement(mockEl)).toBe(false);
expect(transport.routesElement(mockEl)).toBe(true);
expect(transport.isActive()).toBe(true); expect(transport.isActive()).toBe(true);
}); });
@@ -214,6 +215,16 @@ describe("WebAudioTransport", () => {
expect(mock.masterGain.gain.value).toBe(0.4); expect(mock.masterGain.gain.value).toBe(0.4);
}); });
it("applies author and user volume once in separate gain layers", async () => {
const { transport, mock, gen } = setupTransport();
await transport.scheduleMediaElementPlayback(mockEl, 0, 0, 0, 0.8, gen, 1);
transport.setVolume(0.5);
expect(mock.gainNode.gain.value).toBe(0.8);
expect(mock.masterGain.gain.value).toBe(0.5);
expect(mock.gainNode.gain.value * mock.masterGain.gain.value).toBeCloseTo(0.4);
});
describe("ownsElement (per-element mute gate)", () => { describe("ownsElement (per-element mute gate)", () => {
function withSource(el: HTMLMediaElement) { function withSource(el: HTMLMediaElement) {
const transport = new WebAudioTransport(); const transport = new WebAudioTransport();
@@ -481,6 +481,11 @@ export class WebAudioTransport {
return !this._paused && this._activeSources.some((s) => s.el === el && isBufferSource(s)); return !this._paused && this._activeSources.some((s) => s.el === el && isBufferSource(s));
} }
/** Whether this element's native signal currently flows through the WebAudio graph. */
routesElement(el: HTMLMediaElement): boolean {
return !this._paused && this._activeSources.some((source) => source.el === el);
}
destroy(): void { destroy(): void {
this.stopAll(); this.stopAll();
this._bufferCache.clear(); this._bufferCache.clear();