diff --git a/docs/reference/html-schema.mdx b/docs/reference/html-schema.mdx
index c71613c98c..f9717430f3 100644
--- a/docs/reference/html-schema.mdx
+++ b/docs/reference/html-schema.mdx
@@ -126,6 +126,7 @@ Audio has no visual lifecycle.
| `data-playback-rate` | Video, audio, nested composition | Playback multiplier from `0.1` to `5` |
| `data-volume` | Video and audio | Static volume from `0` to `1` |
| `data-has-audio="true"` | Video | Declares that the video contributes audio |
+| `data-native-audio` | Audio | Keeps the element on the browser's own output instead of routing it through Web Audio. The runtime already does this automatically for cross-origin media with no `crossorigin` opt-in — the Web Audio spec makes such a source silent — so this is the escape hatch for the cases a URL cannot settle, such as a same-origin path that redirects to a CDN. Native output cannot carry `data-fx-chain`, `data-automation`, `data-audio-group`, or a `data-volume` above `1`; the runtime reports what it dropped, and `hyperframes check` surfaces it as a `web_audio_bypass` finding. |
Video and audio may omit `data-duration` when their intrinsic duration is known
and the whole remaining source should play.
diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts
index 8a6a4920ab..ce83152e32 100644
--- a/packages/cli/src/utils/checkBrowser.test.ts
+++ b/packages/cli/src/utils/checkBrowser.test.ts
@@ -436,6 +436,53 @@ it("surfaces the runtime's media-proxy-unavailable console.info line as its own
);
});
+it("surfaces the runtime's web-audio-bypass console.info line as its own info finding", async () => {
+ // The whole complaint in #3458 is that nothing was reported. The runtime
+ // emits this from media DISCOVERY, not from playback scheduling, precisely
+ // because `check` seeks and never calls play() — a diagnostic raised from
+ // the transport would never reach this scraper.
+ vi.spyOn(Date, "now").mockReturnValue(100);
+ mountCanvasFixture();
+ const page = fakePage();
+ const bypassMessage = fakeConsoleMessage(
+ "info",
+ '[hyperframes] runtime_web_audio_bypass: "https://cdn.example.com/track.mp3" ' +
+ "(cross_origin_no_cors): Web Audio capture withheld; the track plays through native " +
+ "HTMLMediaElement output. Native playback cannot reproduce: fx-chain — proxy or download " +
+ "the asset to a same-origin URL to keep it.",
+ );
+ const authorInfo = fakeConsoleMessage("info", "debug runtime_web_audio_bypass lookalike");
+ page.on = vi.fn(
+ (event: string, handler: (message: ReturnType) => void) => {
+ if (event === "console") {
+ handler(bypassMessage);
+ handler(authorInfo);
+ }
+ },
+ );
+ installSessionMock(page);
+
+ const result = await runBrowserCheck(
+ PROJECT,
+ { ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false },
+ { kind: "none" },
+ runAuditGrid,
+ );
+
+ expect(result.runtimeFindings).toContainEqual(
+ expect.objectContaining({
+ code: "web_audio_bypass",
+ severity: "info",
+ message: bypassMessage.text(),
+ }),
+ );
+ // Prefix-anchored, so a composition author's own console.info that merely
+ // mentions the code is not promoted into a finding.
+ expect(result.runtimeFindings.some((finding) => finding.message === authorInfo.text())).toBe(
+ false,
+ );
+});
+
it("elevates and deduplicates WebGPU validation warnings while preserving ordinary warnings", async () => {
vi.spyOn(Date, "now").mockReturnValue(100);
mountCanvasFixture();
diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts
index 012708b217..b84f4e92ca 100644
--- a/packages/cli/src/utils/checkBrowser.ts
+++ b/packages/cli/src/utils/checkBrowser.ts
@@ -264,6 +264,11 @@ export async function captureFindingCrops(
// `console.info` from a composition author's own script must not.
const MEDIA_PROXY_MARKER_PREFIX = "[hyperframes] runtime_media_proxy_";
const MEDIA_PROXY_UNAVAILABLE_MARKER = "[hyperframes] runtime_media_proxy_unavailable";
+// `reportWebAudioMediaRoute` (packages/core/src/runtime/webAudioRoute.ts) uses
+// the same code-in-the-console-line contract. It is emitted from the media
+// DISCOVERY phase rather than from playback scheduling, precisely so this
+// scraper can see it — `check` seeks, it never plays.
+const WEB_AUDIO_BYPASS_MARKER = "[hyperframes] runtime_web_audio_bypass";
const WEBGPU_RUNTIME_FAILURE =
/\b(?:GPUValidationError|GPUOutOfMemoryError|GPUInternalError)\b|WebGPU uncaptured error|(?:destroyed\b.*\b(?:GPU )?(?:resource|buffer|texture)\b.*\bsubmit)|(?:(?:GPU )?(?:resource|buffer|texture)\b.*\bdestroyed\b.*\bsubmit)/i;
@@ -290,6 +295,20 @@ function pushRuntimeDraft(drafts: RuntimeDraft[], draft: RuntimeDraft): void {
drafts.push({ ...draft, count: 1 });
}
+/**
+ * The finding code for a runtime-emitted `console.info` line, or null for the
+ * ordinary info logging a composition author's own script produces. Matching is
+ * prefix-anchored on the stable diagnostic codes the runtime deliberately embeds
+ * in the text, so a line that merely mentions one is not promoted.
+ */
+function runtimeInfoFindingCode(text: string): string | null {
+ if (text.startsWith(WEB_AUDIO_BYPASS_MARKER)) return "web_audio_bypass";
+ if (!text.startsWith(MEDIA_PROXY_MARKER_PREFIX)) return null;
+ return text.includes(MEDIA_PROXY_UNAVAILABLE_MARKER)
+ ? "media_proxy_unavailable"
+ : "media_proxy_fallback";
+}
+
function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: () => number): void {
page.on("console", (message) => {
const type = message.type();
@@ -315,12 +334,12 @@ function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: (
url: location.url,
line: location.lineNumber,
});
- } else if (type === "info" && text.startsWith(MEDIA_PROXY_MARKER_PREFIX)) {
+ } else if (type === "info") {
+ const code = runtimeInfoFindingCode(text);
+ if (!code) return;
const location = message.location();
pushRuntimeDraft(drafts, {
- code: text.includes(MEDIA_PROXY_UNAVAILABLE_MARKER)
- ? "media_proxy_unavailable"
- : "media_proxy_fallback",
+ code,
severity: "info",
message: text,
time: currentTime(),
diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts
index 262a08321d..dc8321fae6 100644
--- a/packages/core/src/runtime/init.test.ts
+++ b/packages/core/src/runtime/init.test.ts
@@ -3053,4 +3053,236 @@ describe("initSandboxRuntimeModular", () => {
}).not.toThrow();
});
});
+
+ // #3458: cross-origin media with no CORS opt-in. `createMediaElementSource`
+ // returns a node that outputs silence per the Web Audio spec rather than
+ // throwing, so the composition played through with visuals animating and no
+ // sound, and nothing was logged.
+ describe("cross-origin audio without a CORS opt-in", () => {
+ // `WebAudioTransport.init()` does `new AudioContext()`, which jsdom does not
+ // provide — without a stub it returns false, `webAudioReady` stays false,
+ // and `scheduleWebAudioForActiveClips` is never reached at all, so every
+ // assertion below would pass for the wrong reason.
+ class MockAudioContext {
+ currentTime = 0;
+ state = "running";
+ destination = {};
+ resume() {
+ return Promise.resolve();
+ }
+ createGain() {
+ return { gain: { value: 1 }, connect() {}, disconnect() {} };
+ }
+ }
+ const originalAudioContext = (globalThis as Record).AudioContext;
+
+ beforeEach(() => {
+ (globalThis as Record).AudioContext = MockAudioContext;
+ });
+
+ afterEach(() => {
+ (globalThis as Record).AudioContext = originalAudioContext;
+ });
+
+ /** `webAudio.init()` resolves on a microtask, so `webAudioReady` is still
+ * false on the tick `initSandboxRuntimeModular()` returns. */
+ async function startPlayback() {
+ initSandboxRuntimeModular();
+ await Promise.resolve();
+ window.__player?.play();
+ await Promise.resolve();
+ await Promise.resolve();
+ }
+
+ function mountAudio(src: string, attrs: Record = {}) {
+ const root = document.createElement("div");
+ root.setAttribute("data-composition-id", "main");
+ root.setAttribute("data-root", "true");
+ root.setAttribute("data-start", "0");
+ root.setAttribute("data-duration", "10");
+ root.setAttribute("data-width", "1920");
+ root.setAttribute("data-height", "1080");
+ document.body.appendChild(root);
+
+ const audio = document.createElement("audio");
+ audio.setAttribute("data-start", "0");
+ audio.setAttribute("data-duration", "10");
+ audio.setAttribute("src", src);
+ for (const [name, value] of Object.entries(attrs)) audio.setAttribute(name, value);
+ audio.load = () => {};
+ audio.play = vi.fn(() => Promise.resolve());
+ root.appendChild(audio);
+
+ window.__timelines = { main: createMockTimeline(10) };
+ return audio;
+ }
+
+ it("withholds Web Audio capture but still tries decode, which keeps the FX graph", async () => {
+ // Decode is the BEST outcome here, not a consolation: a CDN that sends
+ // `Access-Control-Allow-Origin` while the author simply never wrote the
+ // `crossorigin` attribute decodes fine, and that route keeps every
+ // effect and automation lane the media-element route would have had.
+ const audio = mountAudio("https://cdn.example.com/track.mp3");
+ vi.spyOn(console, "info").mockImplementation(() => {});
+ const captureSpy = vi.spyOn(WebAudioTransport.prototype, "scheduleMediaElementPlayback");
+ const decodeSpy = vi
+ .spyOn(WebAudioTransport.prototype, "decodeAudioElement")
+ .mockResolvedValue(null);
+
+ await startPlayback();
+
+ expect(captureSpy).not.toHaveBeenCalled();
+ expect(decodeSpy).toHaveBeenCalledWith(audio);
+ });
+
+ it("leaves the element audible on native output when decode also fails", async () => {
+ const audio = mountAudio("https://cdn.example.com/track.mp3");
+ vi.spyOn(console, "info").mockImplementation(() => {});
+ vi.spyOn(WebAudioTransport.prototype, "decodeAudioElement").mockResolvedValue(null);
+
+ await startPlayback();
+
+ // The three things that add up to "the user hears it".
+ expect(audio.muted).toBe(false);
+ expect(audio.volume).toBeGreaterThan(0);
+ expect(audio.play).toHaveBeenCalled();
+ expect(window.__player?.isPlaying()).toBe(true);
+ });
+
+ it("does not fail closed into silence for an FX track it deliberately withheld", async () => {
+ // The pre-existing non-unit-rate rule mutes a processed track rather than
+ // let it lose its graph. On this route capture was withheld ON PURPOSE
+ // and native output IS the fix, so muting would hand back the exact
+ // silence being fixed — now with the runtime's blessing.
+ const audio = mountAudio("https://cdn.example.com/track.mp3", {
+ "data-fx-chain": "[]",
+ "data-playback-rate": "2",
+ });
+ vi.spyOn(console, "info").mockImplementation(() => {});
+ vi.spyOn(WebAudioTransport.prototype, "decodeAudioElement").mockResolvedValue(null);
+
+ await startPlayback();
+
+ expect(audio.muted).toBe(false);
+ });
+
+ it("reports the bypass at media discovery, without anyone calling play()", () => {
+ // `hyperframes check` seeks, it never plays. A diagnostic raised only
+ // from the schedule path would be invisible to the one gate whose job is
+ // to surface this.
+ mountAudio("https://cdn.example.com/track.mp3", { "data-fx-chain": "[]" });
+ const info = vi.spyOn(console, "info").mockImplementation(() => {});
+
+ initSandboxRuntimeModular();
+
+ const line = info.mock.calls.find(([first]) =>
+ String(first).includes("runtime_web_audio_bypass"),
+ );
+ expect(line).toBeDefined();
+ // Names what native playback cannot carry, so the author knows the track
+ // is audible but no longer processed.
+ expect(String(line?.[0])).toContain("fx-chain");
+ });
+
+ it("says nothing about a cross-origin , which never routes through Web Audio", () => {
+ const root = document.createElement("div");
+ root.setAttribute("data-composition-id", "main");
+ root.setAttribute("data-root", "true");
+ root.setAttribute("data-width", "1920");
+ root.setAttribute("data-height", "1080");
+ document.body.appendChild(root);
+ const video = document.createElement("video");
+ video.setAttribute("data-start", "0");
+ video.setAttribute("src", "https://cdn.example.com/clip.mp4");
+ video.load = () => {};
+ root.appendChild(video);
+ window.__timelines = { main: createMockTimeline(10) };
+ const info = vi.spyOn(console, "info").mockImplementation(() => {});
+
+ initSandboxRuntimeModular();
+
+ expect(
+ info.mock.calls.some(([first]) => String(first).includes("runtime_web_audio_bypass")),
+ ).toBe(false);
+ });
+
+ it("keeps a data-native-audio track off BOTH Web Audio routes", async () => {
+ // Unlike the automatic verdict this must skip decode too — a decode
+ // success would put the element back under a buffer source and mute it,
+ // which is precisely what the escape hatch exists to prevent.
+ const audio = mountAudio("/assets/vo.mp3", { "data-native-audio": "" });
+ const captureSpy = vi.spyOn(WebAudioTransport.prototype, "scheduleMediaElementPlayback");
+ const decodeSpy = vi.spyOn(WebAudioTransport.prototype, "decodeAudioElement");
+
+ await startPlayback();
+
+ expect(captureSpy).not.toHaveBeenCalled();
+ expect(decodeSpy).not.toHaveBeenCalled();
+ expect(audio.muted).toBe(false);
+ expect(audio.play).toHaveBeenCalled();
+ });
+
+ it("still routes same-origin audio through Web Audio", async () => {
+ const audio = mountAudio("/assets/vo.mp3");
+ const captureSpy = vi
+ .spyOn(WebAudioTransport.prototype, "scheduleMediaElementPlayback")
+ .mockResolvedValue(null);
+
+ await startPlayback();
+
+ expect(captureSpy).toHaveBeenCalledTimes(1);
+ expect(captureSpy.mock.calls[0]?.[0]).toBe(audio);
+ });
+
+ // The fail-closed rule and the bypass diagnostic answer different
+ // questions, so they deliberately test different attributes. The
+ // diagnostic lists everything native output cannot carry; the rule below
+ // only decides whether losing the FX graph is worse than silence.
+ describe("the non-unit-rate fail-closed rule keeps its original scope", () => {
+ function playWithFailedCapture() {
+ vi.spyOn(WebAudioTransport.prototype, "scheduleMediaElementPlayback").mockResolvedValue(
+ null,
+ );
+ vi.spyOn(WebAudioTransport.prototype, "decodeAudioElement").mockResolvedValue(null);
+ return startPlayback();
+ }
+
+ it("still mutes an fx-chain track whose capture failed at a non-unit rate", async () => {
+ const audio = mountAudio("/assets/vo.mp3", {
+ "data-fx-chain": "[]",
+ "data-playback-rate": "2",
+ });
+
+ await playWithFailedCapture();
+
+ expect(audio.muted).toBe(true);
+ });
+
+ it("leaves a grouped track audible, as it was before #3458", async () => {
+ // Group membership is reported as unexpressible on the bypass route,
+ // but it was never part of the fail-closed pair. Folding it in here
+ // would silence a same-origin grouped clip at a non-unit rate that
+ // plays today — a behaviour change #3458 does not call for.
+ const audio = mountAudio("/assets/vo.mp3", {
+ "data-audio-group": "vo",
+ "data-playback-rate": "2",
+ });
+
+ await playWithFailedCapture();
+
+ expect(audio.muted).toBe(false);
+ });
+
+ it("leaves an above-unity data-volume track audible", async () => {
+ const audio = mountAudio("/assets/vo.mp3", {
+ "data-volume": "2",
+ "data-playback-rate": "2",
+ });
+
+ await playWithFailedCapture();
+
+ expect(audio.muted).toBe(false);
+ });
+ });
+ });
});
diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts
index d968f4279f..b496af797e 100644
--- a/packages/core/src/runtime/init.ts
+++ b/packages/core/src/runtime/init.ts
@@ -42,6 +42,7 @@ import { applyVariableBindings } from "./applyVariableBindings";
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport";
+import { classifyWebAudioMediaRoute, reportWebAudioMediaRoute } from "./webAudioRoute.js";
import {
ensureAudioGroupInertStyle,
HF_AUDIO_GROUP_TAG,
@@ -1833,11 +1834,26 @@ export function initSandboxRuntimeModular(): void {
}
};
+ // Only `` reaches `createMediaElementSource` (see
+ // `scheduleWebAudioForActiveClips`, which queries `audio[data-start]`), so a
+ // cross-origin `` is not affected and must not be reported as if it
+ // were.
+ const reportWebAudioRoute = (mediaEl: HTMLMediaElement) => {
+ if (!(mediaEl instanceof HTMLAudioElement)) return;
+ reportWebAudioMediaRoute(mediaEl, classifyWebAudioMediaRoute(mediaEl));
+ };
+
+ const onMediaLoadedMetadataForRoute = (event: Event) => {
+ const target = event.currentTarget;
+ if (target instanceof HTMLMediaElement) reportWebAudioRoute(target);
+ };
+
const unbindMediaMetadataListeners = () => {
for (const mediaEl of metadataBoundMedia) {
mediaEl.removeEventListener("loadedmetadata", scheduleMetadataDurationHydration);
mediaEl.removeEventListener("durationchange", scheduleMetadataDurationHydration);
mediaEl.removeEventListener("loadedmetadata", onMediaLoadedMetadataForProxy);
+ mediaEl.removeEventListener("loadedmetadata", onMediaLoadedMetadataForRoute);
mediaEl.removeEventListener("error", onMediaErrorForProxy);
}
metadataBoundMedia.clear();
@@ -1855,6 +1871,16 @@ export function initSandboxRuntimeModular(): void {
}
mediaEl.addEventListener("loadedmetadata", scheduleMetadataDurationHydration);
mediaEl.addEventListener("durationchange", scheduleMetadataDurationHydration);
+ // Web Audio eligibility, reported at DISCOVERY rather than only at
+ // schedule time. `hyperframes check` seeks, it never calls play(), so a
+ // diagnostic raised from the transport would be invisible to the one
+ // gate whose job is to surface exactly this class of silent failure.
+ // Bound twice on purpose: now, so a composition that never plays still
+ // reports, and again at `loadedmetadata`, when `currentSrc` is finally
+ // authoritative and the early read may have been guessing from
+ // `` children. `reportWebAudioMediaRoute` latches per element.
+ mediaEl.addEventListener("loadedmetadata", onMediaLoadedMetadataForRoute);
+ reportWebAudioRoute(mediaEl);
// Reactive (zero-videoWidth) + tertiary (error event) proxy-fallback
// triggers. Inert in render mode / when the codec map is absent /
// for — all guarded inside mediaProxy.ts itself.
@@ -3122,43 +3148,71 @@ export function initSandboxRuntimeModular(): void {
);
}
}
- void webAudio
- .scheduleMediaElementPlayback(
- rawEl,
- compStart,
- mediaStart,
- clock.now(),
- vol,
- gen,
- state.playbackRate,
- )
- .then((scheduled) => {
- if (scheduled || !clock.isPlaying()) return;
- const effectiveRate = state.playbackRate * readElementPlaybackRate(rawEl);
- const hasProcessing =
- rawEl.hasAttribute("data-fx-chain") || rawEl.hasAttribute("data-automation");
- // A decoded AudioBufferSourceNode changes pitch whenever its playback
- // rate is non-unit. Bare tracks may safely stay on native output; a
- // processed track must fail closed rather than silently lose its graph.
- if (Math.abs(effectiveRate - 1) > 1e-9) {
- if (hasProcessing) rawEl.muted = true;
- return;
- }
- void webAudio.decodeAudioElement(rawEl).then((buffer) => {
- if (!buffer || !clock.isPlaying()) return;
- void webAudio.schedulePlayback(
+ // Decided BEFORE the transport is asked, because the three verdicts want
+ // three different fallback chains — and only one of them is the chain
+ // that existed before (#3458).
+ const route = classifyWebAudioMediaRoute(rawEl);
+ reportWebAudioMediaRoute(rawEl, route);
+ // `data-native-audio` means native output owns the track outright. It has
+ // to skip the decode path too: a successful decode would hand the element
+ // to a buffer source and mute it, which is exactly what the author used
+ // the attribute to prevent. The automatic cross-origin verdict is the
+ // opposite — decode is its BEST outcome, since a CDN that sends
+ // `Access-Control-Allow-Origin` (the author just never wrote the
+ // `crossorigin` attribute) decodes fine and keeps the whole FX graph.
+ if (route.kind === "native") continue;
+ const capture =
+ route.kind === "web-audio"
+ ? webAudio.scheduleMediaElementPlayback(
rawEl,
- buffer,
compStart,
mediaStart,
clock.now(),
vol,
gen,
state.playbackRate,
- clipDuration,
- );
- });
+ )
+ : Promise.resolve(null);
+ void capture.then((scheduled) => {
+ if (scheduled || !clock.isPlaying()) return;
+ const effectiveRate = state.playbackRate * readElementPlaybackRate(rawEl);
+ // Deliberately the FX/automation pair and NOT
+ // `nativeUnexpressibleProcessing()`, which this route's diagnostic uses.
+ // The two answer different questions: the diagnostic lists everything
+ // native output cannot carry (group bus and above-unity gain included),
+ // while this decides whether losing the graph is worse than silence.
+ // Widening it here would newly mute tracks that play today — a grouped
+ // clip at a non-unit rate among them — which is a behaviour change
+ // #3458 does not call for.
+ const hasProcessing =
+ rawEl.hasAttribute("data-fx-chain") || rawEl.hasAttribute("data-automation");
+ // A decoded AudioBufferSourceNode changes pitch whenever its playback
+ // rate is non-unit. Bare tracks may safely stay on native output; a
+ // processed track must fail closed rather than silently lose its graph.
+ if (Math.abs(effectiveRate - 1) > 1e-9) {
+ // ...but only when the transport TRIED and failed. On the
+ // cross-origin route capture was withheld on purpose, and native
+ // output is the fix — muting here would hand back the exact
+ // silence #3458 is about, now with the runtime's own blessing. The
+ // dropped processing is reported instead (`reportWebAudioMediaRoute`).
+ if (route.kind === "web-audio" && hasProcessing) rawEl.muted = true;
+ return;
+ }
+ void webAudio.decodeAudioElement(rawEl).then((buffer) => {
+ if (!buffer || !clock.isPlaying()) return;
+ void webAudio.schedulePlayback(
+ rawEl,
+ buffer,
+ compStart,
+ mediaStart,
+ clock.now(),
+ vol,
+ gen,
+ state.playbackRate,
+ clipDuration,
+ );
});
+ });
}
};
diff --git a/packages/core/src/runtime/webAudioRoute.test.ts b/packages/core/src/runtime/webAudioRoute.test.ts
new file mode 100644
index 0000000000..9a9cd7a781
--- /dev/null
+++ b/packages/core/src/runtime/webAudioRoute.test.ts
@@ -0,0 +1,260 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ classifyWebAudioMediaRoute,
+ nativeUnexpressibleProcessing,
+ reportWebAudioMediaRoute,
+} from "./webAudioRoute";
+
+const SAME_ORIGIN = window.location.origin;
+const CROSS_ORIGIN = "https://cdn.example.com";
+
+function audio(attrs: Record = {}): HTMLAudioElement {
+ const el = document.createElement("audio");
+ for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
+ return el;
+}
+
+/** jsdom never runs the resource selection algorithm, so `currentSrc` has to be
+ * planted to exercise the branch where the browser has already committed. */
+function withCurrentSrc(el: HTMLAudioElement, currentSrc: string): HTMLAudioElement {
+ Object.defineProperty(el, "currentSrc", { value: currentSrc, configurable: true });
+ return el;
+}
+
+describe("classifyWebAudioMediaRoute", () => {
+ it("routes same-origin media through Web Audio", () => {
+ expect(classifyWebAudioMediaRoute(audio({ src: "/assets/vo.mp3" }))).toEqual({
+ kind: "web-audio",
+ });
+ expect(classifyWebAudioMediaRoute(audio({ src: `${SAME_ORIGIN}/assets/vo.mp3` }))).toEqual({
+ kind: "web-audio",
+ });
+ });
+
+ it("withholds capture from cross-origin media with no CORS opt-in", () => {
+ // The bug: createMediaElementSource here builds a node that outputs silence
+ // per spec, without throwing, and permanently steals the element's native
+ // output on the way.
+ const route = classifyWebAudioMediaRoute(audio({ src: `${CROSS_ORIGIN}/track.mp3` }));
+
+ expect(route).toEqual({
+ kind: "decode-only",
+ reason: "cross_origin_no_cors",
+ asset: `${CROSS_ORIGIN}/track.mp3`,
+ });
+ });
+
+ it("treats any crossorigin attribute as the opt-in, whatever its value", () => {
+ // Enumerated attribute: the invalid-value default is `anonymous`, so every
+ // one of these makes the fetch a CORS request. Matching on "anonymous"
+ // would strip Web Audio from two of the three.
+ for (const value of ["anonymous", "use-credentials", "", "garbage"]) {
+ const el = audio({ src: `${CROSS_ORIGIN}/track.mp3`, crossorigin: value });
+ expect(classifyWebAudioMediaRoute(el)).toEqual({ kind: "web-audio" });
+ }
+ });
+
+ it("keeps non-http(s) schemes on the pre-existing path", () => {
+ // blob:/data: are same-origin by construction and file: has an opaque
+ // origin no comparison can settle — guessing there would cost Web Audio on
+ // compositions that never had this problem.
+ for (const src of ["blob:https://cdn.example.com/abc", "data:audio/mp3;base64,AAAA"]) {
+ expect(classifyWebAudioMediaRoute(audio({ src }))).toEqual({ kind: "web-audio" });
+ }
+ });
+
+ it("judges only currentSrc once the browser has committed to a resource", () => {
+ // The element ended up on a same-origin file. A cross-origin the
+ // browser passed over must not cost it its graph.
+ const el = audio();
+ el.innerHTML =
+ `` + ``;
+ withCurrentSrc(el, `${SAME_ORIGIN}/assets/fallback.mp3`);
+
+ expect(classifyWebAudioMediaRoute(el)).toEqual({ kind: "web-audio" });
+ });
+
+ it("prefers a src attribute over children, as the spec does", () => {
+ const el = audio({ src: "/assets/vo.mp3" });
+ el.innerHTML = ``;
+
+ expect(classifyWebAudioMediaRoute(el)).toEqual({ kind: "web-audio" });
+ });
+
+ it("checks every candidate while selection is still unsettled", () => {
+ // No currentSrc and no src attribute: any candidate could still win, so the
+ // conservative read is the only safe one — the node is unbuildable-back.
+ const el = audio();
+ el.innerHTML = `` + ``;
+
+ expect(classifyWebAudioMediaRoute(el)).toEqual({
+ kind: "decode-only",
+ reason: "cross_origin_no_cors",
+ asset: `${CROSS_ORIGIN}/second.mp3`,
+ });
+ });
+
+ it("routes an element with no resolvable source through Web Audio", () => {
+ expect(classifyWebAudioMediaRoute(audio())).toEqual({ kind: "web-audio" });
+ });
+
+ it("gives data-native-audio its own verdict, distinct from the CORS one", () => {
+ // Not the same as `decode-only`: this one has to skip decode too, or a
+ // decode success puts the element right back under a buffer source.
+ const el = audio({ src: "/assets/vo.mp3", "data-native-audio": "" });
+
+ expect(classifyWebAudioMediaRoute(el)).toEqual({
+ kind: "native",
+ reason: "authored_opt_out",
+ asset: "/assets/vo.mp3",
+ });
+ });
+
+ it("lets data-native-audio win over an otherwise eligible cross-origin read", () => {
+ const el = audio({ src: `${CROSS_ORIGIN}/track.mp3`, "data-native-audio": "" });
+ expect(classifyWebAudioMediaRoute(el).kind).toBe("native");
+ });
+});
+
+describe("nativeUnexpressibleProcessing", () => {
+ it("reports nothing for a bare track", () => {
+ expect(nativeUnexpressibleProcessing(audio({ src: "/a.mp3", "data-volume": "0.5" }))).toEqual(
+ [],
+ );
+ });
+
+ it("names every authored intention native output cannot carry", () => {
+ const el = audio({
+ "data-fx-chain": "[]",
+ "data-automation": "{}",
+ "data-audio-group": "vo",
+ // `el.volume` is spec-pinned to [0,1], so a boost cannot survive a route
+ // whose only gain stage is the element itself.
+ "data-volume": "2",
+ });
+
+ expect(nativeUnexpressibleProcessing(el)).toEqual([
+ "fx-chain",
+ "automation",
+ "audio-group",
+ "above-unity-gain",
+ ]);
+ });
+});
+
+describe("reportWebAudioMediaRoute", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("emits a scrapable console line and a bridge diagnostic for a CORS bypass", () => {
+ const info = vi.spyOn(console, "info").mockImplementation(() => {});
+ const post = vi.spyOn(window.parent, "postMessage").mockImplementation(() => {});
+ const el = audio({ src: `${CROSS_ORIGIN}/track.mp3` });
+
+ reportWebAudioMediaRoute(el, classifyWebAudioMediaRoute(el));
+
+ // The CLI's check gate matches this token, not the prose around it.
+ expect(info.mock.calls[0]?.[0]).toContain("[hyperframes] runtime_web_audio_bypass");
+ expect(post).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "diagnostic", code: "runtime_web_audio_bypass" }),
+ "*",
+ );
+ });
+
+ it("latches to one diagnostic per element", () => {
+ const info = vi.spyOn(console, "info").mockImplementation(() => {});
+ const el = audio({ src: `${CROSS_ORIGIN}/track.mp3` });
+ const route = classifyWebAudioMediaRoute(el);
+
+ reportWebAudioMediaRoute(el, route);
+ reportWebAudioMediaRoute(el, route);
+
+ expect(info).toHaveBeenCalledTimes(1);
+ });
+
+ it("says nothing for an eligible element", () => {
+ const info = vi.spyOn(console, "info").mockImplementation(() => {});
+ const el = audio({ src: "/assets/vo.mp3" });
+
+ reportWebAudioMediaRoute(el, classifyWebAudioMediaRoute(el));
+
+ expect(info).not.toHaveBeenCalled();
+ });
+
+ it("stays quiet for a bare data-native-audio opt-out", () => {
+ // The author asked for native output and nothing is being lost by giving
+ // it to them — honouring a request is not a finding.
+ const info = vi.spyOn(console, "info").mockImplementation(() => {});
+ const el = audio({ src: "/assets/vo.mp3", "data-native-audio": "" });
+
+ reportWebAudioMediaRoute(el, classifyWebAudioMediaRoute(el));
+
+ expect(info).not.toHaveBeenCalled();
+ });
+
+ it("still reports a data-native-audio opt-out that drops processing", () => {
+ const info = vi.spyOn(console, "info").mockImplementation(() => {});
+ const el = audio({ src: "/a.mp3", "data-native-audio": "", "data-fx-chain": "[]" });
+
+ reportWebAudioMediaRoute(el, classifyWebAudioMediaRoute(el));
+
+ expect(info.mock.calls[0]?.[0]).toContain("fx-chain");
+ });
+
+ it("names the dropped processing in the CORS bypass line", () => {
+ const info = vi.spyOn(console, "info").mockImplementation(() => {});
+ const el = audio({ src: `${CROSS_ORIGIN}/track.mp3`, "data-audio-group": "vo" });
+
+ reportWebAudioMediaRoute(el, classifyWebAudioMediaRoute(el));
+
+ expect(info.mock.calls[0]?.[0]).toContain("audio-group");
+ });
+
+ describe("render mode", () => {
+ afterEach(() => {
+ delete window.__HF_EXPORT_RENDER_SEEK_CONFIG;
+ });
+
+ it("says nothing during a render pass", () => {
+ // Render never plays through Web Audio: the producer mixes offline from
+ // the source files and applies the FX chain there, so "native playback
+ // cannot reproduce: fx-chain" would be an outright false claim — and the
+ // engine forwards every console line into producer stdout, so an ungated
+ // report lands in every render log.
+ window.__HF_EXPORT_RENDER_SEEK_CONFIG = { mode: "frames" };
+ const info = vi.spyOn(console, "info").mockImplementation(() => {});
+ const post = vi.spyOn(window.parent, "postMessage").mockImplementation(() => {});
+ const el = audio({ src: `${CROSS_ORIGIN}/track.mp3`, "data-fx-chain": "[]" });
+
+ reportWebAudioMediaRoute(el, classifyWebAudioMediaRoute(el));
+
+ expect(info).not.toHaveBeenCalled();
+ expect(post).not.toHaveBeenCalled();
+ });
+
+ it("still reports once the same element is seen outside a render pass", () => {
+ // The render-mode skip must not consume the element's one-shot latch.
+ window.__HF_EXPORT_RENDER_SEEK_CONFIG = { mode: "frames" };
+ const info = vi.spyOn(console, "info").mockImplementation(() => {});
+ const el = audio({ src: `${CROSS_ORIGIN}/track.mp3` });
+ reportWebAudioMediaRoute(el, classifyWebAudioMediaRoute(el));
+ expect(info).not.toHaveBeenCalled();
+
+ delete window.__HF_EXPORT_RENDER_SEEK_CONFIG;
+ reportWebAudioMediaRoute(el, classifyWebAudioMediaRoute(el));
+
+ expect(info).toHaveBeenCalledTimes(1);
+ });
+
+ it("leaves the verdict itself untouched — only the report is gated", () => {
+ window.__HF_EXPORT_RENDER_SEEK_CONFIG = { mode: "frames" };
+
+ expect(classifyWebAudioMediaRoute(audio({ src: `${CROSS_ORIGIN}/track.mp3` }))).toEqual({
+ kind: "decode-only",
+ reason: "cross_origin_no_cors",
+ asset: `${CROSS_ORIGIN}/track.mp3`,
+ });
+ });
+ });
+});
diff --git a/packages/core/src/runtime/webAudioRoute.ts b/packages/core/src/runtime/webAudioRoute.ts
new file mode 100644
index 0000000000..3f9bd7eb3a
--- /dev/null
+++ b/packages/core/src/runtime/webAudioRoute.ts
@@ -0,0 +1,239 @@
+import { HF_AUDIO_AUTOMATION_ATTR } from "../audioAutomation.js";
+import { HF_AUDIO_FX_ATTR } from "../audioFx.js";
+import { HF_AUDIO_GROUP_ATTR } from "../audioGroups.js";
+import { postRuntimeMessage } from "./bridge";
+import type { RuntimeJson } from "./types";
+
+/**
+ * Which transport may claim an `` element's output.
+ *
+ * `createMediaElementSource()` is the runtime's PRIMARY audio path, and it is
+ * a one-way door: the node permanently reroutes the element away from its
+ * native output and is cached for the element's lifetime. That matters because
+ * of a spec behaviour that looks nothing like a failure — per the Web Audio
+ * spec's MediaElementAudioSourceNode security section, a node built over a
+ * resource that fails the CORS-cross-origin check outputs SILENCE. It does not
+ * throw, so the `try/catch` around the call in `webAudioTransport.ts` never
+ * fires, nothing reaches `swallow()`, and the composition plays perfectly —
+ * timeline advancing, visuals animating — with no audio at all (#3458).
+ *
+ * The only defence is to decide BEFORE the call, which is what this module is:
+ * a pure classifier, so the same verdict can be reached at media-discovery time
+ * (to emit a diagnostic) and at schedule time (to actually withhold the node)
+ * without those two ever drifting apart.
+ */
+export type WebAudioMediaRoute =
+ /** Same-origin, CORS-opted-in, or a scheme the check doesn't apply to. */
+ | { kind: "web-audio" }
+ /**
+ * Cross-origin without a `crossorigin` opt-in. MediaElementSource would be
+ * silent, but `fetch` + `decodeAudioData` may still succeed — a CDN that
+ * sends `Access-Control-Allow-Origin` while the author simply never wrote the
+ * attribute is the common shape of this bug — and that route keeps the whole
+ * FX graph. So: withhold the node, still let the decode path try.
+ */
+ | { kind: "decode-only"; reason: "cross_origin_no_cors"; asset: string }
+ /**
+ * `data-native-audio`: the author took the element off Web Audio entirely.
+ * Unlike the automatic verdict this must ALSO skip decode — a decode success
+ * would put the element right back under a buffer source and mute it, which
+ * is the exact outcome the escape hatch exists to prevent.
+ */
+ | { kind: "native"; reason: "authored_opt_out"; asset: string };
+
+/**
+ * Per-element escape hatch. Origin comparison is a URL question, and some
+ * things a URL cannot answer: a same-origin path that 302s to a CDN reads as
+ * same-origin here but is cross-origin by the time the media element resolves
+ * it. A global message-passing switch can't cover that either — it races the
+ * runtime bootstrap that already scheduled the track.
+ */
+const HF_NATIVE_AUDIO_ATTR = "data-native-audio";
+
+/** Fired when the runtime withholds Web Audio capture from a media element. */
+const DIAGNOSTIC_BYPASS_CODE = "runtime_web_audio_bypass";
+
+function getAttr(el: HTMLMediaElement, name: string): string | null {
+ return typeof el.getAttribute === "function" ? el.getAttribute(name) : null;
+}
+
+function hasAttr(el: HTMLMediaElement, name: string): boolean {
+ return getAttr(el, name) !== null;
+}
+
+/**
+ * `crossorigin` is an enumerated attribute whose invalid-value default is
+ * `anonymous`, so PRESENCE is the opt-in — `crossorigin=""` and even
+ * `crossorigin="garbage"` both make the fetch a CORS request. Comparing the
+ * value against `"anonymous"` would wrongly block those.
+ */
+function hasCorsOptIn(el: HTMLMediaElement): boolean {
+ if (hasAttr(el, "crossorigin")) return true;
+ // Secondary read for a host that set the IDL property without reflecting it.
+ return typeof el.crossOrigin === "string";
+}
+
+function baseUri(el: HTMLMediaElement): string {
+ if (typeof el.baseURI === "string" && el.baseURI) return el.baseURI;
+ return typeof document !== "undefined" ? document.baseURI : "";
+}
+
+/**
+ * The URLs whose origin could decide this element's route.
+ *
+ * Order matters and mirrors the HTML resource selection algorithm. Once
+ * `currentSrc` is set the browser has COMMITTED to that resource, so it is the
+ * only candidate that can matter; a `` sibling it passed over must not
+ * cost a same-origin element its Web Audio graph. A `src` attribute is equally
+ * definitive — the spec has it win outright over `` children. Only
+ * before selection settles (no `currentSrc`, no `src`) do the ``
+ * candidates matter, and there the conservative read is right: any of them
+ * could be the one that gets picked.
+ */
+function routeCandidates(el: HTMLMediaElement): string[] {
+ const current = typeof el.currentSrc === "string" ? el.currentSrc : "";
+ if (current) return [current];
+ const srcAttr = getAttr(el, "src");
+ if (srcAttr) return [srcAttr];
+ if (typeof el.querySelectorAll !== "function") return [];
+ const sources: string[] = [];
+ for (const source of Array.from(el.querySelectorAll("source"))) {
+ const value = source.getAttribute("src");
+ if (value) sources.push(value);
+ }
+ return sources;
+}
+
+/**
+ * Whether this URL would make MediaElementSource silent. Only http(s) is
+ * judged: `blob:` and `data:` are same-origin by construction, and a `file:`
+ * page's opaque origin can't be compared meaningfully — so those keep the
+ * pre-existing behaviour rather than losing Web Audio on a guess. The guard
+ * changes behaviour ONLY in the case that is already known-broken.
+ */
+function isCorsSilenced(rawUrl: string, el: HTMLMediaElement): boolean {
+ if (typeof window === "undefined") return false;
+ let url: URL;
+ try {
+ url = new URL(rawUrl, baseUri(el));
+ } catch {
+ return false;
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
+ if (url.origin === window.location.origin) return false;
+ return !hasCorsOptIn(el);
+}
+
+function primaryAsset(el: HTMLMediaElement): string {
+ return routeCandidates(el)[0] ?? "";
+}
+
+/**
+ * Pure — no node creation, no diagnostics, no element mutation. Called from
+ * both the schedule path (where it withholds the node) and the discovery path
+ * (where it only reports), which is the point: `hyperframes check` never calls
+ * `play()`, so a verdict reachable only from the transport would be invisible
+ * to the very gate meant to surface it.
+ */
+export function classifyWebAudioMediaRoute(el: HTMLMediaElement): WebAudioMediaRoute {
+ if (hasAttr(el, HF_NATIVE_AUDIO_ATTR)) {
+ return { kind: "native", reason: "authored_opt_out", asset: primaryAsset(el) };
+ }
+ for (const candidate of routeCandidates(el)) {
+ if (isCorsSilenced(candidate, el)) {
+ return { kind: "decode-only", reason: "cross_origin_no_cors", asset: candidate };
+ }
+ }
+ return { kind: "web-audio" };
+}
+
+/**
+ * Processing the native HTMLMediaElement fallback cannot reproduce. The track
+ * stays AUDIBLE either way — silence is the bug being fixed, so failing closed
+ * would just reinstate it — but these authored intentions are quietly dropped,
+ * which is worth saying out loud.
+ *
+ * Wider than the FX/automation pair the schedule path already tests: group
+ * membership carries a whole shared bus (chain, fader, its own automation
+ * clock), and an above-unity `data-volume` cannot survive a route whose only
+ * gain stage is `el.volume`, which the spec pins to [0,1].
+ */
+export function nativeUnexpressibleProcessing(el: HTMLMediaElement): string[] {
+ const lost: string[] = [];
+ if (hasAttr(el, HF_AUDIO_FX_ATTR)) lost.push("fx-chain");
+ if (hasAttr(el, HF_AUDIO_AUTOMATION_ATTR)) lost.push("automation");
+ if (hasAttr(el, HF_AUDIO_GROUP_ATTR)) lost.push("audio-group");
+ const volume = Number.parseFloat(getAttr(el, "data-volume") ?? "");
+ if (Number.isFinite(volume) && volume > 1) lost.push("above-unity-gain");
+ return lost;
+}
+
+/**
+ * Render never plays through Web Audio at all: the producer mixes offline from
+ * the source files, and that mix applies the FX chain itself (see
+ * `applyAudioFxChain` in `packages/engine/src/services/audioMixer.ts`). So a
+ * bypass is not a fact about the render, and `lostProcessing` would be an
+ * outright false claim there — the offline mix DOES reproduce the chain the
+ * line says native output cannot. The engine forwards every console line to
+ * producer stdout, so an ungated report would also land in every render log.
+ *
+ * Reporting is all that is gated: `classifyWebAudioMediaRoute` stays pure and
+ * the routing decision is unchanged, which costs nothing in render because the
+ * element is not the audio source there in the first place.
+ *
+ * Same signal `mediaProxy.ts`'s `isRenderMode` gates on. The `` half of
+ * that check (the injected render-frame sibling) is deliberately not mirrored:
+ * only `` ever reaches this module.
+ */
+function isRenderMode(): boolean {
+ return typeof window !== "undefined" && !!window.__HF_EXPORT_RENDER_SEEK_CONFIG;
+}
+
+// One diagnostic per element. Latched only when something is actually emitted,
+// so an early "web-audio" verdict taken before the resource selection settled
+// can't suppress the real one at `loadedmetadata`.
+const diagnosedElements = new WeakSet();
+
+/**
+ * Emit the one-time diagnostic for a non-Web-Audio verdict.
+ *
+ * The automatic bypass always reports: it is an accident by definition, and the
+ * whole complaint in #3458 is that nothing was said. An authored
+ * `data-native-audio` reports only when processing is being dropped — the
+ * author asked for native output, so honouring it is not a finding.
+ */
+export function reportWebAudioMediaRoute(el: HTMLMediaElement, route: WebAudioMediaRoute): void {
+ if (route.kind === "web-audio") return;
+ if (isRenderMode()) return;
+ if (diagnosedElements.has(el)) return;
+ const lost = nativeUnexpressibleProcessing(el);
+ if (route.kind === "native" && lost.length === 0) return;
+ diagnosedElements.add(el);
+
+ const details: Record = {
+ asset: route.asset,
+ reason: route.reason,
+ lostProcessing: lost,
+ note:
+ route.reason === "cross_origin_no_cors"
+ ? "cross-origin media without a `crossorigin` opt-in is silent through createMediaElementSource (Web Audio spec); using native playback instead"
+ : "element opted out of Web Audio via data-native-audio",
+ };
+ postRuntimeMessage({
+ source: "hf-preview",
+ type: "diagnostic",
+ code: DIAGNOSTIC_BYPASS_CODE,
+ details,
+ });
+ // The stable code lives in the console text so the CLI's scraper
+ // (packages/cli/src/utils/checkBrowser.ts) can match a token, not prose —
+ // same contract mediaProxy.ts's diagnostics use.
+ const lostNote =
+ lost.length > 0
+ ? ` Native playback cannot reproduce: ${lost.join(", ")} — proxy or download the asset to a same-origin URL to keep it.`
+ : "";
+ console.info(
+ `[hyperframes] ${DIAGNOSTIC_BYPASS_CODE}: "${route.asset}" (${route.reason}): ` +
+ `Web Audio capture withheld; the track plays through native HTMLMediaElement output.${lostNote}`,
+ );
+}
diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts
index 589566a356..df42db01b4 100644
--- a/packages/core/src/runtime/webAudioTransport.test.ts
+++ b/packages/core/src/runtime/webAudioTransport.test.ts
@@ -158,6 +158,64 @@ describe("WebAudioTransport", () => {
expect(mockEl.volume).toBe(0.4);
expect(transport.isActive()).toBe(false);
});
+
+ // #3458. `createMediaElementSource` over a CORS-cross-origin resource does
+ // not throw — the Web Audio spec asks the node for SILENCE — so the
+ // `try/catch` around it never fires and the composition plays through
+ // perfectly with no sound. The node also permanently steals the element's
+ // native output, so the only possible defence is to not build it.
+ it("never builds a source node over cross-origin media with no CORS opt-in", async () => {
+ const { transport, mock, gen } = setupTransport(100);
+ const el = document.createElement("audio");
+ el.setAttribute("src", "https://cdn.example.com/track.mp3");
+ vi.spyOn(console, "info").mockImplementation(() => {});
+
+ const scheduled = await transport.scheduleMediaElementPlayback(el, 0, 0, 0, 1, gen, 1);
+
+ expect(scheduled).toBeNull();
+ expect(mock.ctx.createMediaElementSource).not.toHaveBeenCalled();
+ // Untouched: the caller falls back, and a muted or re-levelled element
+ // would take the fallback's audio down with it.
+ expect(el.muted).toBe(false);
+ expect(transport.routesElement(el)).toBe(false);
+ });
+
+ it("still routes cross-origin media that carries the crossorigin opt-in", async () => {
+ const { transport, mock, gen } = setupTransport(100);
+ const el = document.createElement("audio");
+ el.setAttribute("src", "https://cdn.example.com/track.mp3");
+ el.setAttribute("crossorigin", "anonymous");
+
+ const scheduled = await transport.scheduleMediaElementPlayback(el, 0, 0, 0, 1, gen, 1);
+
+ expect(scheduled).not.toBeNull();
+ expect(mock.ctx.createMediaElementSource).toHaveBeenCalledWith(el);
+ });
+
+ it("still routes same-origin media", async () => {
+ const { transport, mock, gen } = setupTransport(100);
+ const el = document.createElement("audio");
+ el.setAttribute("src", "/assets/vo.mp3");
+
+ const scheduled = await transport.scheduleMediaElementPlayback(el, 0, 0, 0, 1, gen, 1);
+
+ expect(scheduled).not.toBeNull();
+ expect(mock.ctx.createMediaElementSource).toHaveBeenCalledWith(el);
+ });
+
+ // The guard is enforced HERE, not only in init.ts's routing, so a direct
+ // caller (studio, player) cannot reopen the one-way door.
+ it("refuses capture for a data-native-audio opt-out even when same-origin", async () => {
+ const { transport, mock, gen } = setupTransport(100);
+ const el = document.createElement("audio");
+ el.setAttribute("src", "/assets/vo.mp3");
+ el.setAttribute("data-native-audio", "");
+
+ const scheduled = await transport.scheduleMediaElementPlayback(el, 0, 0, 0, 1, gen, 1);
+
+ expect(scheduled).toBeNull();
+ expect(mock.ctx.createMediaElementSource).not.toHaveBeenCalled();
+ });
});
it("tracks play generation for async race prevention", () => {
diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts
index 6874288ce3..418691d8d9 100644
--- a/packages/core/src/runtime/webAudioTransport.ts
+++ b/packages/core/src/runtime/webAudioTransport.ts
@@ -11,6 +11,7 @@ import { swallow } from "./diagnostics";
import { clampAudioGain } from "../audioGain.js";
import { getDebugSurface } from "./globals.js";
import { readElementPlaybackRate } from "./media.js";
+import { classifyWebAudioMediaRoute, reportWebAudioMediaRoute } from "./webAudioRoute.js";
function normalizeRate(rate: number): number {
if (!Number.isFinite(rate) || rate <= 0) return 1;
@@ -242,6 +243,36 @@ export class WebAudioTransport {
return this._playGeneration;
}
+ /**
+ * The element's cached MediaElementAudioSourceNode, building it on first use
+ * — or `null` when this element must not be captured at all.
+ *
+ * That second outcome is the whole point. `createMediaElementSource` cannot
+ * report that it produced a silent node: over a CORS-cross-origin resource
+ * the Web Audio spec asks the node for SILENCE rather than an exception, so
+ * the caller's `try/catch` never fires and the composition plays through
+ * with no audio and no error (#3458). The call also permanently reroutes the
+ * element away from its native output, so the question has to be settled
+ * before it, and there is no undo afterwards.
+ *
+ * `init.ts` routes on the same verdict before ever calling in; this stays the
+ * enforcement point so a direct caller (studio, player) cannot reopen the
+ * one-way door.
+ */
+ private acquireMediaElementSource(el: HTMLMediaElement): MediaElementAudioSourceNode | null {
+ const cached = this._mediaElementSources.get(el);
+ if (cached) return cached;
+ if (!this._ctx) return null;
+ const route = classifyWebAudioMediaRoute(el);
+ if (route.kind !== "web-audio") {
+ reportWebAudioMediaRoute(el, route);
+ return null;
+ }
+ const sourceNode = this._ctx.createMediaElementSource(el);
+ this._mediaElementSources.set(el, sourceNode);
+ return sourceNode;
+ }
+
/**
* Route the browser's pitch-preserving HTMLMediaElement transport through the
* same FX, automation, element-gain, and master graph used by final audio.
@@ -264,11 +295,8 @@ export class WebAudioTransport {
if (this._ctx.state === "suspended") await this._ctx.resume();
if (generation !== this._playGeneration) return null;
- let sourceNode = this._mediaElementSources.get(el);
- if (!sourceNode) {
- sourceNode = this._ctx.createMediaElementSource(el);
- this._mediaElementSources.set(el, sourceNode);
- }
+ const sourceNode = this.acquireMediaElementSource(el);
+ if (!sourceNode) return null;
const safeRate = normalizeRate(rate);
const gainNode = this._ctx.createGain();