Skip to content

Commit cd832f0

Browse files
fix(core): mute preview audio per-element so a slow-decoding track isn't silenced (#1602)
The runtime plays audio two ways — a Web Audio transport (sample-accurate) and the HTMLMediaElement as a fallback — and mutes the elements when Web Audio takes over so they don't double-play. That mute gate was global: it muted every element the moment ANY Web Audio source was active (webAudio.isActive()). A track Web Audio had not claimed yet (its larger buffer decodes slower) was muted on the fallback AND not playing on Web Audio = silent, while the other tracks played. With TTS narration + BGM + SFX, the narration (largest buffer) lost the decode race and dropped out intermittently, with every file fully loaded. Make the mute per-element: an element is muted only when its own Web Audio source is live, or the user / parent-proxy force-mute is set. A track Web Audio has not claimed stays audible on the HTMLMedia fallback until the transport takes it over — which also lets narration start immediately on cold play instead of waiting for its buffer to decode. Also in this change: - Don't permanently blacklist a transient fetch failure in the Web Audio decoder (_failedSrcs was never cleared); only blacklist genuinely undecodable bytes, so a late-arriving asset (404 then available) self-heals on the next play. - Stop re-issuing play() every tick on an errored / no-source element.
1 parent 82b6ccd commit cd832f0

5 files changed

Lines changed: 264 additions & 14 deletions

File tree

