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:
Miguel Ángel
2026-09-26 10:34:42 +00:00
committed by GitHub
parent 9faade09e7
commit fb3fdd4f0c
2 changed files with 177 additions and 13 deletions
@@ -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);
});
});
+23 -13
View File
@@ -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