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
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user