fix(core): prevent cross-origin Web Audio capture from silencing audio - #3459
fix(core): prevent cross-origin Web Audio capture from silencing audio#3459desenmeng wants to merge 1 commit into
Conversation
Cross-origin media without a crossorigin opt-in produces silence when captured through createMediaElementSource. The call succeeds instead of throwing and permanently reroutes the element, so the existing fallback cannot recover after capture. Classify audio before capture and keep three explicit routes: eligible media stays on Web Audio, unsafe cross-origin media skips capture while retaining the decode/native fallback chain, and data-native-audio opts an element out of both Web Audio paths. Recheck the route at the transport's irreversible capture boundary and account for currentSrc, src, and source children. Emit a stable preview diagnostic that hyperframes check can surface, while suppressing it during export rendering where audio is mixed offline. Keep the existing non-unit-rate fail-closed rule scoped to fx-chain and automation so this fix does not newly mute grouped or above-unity tracks. Document the escape hatch and cover routing, fallback, diagnostics, and CLI reporting with regression tests. Fixes heygen-com#3458
84f2c72 to
88f4603
Compare
miga-heygen
left a comment
There was a problem hiding this comment.
Reviewed the diff (docs/reference/html-schema.mdx, packages/cli/src/utils/checkBrowser.ts(.test.ts), packages/core/src/runtime/init.ts, webAudioRoute.ts (new), webAudioTransport.ts(.test.ts), init.test.ts) plus the un-diffed decodeAudioElement/scheduleWebAudioForActiveClips call sites to check a perf hypothesis below.
Summary
Fixes #3458: AudioContext.createMediaElementSource() silently outputs silence (no throw) for cross-origin <audio> with no crossorigin opt-in, and permanently steals the element's native output on the way. The fix introduces a pure classifyWebAudioMediaRoute classifier (web-audio / decode-only / native) consulted at three points — discovery (loadedmetadata + immediate call), schedule time in init.ts, and as an enforcement backstop inside WebAudioTransport.acquireMediaElementSource — plus a data-native-audio escape hatch and a hyperframes check-visible web_audio_bypass finding.
Correctness
- Verified the classifier's origin/candidate-selection logic against the HTML resource-selection algorithm (
currentSrc>src><source>children) — matches spec priority and is well tested, including the "browser already committed via currentSrc" and "selection still unsettled, be conservative across all<source>candidates" cases. crossoriginenumerated-attribute handling (presence, not value, is the opt-in) is correct and tested againstanonymous/use-credentials/empty/garbage values.data-native-audiocorrectly short-circuits both Web Audio capture and decode (a decode success would otherwise re-mute the element via a buffer source) — this is enforced independently inwebAudioTransport.ts, not justinit.ts, so a direct caller (studio/player) can't reopen the one-way door. Good defense-in-depth, tested (webAudioTransport.test.ts).- The non-unit-playback-rate fail-closed mute rule is correctly scoped to stay
web-audio-route-only (route.kind === "web-audio" && hasProcessing) so it doesn't newly mute grouped/above-unity-volume tracks on the new decode-only route — good catch on scope creep, and it's explicitly tested against regressing pre-#3458 behavior. - Chased a perf hypothesis: since "decode-only" is now a mainline path for ordinary cross-origin audio (not just a rare fallback), does
webAudio.decodeAudioElement()get called repeatedly (e.g. once per animation frame)? Confirmed no —decodeAudioElementhas an internal_bufferCache/_failedSrcskeyed bysrc, andscheduleWebAudioForActiveClips()(the only caller of this scheduling path) is invoked only on discrete transitions (play, playback-rate change, hidden-audio dirty), not per rAF tick. No redundant fetch/decode risk. - Render-mode gating (
isRenderMode()checked before the diagnostic and before the latch) matches the documented rationale — render mixes offline and reproduces the FX chain, so reporting a "bypass" there would be a false claim, and it's confirmed the once-per-element latch isn't consumed by a render-mode call (tested).
Type safety
RuntimeJsonrecursively permitsstring[], solostProcessing: string[]in the diagnosticdetailstypechecks againstRecord<string, RuntimeJson>. No unsafe casts introduced;getAttr/hasAttrguard against non-Elementinputs defensively.
Architecture / SSOT
classifyWebAudioMediaRouteis called at up to three points (discovery ininit.ts, schedule loop ininit.ts, andacquireMediaElementSourceinwebAudioTransport.ts). This reads as intentional defense-in-depth (comment: "this stays the enforcement point so a direct caller cannot reopen the one-way door") rather than duplicated decision logic — it's the same pure function called from multiple call sites, not reimplemented, andreportWebAudioMediaRoute'sWeakSetlatch keeps the diagnostic single-fire regardless of how many times it's (re)classified. No SSOT violation.nativeUnexpressibleProcessing()is intentionally wider (addsaudio-group,above-unity-gain) than the schedule loop's ownhasProcessingcheck (fx-chain/automationonly) — the PR comments explicitly call out why these answer different questions (diagnostic = "what's lost", fail-closed rule = "is losing FX worse than silence"). Documented rather than accidental drift.
Edge cases
- Non-http(s) schemes (
blob:,data:,file:) correctly fall through to the pre-existingweb-audiobehavior rather than guessing — reasonable, avoids new regressions on schemes the origin check can't meaningfully judge. <video>is correctly excluded (reportWebAudioRoutechecksinstanceof HTMLAudioElement) since only<audio>reachescreateMediaElementSourceper the existing capture path — tested.- One minor question, not a blocker:
reportWebAudioMediaRoutereturns early (before adding to thediagnosedElementslatch) whenroute.kind === "native"and nothing is lost. That's correct behavior (no spam, "honoring a request isn't a finding"), but it does mean that specific case recomputesnativeUnexpressibleProcessing()on every call for the lifetime of the element instead of latching a no-op result — cheap, so not worth blocking on, just noting for anyone chasing a future perf ticket.
Tests / docs
- Coverage is thorough: classifier unit tests, transport-level enforcement tests, CLI scraper prefix-anchoring test (verifies a composition author's own
console.infothat merely mentions the code isn't promoted into a finding), and an end-to-endinit.test.tssuite covering the full cross-origin/native/same-origin/video matrix plus the fail-closed-rule regression guard. - Docs (
html-schema.mdx) accurately describe the new attribute and matchnativeUnexpressibleProcessing()'s actual dropped-feature list (fx-chain, automation, audio-group, above-unity volume).
Verdict
Solid, well-reasoned fix for a nasty silent-failure class of bug (spec-mandated silence instead of a throw). Didn't find correctness, type-safety, or SSOT issues worth blocking on — the multi-call-site classification is deliberate defense-in-depth, and the perf concern I chased down doesn't materialize since decode is cached by src and the scheduling path only runs on discrete state transitions.
— Miga
miga-heygen
left a comment
There was a problem hiding this comment.
Changes Requested
Remove the data-native-audio escape hatch — the automatic detection is sufficient.
The classifyWebAudioMediaRoute classifier and isCorsSilenced origin check already cover the core bug (#3458): cross-origin <audio> elements without a crossorigin attribute are correctly routed to decode-only or native fallback. That's the fix, and it's solid.
The data-native-audio attribute handles a narrow edge case (same-origin URLs that redirect to a cross-origin CDN at request time), but URL inspection can't detect that scenario anyway — and authors who hit it can use the standard HTML solution: add crossorigin to the <audio> element, assuming the CDN sends CORS headers.
What to remove:
- The
HF_NATIVE_AUDIO_ATTRconstant and all references todata-native-audio - The
authored_opt_outreason branch inclassifyWebAudioMediaRoute - The
nativeUnexpressibleProcessingfunction (only used for the native opt-out diagnostic) - The
web_audio_bypassfinding inhyperframes check(tied to the attribute) - All test cases specific to
data-native-audiobehavior - The doc-table row describing the attribute
Keep everything else — the automatic origin detection, the decode-only route, the transport-layer enforcement, the WeakSet diagnostic latch, and the existing tests for cross-origin behavior are all good.
— Miga
What
Prevent
AudioContext.createMediaElementSource()from silently taking over and zeroing cross-origin<audio>elements that were loaded without acrossoriginopt-in.web-audio,decode-only, or explicitlynative.data-native-audioas a per-element escape hatch for redirects and other cases URL inspection cannot settle.hyperframes check, including processing that native fallback cannot reproduce.Why
The Web Audio specification requires a
MediaElementAudioSourceNodecreated from media that fails the CORS-cross-origin check to output silence. The call succeeds and does not throw, so the existingtry/catchcannot recover. Creating the node also permanently reroutes the element away from native output for its lifetime.The result is a composition whose timeline and visuals continue normally while its audio is completely silent, with no error or diagnostic. Guarding before node creation preserves audible native playback instead.
Fixes #3458.
How
The runtime now uses a pure route classifier at discovery and scheduling time:
crossoriginattribute skipcreateMediaElementSource()but still attemptfetchplusdecodeAudioData; native playback remains the final fallback.data-native-audioskips both Web Audio capture and decode.Source selection follows
currentSrc, thensrc, then<source>candidates. The transport also rechecks the route immediately before the irreversible capture call. Diagnostics are emitted during preview discovery sohyperframes checkcan observe them without playback, but are suppressed during export rendering, where audio is mixed offline. The existing non-unit-rate fail-closed policy remains limited todata-fx-chainanddata-automation.Test plan
Verified locally:
checkBrowsertests: 16 passed