mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(core): muting or unmuting an audio group while playing takes effect at once (#4532)
* fix(core): muting or unmuting an audio group while playing reaches the mix * fix(core): a group mute uses its bus gain; only an unmute restarts the mix * fix(core): unmuting a group captures members that have yet to play, without restarting routed ones * fix(core): unmuting a group skips members muted on their own
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { initSandboxRuntimeModular } from "./init";
|
||||
import type { RuntimeTimelineLike } from "./types";
|
||||
import { WebAudioTransport } from "./webAudioTransport";
|
||||
|
||||
type GraphNode = {
|
||||
kind: string;
|
||||
@@ -216,3 +217,156 @@ describe("an element Web Audio captured, outside a play", () => {
|
||||
expect(audible(sources.get(vo))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/** On its idle route an element's own volume applies; inside a play only the graph's gains do. */
|
||||
function heard(el: HTMLMediaElement): boolean {
|
||||
const source = sources.get(el);
|
||||
if (!source) return !el.muted && el.volume > 0;
|
||||
if (source.outputs.has(destination)) return el.volume > 0;
|
||||
return audible(source);
|
||||
}
|
||||
|
||||
describe("an audio group muted or unmuted while playing", () => {
|
||||
const groupHtml =
|
||||
`<hf-audio-group id="music"></hf-audio-group>` +
|
||||
`<audio id="bed" data-start="0" data-duration="10" data-audio-group="music" src="/assets/music.mp3"></audio>`;
|
||||
|
||||
it("silences its members at once", async () => {
|
||||
mount(groupHtml);
|
||||
const bed = document.getElementById("bed") as HTMLAudioElement;
|
||||
initSandboxRuntimeModular();
|
||||
await flush();
|
||||
window.__player?.play();
|
||||
await flush();
|
||||
expect(heard(bed)).toBe(true);
|
||||
|
||||
document.getElementById("music")!.setAttribute("data-hidden", "");
|
||||
stepFrames(1);
|
||||
await flush();
|
||||
|
||||
expect(heard(bed)).toBe(false);
|
||||
});
|
||||
|
||||
it("brings its members back through the group", async () => {
|
||||
mount(groupHtml);
|
||||
const bed = document.getElementById("bed") as HTMLAudioElement;
|
||||
const group = document.getElementById("music")!;
|
||||
initSandboxRuntimeModular();
|
||||
await flush();
|
||||
window.__player?.play();
|
||||
await flush();
|
||||
window.__player?.pause();
|
||||
group.setAttribute("data-hidden", "");
|
||||
stepFrames(1);
|
||||
window.__player?.play();
|
||||
await flush();
|
||||
expect(heard(bed)).toBe(false);
|
||||
|
||||
group.removeAttribute("data-hidden");
|
||||
stepFrames(1);
|
||||
await flush();
|
||||
|
||||
expect(heard(bed)).toBe(true);
|
||||
expect(sources.get(bed)!.outputs.has(destination)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not restart the rest of the mix to mute it", async () => {
|
||||
mount(
|
||||
groupHtml + `<audio id="vo" data-start="0" data-duration="10" src="/assets/vo.mp3"></audio>`,
|
||||
);
|
||||
initSandboxRuntimeModular();
|
||||
await flush();
|
||||
window.__player?.play();
|
||||
await flush();
|
||||
const stopAll = vi.spyOn(WebAudioTransport.prototype, "stopAll");
|
||||
|
||||
document.getElementById("music")!.setAttribute("data-hidden", "");
|
||||
stepFrames(1);
|
||||
await flush();
|
||||
|
||||
expect(heard(document.getElementById("bed") as HTMLAudioElement)).toBe(false);
|
||||
expect(heard(document.getElementById("vo") as HTMLAudioElement)).toBe(true);
|
||||
expect(stopAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not restart the mix to unmute it right after muting it", async () => {
|
||||
mount(
|
||||
groupHtml + `<audio id="vo" data-start="0" data-duration="10" src="/assets/vo.mp3"></audio>`,
|
||||
);
|
||||
const bed = document.getElementById("bed") as HTMLAudioElement;
|
||||
const group = document.getElementById("music")!;
|
||||
initSandboxRuntimeModular();
|
||||
await flush();
|
||||
window.__player?.play();
|
||||
await flush();
|
||||
group.setAttribute("data-hidden", "");
|
||||
stepFrames(1);
|
||||
const stopAll = vi.spyOn(WebAudioTransport.prototype, "stopAll");
|
||||
|
||||
group.removeAttribute("data-hidden");
|
||||
stepFrames(1);
|
||||
await flush();
|
||||
|
||||
expect(heard(bed)).toBe(true);
|
||||
expect(stopAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not restart the mix to unmute a group whose members have all finished", async () => {
|
||||
mount(
|
||||
`<hf-audio-group id="music" data-hidden=""></hf-audio-group>` +
|
||||
`<audio id="bed" data-start="0" data-duration="2" data-audio-group="music" src="/assets/music.mp3"></audio>` +
|
||||
`<audio id="vo" data-start="0" data-duration="10" src="/assets/vo.mp3"></audio>`,
|
||||
);
|
||||
initSandboxRuntimeModular();
|
||||
await flush();
|
||||
window.__player?.seek(3);
|
||||
window.__player?.play();
|
||||
await flush();
|
||||
const stopAll = vi.spyOn(WebAudioTransport.prototype, "stopAll");
|
||||
|
||||
document.getElementById("music")!.removeAttribute("data-hidden");
|
||||
stepFrames(1);
|
||||
await flush();
|
||||
|
||||
expect(stopAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not restart the mix to unmute a group whose unfinished member is muted itself", async () => {
|
||||
mount(
|
||||
`<hf-audio-group id="music" data-hidden=""></hf-audio-group>` +
|
||||
`<audio id="bed" data-start="0" data-duration="10" data-audio-group="music" data-hidden="" src="/assets/music.mp3"></audio>` +
|
||||
`<audio id="vo" data-start="0" data-duration="10" src="/assets/vo.mp3"></audio>`,
|
||||
);
|
||||
initSandboxRuntimeModular();
|
||||
await flush();
|
||||
window.__player?.play();
|
||||
await flush();
|
||||
const stopAll = vi.spyOn(WebAudioTransport.prototype, "stopAll");
|
||||
|
||||
document.getElementById("music")!.removeAttribute("data-hidden");
|
||||
stepFrames(1);
|
||||
await flush();
|
||||
|
||||
expect(stopAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("brings a member that starts after the unmute in through the group", async () => {
|
||||
mount(
|
||||
`<hf-audio-group id="music" data-hidden=""></hf-audio-group>` +
|
||||
`<audio id="bed" data-start="5" data-duration="4" data-audio-group="music" src="/assets/music.mp3"></audio>`,
|
||||
);
|
||||
const bed = document.getElementById("bed") as HTMLAudioElement;
|
||||
initSandboxRuntimeModular();
|
||||
await flush();
|
||||
window.__player?.play();
|
||||
await flush();
|
||||
expect(sources.get(bed)).toBeUndefined();
|
||||
|
||||
document.getElementById("music")!.removeAttribute("data-hidden");
|
||||
stepFrames(1);
|
||||
await flush();
|
||||
|
||||
expect(audible(sources.get(bed))).toBe(true);
|
||||
expect(sources.get(bed)!.outputs.has(destination)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
reportWebAudioMediaRoute,
|
||||
} from "./webAudioRoute.js";
|
||||
import {
|
||||
audioGroupOf,
|
||||
ensureAudioGroupInertStyle,
|
||||
HF_AUDIO_GROUP_TAG,
|
||||
isMemberGroupHidden,
|
||||
@@ -2427,19 +2428,30 @@ export function initSandboxRuntimeModular(): void {
|
||||
// export time, per B4); this just keeps the live WebAudio group bus in
|
||||
// sync with a `data-hidden` toggle made mid-playback.
|
||||
const groupHiddenLast = new WeakMap<Element, boolean>();
|
||||
/** Set when a `data-hidden` mutation could have touched a BUS, so the sweep
|
||||
* below is not a whole-document query on every visibility pass. Same
|
||||
* dirty-flag shape as `hiddenAudioDirty` right above it. */
|
||||
let groupMuteDirty = true;
|
||||
const syncAudioGroupMute = () => {
|
||||
if (!groupMuteDirty) return;
|
||||
groupMuteDirty = false;
|
||||
const groupHasUncapturedMember = (groupId: string, currentTime: number): boolean => {
|
||||
for (const el of document.querySelectorAll("audio[data-start]")) {
|
||||
if (!isMediaElement(el) || audioGroupOf(el) !== groupId) continue;
|
||||
if (webAudio.routesElement(el) || isSilencedByHidden(el)) continue;
|
||||
const start = resolveAbsoluteMediaStartSeconds(el);
|
||||
const duration = parseStrictFiniteTimingNumber(el.dataset.duration);
|
||||
const end = duration != null && duration > 0 ? start + duration : Infinity;
|
||||
if (Number.isFinite(start) && currentTime < end) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
/** The bus gain owns a group's mute; true when an unmute leaves a member still to play outside the graph. */
|
||||
const syncAudioGroupMute = (currentTime: number): boolean => {
|
||||
let needsCapture = false;
|
||||
for (const groupEl of document.querySelectorAll(HF_AUDIO_GROUP_TAG)) {
|
||||
const hidden = groupEl.hasAttribute("data-hidden");
|
||||
if (groupHiddenLast.get(groupEl) === hidden) continue;
|
||||
const last = groupHiddenLast.get(groupEl);
|
||||
if (last === hidden) continue;
|
||||
groupHiddenLast.set(groupEl, hidden);
|
||||
if (groupEl.id) webAudio.setGroupMuted(groupEl.id, hidden);
|
||||
if (!groupEl.id) continue;
|
||||
webAudio.setGroupMuted(groupEl.id, hidden);
|
||||
if (last && !hidden && groupHasUncapturedMember(groupEl.id, currentTime)) needsCapture = true;
|
||||
}
|
||||
return needsCapture;
|
||||
};
|
||||
|
||||
const applyTimedElementVisibility = (
|
||||
@@ -2461,7 +2473,6 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (!dataHiddenDisplayNodes.has(rawNode)) {
|
||||
dataHiddenDisplayNodes.add(rawNode);
|
||||
if (nodeAffectsAudio(rawNode)) hiddenAudioDirty = true;
|
||||
groupMuteDirty = true;
|
||||
}
|
||||
hideByDisplay(rawNode, false);
|
||||
if (isVideoElement(rawNode) || isImageElement(rawNode)) {
|
||||
@@ -2474,7 +2485,6 @@ export function initSandboxRuntimeModular(): void {
|
||||
restoreDisplay(rawNode);
|
||||
dataHiddenDisplayNodes.delete(rawNode);
|
||||
if (nodeAffectsAudio(rawNode)) hiddenAudioDirty = true;
|
||||
groupMuteDirty = true;
|
||||
}
|
||||
|
||||
let isVisibleNow = isRuntimeElementVisibleAt(rawNode, {
|
||||
@@ -2526,7 +2536,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
// this reschedule exists to re-run are what change the active set, so
|
||||
// firing it otherwise was an audible stop-and-restart across the whole mix
|
||||
// that rebuilt an identical set.
|
||||
if (hiddenAudioDirty && clock.isPlaying()) {
|
||||
const groupNeedsCapture = syncAudioGroupMute(currentTime);
|
||||
if ((hiddenAudioDirty || groupNeedsCapture) && clock.isPlaying()) {
|
||||
webAudio.stopAll();
|
||||
for (const el of document.querySelectorAll("audio[data-start]")) {
|
||||
if (isMediaElement(el) && isSilencedByHidden(el)) el.volume = 0;
|
||||
@@ -2534,7 +2545,6 @@ export function initSandboxRuntimeModular(): void {
|
||||
scheduleWebAudioForActiveClips();
|
||||
}
|
||||
hiddenAudioDirty = false;
|
||||
syncAudioGroupMute();
|
||||
};
|
||||
|
||||
// Scope 2 of 3 (see `withTimingResolver`). One resolver for the whole
|
||||
|
||||
Reference in New Issue
Block a user