packages/core/src/runtime/init.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1506,11 +1506,12 @@ export function initSandboxRuntimeModular(): void {
15061506
timeSeconds: state.currentTime,
15071507
playing: state.isPlaying,
15081508
playbackRate: state.playbackRate,
1509-
outputMuted: state.mediaOutputMuted || webAudio.isActive(),
1509+
outputMuted: state.mediaOutputMuted,
15101510
userMuted: state.bridgeMuted,
15111511
userVolume: state.bridgeVolume,
15121512
forceSync,
15131513
onElementVolume: (el, volume) => webAudio.setElementVolume(el, volume),
1514+
isWebAudioOwned: (el) => webAudio.ownsElement(el),
15141515
onAutoplayBlocked: () => {
15151516
if (state.mediaAutoplayBlockedPosted) return;
15161517
state.mediaAutoplayBlockedPosted = true;

packages/core/src/runtime/media.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,41 @@ describe("syncRuntimeMedia", () => {
262262
expect(clip.el.play).toHaveBeenCalled();
263263
});
264264

265+
describe("play() storm guard (unplayable elements)", () => {
266+
it("does not play() an element with a media error", () => {
267+
const clip = createMockClip({ start: 0, end: 10 });
268+
Object.defineProperty(clip.el, "error", { value: { code: 4 }, configurable: true });
269+
syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: true, playbackRate: 1 });
270+
expect(clip.el.play).not.toHaveBeenCalled();
271+
});
272+
273+
it("does not play() an element whose networkState is NO_SOURCE", () => {
274+
const clip = createMockClip({ start: 0, end: 10 });
275+
Object.defineProperty(clip.el, "networkState", { value: 3, configurable: true });
276+
syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: true, playbackRate: 1 });
277+
expect(clip.el.play).not.toHaveBeenCalled();
278+
});
279+
280+
it("does not re-play() across ticks while the element stays errored", () => {
281+
const clip = createMockClip({ start: 0, end: 10 });
282+
Object.defineProperty(clip.el, "error", { value: { code: 4 }, configurable: true });
283+
for (const t of [5, 5.1, 5.2, 5.3]) {
284+
syncRuntimeMedia({ clips: [clip], timeSeconds: t, playing: true, playbackRate: 1 });
285+
}
286+
expect(clip.el.play).not.toHaveBeenCalled();
287+
});
288+
289+
it("plays again once the element recovers (error clears)", () => {
290+
const clip = createMockClip({ start: 0, end: 10 });
291+
Object.defineProperty(clip.el, "error", { value: { code: 4 }, configurable: true });
292+
syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: true, playbackRate: 1 });
293+
expect(clip.el.play).not.toHaveBeenCalled();
294+
Object.defineProperty(clip.el, "error", { value: null, configurable: true });
295+
syncRuntimeMedia({ clips: [clip], timeSeconds: 5.1, playing: true, playbackRate: 1 });
296+
expect(clip.el.play).toHaveBeenCalled();
297+
});
298+
});
299+
265300
it("forces preload=auto on every active element, not just during play", () => {
266301
// Streaming formats (MP3) may arrive with preload="metadata", which only
267302
// buffers the first few seconds. Setting preload="auto" on every active
@@ -469,6 +504,84 @@ describe("syncRuntimeMedia", () => {
469504
expect(onElementVolume).toHaveBeenLastCalledWith(clip.el, 0.375);
470505
});
471506

507+
describe("per-element mute (Web Audio ownership)", () => {
508+
it("mutes a clip whose element the transport owns", () => {
509+
const clip = createMockClip({ start: 0, end: 10 });
510+
syncRuntimeMedia({
511+
clips: [clip],
512+
timeSeconds: 5,
513+
playing: true,
514+
playbackRate: 1,
515+
isWebAudioOwned: (el) => el === clip.el,
516+
});
517+
expect(clip.el.muted).toBe(true);
518+
});
519+
520+
it("leaves an un-owned clip audible while another track is on Web Audio", () => {
521+
// Regression: the un-owned track used to be muted by the global gate the
522+
// moment any source was active → silent while the owned track played.
523+
const owned = createMockClip({ start: 0, end: 10 });
524+
const unowned = createMockClip({ start: 0, end: 10 });
525+
syncRuntimeMedia({
526+
clips: [owned, unowned],
527+
timeSeconds: 5,
528+
playing: true,
529+
playbackRate: 1,
530+
isWebAudioOwned: (el) => el === owned.el,
531+
});
532+
expect(owned.el.muted).toBe(true);
533+
expect(unowned.el.muted).toBe(false);
534+
});
535+
536+
it("force-mutes every element when outputMuted (parent proxy owns all audio)", () => {
537+
const clip = createMockClip({ start: 0, end: 10 });
538+
syncRuntimeMedia({
539+
clips: [clip],
540+
timeSeconds: 5,
541+
playing: true,
542+
playbackRate: 1,
543+
outputMuted: true,
544+
isWebAudioOwned: () => false,
545+
});
546+
expect(clip.el.muted).toBe(true);
547+
});
548+
549+
it("force-mutes every element when userMuted", () => {
550+
const clip = createMockClip({ start: 0, end: 10 });
551+
syncRuntimeMedia({
552+
clips: [clip],
553+
timeSeconds: 5,
554+
playing: true,
555+
playbackRate: 1,
556+
userMuted: true,
557+
isWebAudioOwned: () => false,
558+
});
559+
expect(clip.el.muted).toBe(true);
560+
});
561+
562+
it("mutes only once the transport takes the element over", () => {
563+
const clip = createMockClip({ start: 0, end: 10 });
564+
let owned = false;
565+
syncRuntimeMedia({
566+
clips: [clip],
567+
timeSeconds: 5,
568+
playing: true,
569+
playbackRate: 1,
570+
isWebAudioOwned: () => owned,
571+
});
572+
expect(clip.el.muted).toBe(false); // decoding — audible via HTMLMedia fallback
573+
owned = true;
574+
syncRuntimeMedia({
575+
clips: [clip],
576+
timeSeconds: 5.1,
577+
playing: true,
578+
playbackRate: 1,
579+
isWebAudioOwned: () => owned,
580+
});
581+
expect(clip.el.muted).toBe(true);
582+
});
583+
});
584+
472585
it("hard-syncs on the first active tick (sub-composition activation, mediaStart offsets)", () => {
473586
const clip = createMockClip({ start: 0, end: 10, mediaStart: 0 });
474587
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });

packages/core/src/runtime/media.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@ function markPlayRequested(el: HTMLMediaElement): void {
113113
el.addEventListener("error", clear, { once: true });
114114
}
115115

116+
// HTMLMediaElement.NETWORK_NO_SOURCE — no usable source (404 / unsupported).
117+
const MEDIA_NETWORK_NO_SOURCE = 3;
118+
// An element that errored or has no source can't play; re-issuing play() every
119+
// tick just floods rejections. Skip it until its state changes (src reload).
120+
function isUnplayable(el: HTMLMediaElement): boolean {
121+
return el.error != null || el.networkState === MEDIA_NETWORK_NO_SOURCE;
122+
}
123+
116124
const lastRuntimeAppliedVolume = new WeakMap<HTMLMediaElement, number>();
117125

118126
function clampVolume(volume: number): number {
@@ -126,11 +134,8 @@ export function syncRuntimeMedia(params: {
126134
timeSeconds: number;
127135
playing: boolean;
128136
playbackRate: number;
129-
/**
130-
* Parent-frame audio-owner has taken over audible playback. Assert
131-
* `el.muted = true` on every active media element per tick so that any
132-
* sub-composition media inserted mid-playback inherits the silence.
133-
*/
137+
/** Force-mute every element (parent-frame proxy owns all audio). Asserted per
138+
* tick so sub-composition media added mid-playback inherits the silence. */
134139
outputMuted?: boolean;
135140
/**
136141
* User's explicit mute preference (set via `onSetMuted`). Symmetric to
@@ -151,11 +156,13 @@ export function syncRuntimeMedia(params: {
151156
*/
152157
onAutoplayBlocked?: () => void;
153158
onElementVolume?: (el: HTMLMediaElement, volume: number) => void;
159+
/** Is THIS element owned by the Web Audio transport? Owned → mute it (transport
160+
* plays it); not owned → leave audible (HTMLMedia fallback). Per-element, not a
161+
* global flag, so a not-yet-claimed track isn't muted by other tracks. */
162+
isWebAudioOwned?: (el: HTMLMediaElement) => boolean;
154163
forceSync?: boolean;
155164
}): void {
156-
// Either flag silences output. Combined up front so the per-clip loop is
157-
// a single branch instead of two.
158-
const shouldMute = !!(params.outputMuted || params.userMuted);
165+
const forceMuteAll = !!(params.outputMuted || params.userMuted);
159166
for (const clip of params.clips) {
160167
const { el } = clip;
161168
if (!el.isConnected) continue;
@@ -205,7 +212,9 @@ export function syncRuntimeMedia(params: {
205212
el.volume = effectiveVolume;
206213
lastRuntimeAppliedVolume.set(el, effectiveVolume);
207214
params.onElementVolume?.(el, effectiveVolume);
208-
if (shouldMute) el.muted = true;
215+
// Mute only when force-muted or the transport owns this element; an unclaimed
216+
// track stays audible via the HTMLMedia fallback.
217+
if (forceMuteAll || params.isWebAudioOwned?.(el)) el.muted = true;
209218
// Ensure full preload for every active media element. Streaming
210219
// formats (MP3) may arrive with preload="metadata", which only
211220
// buffers the first few seconds and causes seeks to silently fail
@@ -297,7 +306,7 @@ export function syncRuntimeMedia(params: {
297306
}
298307
playRequested.delete(el);
299308
}
300-
if (params.playing && el.paused && !playRequested.has(el)) {
309+
if (params.playing && el.paused && !playRequested.has(el) && !isUnplayable(el)) {
301310
// `HTMLMediaElement.play()` is spec'd to queue playback and resolve
302311
// once enough data is buffered, so we can unconditionally call it —
303312
// no need to gate on `readyState` or defer to a `canplay` listener.

packages/core/src/runtime/webAudioTransport.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,42 @@ describe("WebAudioTransport", () => {
129129
expect(transport.isActive()).toBe(false);
130130
});
131131

132+
describe("ownsElement (per-element mute gate)", () => {
133+
function withSource(el: HTMLMediaElement) {
134+
const transport = new WebAudioTransport();
135+
const source = {
136+
el,
137+
sourceNode: { stop: vi.fn(), disconnect: vi.fn() } as unknown as AudioBufferSourceNode,
138+
gainNode: { disconnect: vi.fn() } as unknown as GainNode,
139+
compositionStart: 0,
140+
mediaStart: 0,
141+
scheduledAt: 0,
142+
priorMuted: false,
143+
};
144+
(transport as unknown as { _activeSources: (typeof source)[] })._activeSources = [source];
145+
(transport as unknown as { _paused: boolean })._paused = false;
146+
return transport;
147+
}
148+
149+
it("returns true for an element the transport plays", () => {
150+
const el = { muted: false } as HTMLMediaElement;
151+
expect(withSource(el).ownsElement(el)).toBe(true);
152+
});
153+
154+
it("returns false for an element the transport does not play", () => {
155+
const el = { muted: false } as HTMLMediaElement;
156+
const other = { muted: false } as HTMLMediaElement;
157+
expect(withSource(el).ownsElement(other)).toBe(false);
158+
});
159+
160+
it("returns false after stopAll releases the element", () => {
161+
const el = { muted: false } as HTMLMediaElement;
162+
const transport = withSource(el);
163+
transport.stopAll();
164+
expect(transport.ownsElement(el)).toBe(false);
165+
});
166+
});
167+
132168
describe("schedulePlayback timing", () => {
133169
it("starts in-progress clips immediately with correct buffer offset", async () => {
134170
const { transport, mock, gen } = setupTransport(100);
@@ -379,4 +415,54 @@ describe("WebAudioTransport", () => {
379415
expect(transport.isActive()).toBe(false);
380416
});
381417
});
418+
419+
describe("decodeAudioElement retry policy (late-asset self-heal)", () => {
420+
function transportWithDecode(decodeImpl: () => Promise<AudioBuffer>) {
421+
const transport = new WebAudioTransport();
422+
const ctx = { state: "running", decodeAudioData: vi.fn(decodeImpl) };
423+
(transport as unknown as { _ctx: unknown })._ctx = ctx;
424+
return transport;
425+
}
426+
const el = (src: string) =>
427+
({ getAttribute: () => src, currentSrc: "" }) as unknown as HTMLMediaElement;
428+
const failedSrcs = (t: WebAudioTransport) =>
429+
(t as unknown as { _failedSrcs: Set<string> })._failedSrcs;
430+
431+
it("does NOT blacklist a transient fetch failure — a later play retries and succeeds", async () => {
432+
const transport = transportWithDecode(async () => ({}) as AudioBuffer);
433+
const fetchMock = vi
434+
.fn()
435+
.mockResolvedValueOnce({ ok: false, status: 404 }) // asset not uploaded yet
436+
.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) });
437+
vi.stubGlobal("fetch", fetchMock);
438+
439+
const first = await transport.decodeAudioElement(el("tts.wav"));
440+
expect(first).toBeNull();
441+
expect(failedSrcs(transport).has("tts.wav")).toBe(false); // not permanently silenced
442+
443+
const second = await transport.decodeAudioElement(el("tts.wav"));
444+
expect(second).not.toBeNull(); // self-heals once the asset is available
445+
expect(fetchMock).toHaveBeenCalledTimes(2);
446+
vi.unstubAllGlobals();
447+
});
448+
449+
it("DOES blacklist genuinely undecodable bytes — not retried", async () => {
450+
const transport = transportWithDecode(async () => {
451+
throw new Error("unsupported codec");
452+
});
453+
const fetchMock = vi
454+
.fn()
455+
.mockResolvedValue({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) });
456+
vi.stubGlobal("fetch", fetchMock);
457+
458+
const first = await transport.decodeAudioElement(el("corrupt.wav"));
459+
expect(first).toBeNull();
460+
expect(failedSrcs(transport).has("corrupt.wav")).toBe(true); // bad data is permanent
461+
462+
const second = await transport.decodeAudioElement(el("corrupt.wav"));
463+
expect(second).toBeNull();
464+
expect(fetchMock).toHaveBeenCalledTimes(1); // short-circuited, no re-fetch
465+
vi.unstubAllGlobals();
466+
});
467+
});
382468
});

packages/core/src/runtime/webAudioTransport.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
import { swallow } from "./diagnostics";
2+
import { getDebugSurface } from "./globals.js";
23

34
function normalizeRate(rate: number): number {
45
if (!Number.isFinite(rate) || rate <= 0) return 1;
56
return rate;
67
}
78

9+
/**
10+
* Breadcrumb for the per-element-mute handoff: the transport just claimed a track
11+
* that was audibly playing through the HTMLMedia fallback. Quiet unless
12+
* `__hfDebug` — a hook for diagnosing the race if it ever regresses.
13+
*/
14+
function logFallbackHandoff(el: HTMLMediaElement, priorMuted: boolean): void {
15+
if (priorMuted || el.paused || !getDebugSurface().__hfDebug) return;
16+
// eslint-disable-next-line no-console -- intentional debug surface
17+
console.debug(
18+
"[hyperframes] webAudioTransport claimed fallback-playing element:",
19+
el.currentSrc || el.getAttribute("src") || "",
20+
);
21+
}
22+
823
/**
924
* Start a buffer source, bounding it to the clip's authored window
1025
* (`data-duration`) so a trimmed clip stops at its edge instead of running the
@@ -96,14 +111,32 @@ export class WebAudioTransport {
96111
if (this._bufferCache.has(src)) return this._bufferCache.get(src)!;
97112
if (this._failedSrcs.has(src)) return null;
98113
if (!this._ctx) return null;
114+
115+
// Fetch the bytes. A network error or non-OK status (e.g. a 404 for an
116+
// asset that simply has not been uploaded yet) is TRANSIENT — return null
117+
// WITHOUT blacklisting, so the next play/seek generation retries once the
118+
// asset becomes available. (Previously these were added to `_failedSrcs`,
119+
// which is never cleared, permanently silencing a merely-late track.)
120+
let arrayBuffer: ArrayBuffer;
99121
try {
100-
const response = await fetch(src);
122+
// `no-store`: a retry must actually re-request the asset — not replay a
123+
// cached 404/stale response from the failed attempt that we chose not to
124+
// blacklist.
125+
const response = await fetch(src, { cache: "no-store" });
101126
if (!response.ok) {
102-
this._failedSrcs.add(src);
103127
swallow("webAudioTransport.fetch", new Error(`${response.status} ${src}`));
104128
return null;
105129
}
106-
const arrayBuffer = await response.arrayBuffer();
130+
arrayBuffer = await response.arrayBuffer();
131+
} catch (err) {
132+
swallow("webAudioTransport.fetch", err);
133+
return null;
134+
}
135+
136+
// A decode failure means the bytes themselves are unusable (corrupt or an
137+
// unsupported codec) — that IS permanent, so blacklist to avoid re-decoding
138+
// the same bad payload on every generation.
139+
try {
107140
const audioBuffer = await this._ctx.decodeAudioData(arrayBuffer);
108141
this._bufferCache.set(src, audioBuffer);
109142
return audioBuffer;
@@ -177,6 +210,7 @@ export class WebAudioTransport {
177210

178211
const priorMuted = el.muted;
179212
el.muted = true;
213+
logFallbackHandoff(el, priorMuted);
180214

181215
const scheduled: ScheduledSource = {
182216
el,
@@ -281,9 +315,16 @@ export class WebAudioTransport {
281315
return this._activeSources.length > 0 && !this._paused;
282316
}
283317

318+
/** Whether the transport currently plays THIS element (the runtime mutes it to
319+
* avoid double audio; an unclaimed track stays audible). */
320+
ownsElement(el: HTMLMediaElement): boolean {
321+
return !this._paused && this._activeSources.some((s) => s.el === el);
322+
}
323+
284324
destroy(): void {
285325
this.stopAll();
286326
this._bufferCache.clear();
327+
this._failedSrcs.clear();
287328
if (this._ctx) {
288329
try {
289330
void this._ctx.close();

0 commit comments

Comments
 (0)