diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 9bc37d6ac..ecccece1b 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -1,9 +1,15 @@ // fallow-ignore-file code-duplication import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; import { initSandboxRuntimeModular } from "./init"; import { TYPEGPU_PRESENT_HEARTBEAT_MS } from "./adapters/typegpu"; 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 { const state = { time: 0, paused: true, duration }; return { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index f6c2b6854..f9260e0c9 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -2031,8 +2031,10 @@ export function initSandboxRuntimeModular(): void { userMuted: state.bridgeMuted, userVolume: state.bridgeVolume, forceSync, - onElementVolume: (el, volume) => webAudio.setElementVolume(el, volume), + onElementVolume: (el, _effectiveVolume, authorVolume) => + webAudio.setElementVolume(el, authorVolume), isWebAudioOwned: (el) => webAudio.ownsElement(el), + isWebAudioRouted: (el) => webAudio.routesElement(el), onAutoplayBlocked: () => { if (state.mediaAutoplayBlockedPosted) return; state.mediaAutoplayBlockedPosted = true; @@ -3047,7 +3049,7 @@ export function initSandboxRuntimeModular(): void { compStart, mediaStart, clock.now(), - vol * state.bridgeVolume, + vol, gen, state.playbackRate, ) @@ -3071,7 +3073,7 @@ export function initSandboxRuntimeModular(): void { compStart, mediaStart, clock.now(), - vol * state.bridgeVolume, + vol, gen, state.playbackRate, clipDuration, diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index ca6f66e11..c3432980d 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -407,6 +407,22 @@ describe("syncRuntimeMedia", () => { 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", () => { const [a, b, c] = volumesAt([2, 2.5, 3], DUCK); expect(a).toBeCloseTo(0.8, 5); @@ -749,7 +765,7 @@ describe("syncRuntimeMedia", () => { 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 onElementVolume = vi.fn(); syncRuntimeMedia({ @@ -770,7 +786,48 @@ describe("syncRuntimeMedia", () => { }); 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)", () => { diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 2af60db04..220fc4988 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -207,11 +207,14 @@ export function syncRuntimeMedia(params: { * outbound message; further invocations are suppressed by the caller. */ 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 * 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. */ 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; }): void { const forceMuteAll = !!(params.outputMuted || params.userMuted); @@ -281,6 +284,8 @@ export function syncRuntimeMedia(params: { // for an untrimmed clip playing at 1x from t=0. const elapsedInClip = params.timeSeconds - clip.start; authorVolume = clampVolume(interpolateVolumeGain(clip.volumeKeyframes, elapsedInClip)); + } else if (params.isWebAudioRouted?.(el)) { + authorVolume = fallbackAuthorVolume; } else if (previousRuntimeVolume === undefined) { // First tick this clip is active. The transport has already seeked GSAP // to the current time (seekTimelineAndAdapters runs before syncRuntimeMedia), @@ -298,7 +303,7 @@ export function syncRuntimeMedia(params: { const effectiveVolume = clampVolume(authorVolume * userVol); el.volume = 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 // track stays audible via the HTMLMedia fallback. if (forceMuteAll || params.isWebAudioOwned?.(el)) el.muted = true; diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index 89cba4492..6bac784ea 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -80,6 +80,7 @@ describe("WebAudioTransport", () => { expect(mockEl.volume).toBe(1); expect(mock.gainNode.gain.value).toBe(0.8); expect(transport.ownsElement(mockEl)).toBe(false); + expect(transport.routesElement(mockEl)).toBe(true); expect(transport.isActive()).toBe(true); }); @@ -214,6 +215,16 @@ describe("WebAudioTransport", () => { 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)", () => { function withSource(el: HTMLMediaElement) { const transport = new WebAudioTransport(); diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index 180a5e5f9..9f8d24f4e 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -481,6 +481,11 @@ export class WebAudioTransport { 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 { this.stopAll(); this._bufferCache.clear();