mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core): separate author and user audio gain (#3328)
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)", () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user