createMediaElementSource silently mutes cross-origin media that has no CORS opt-in
Package: @hyperframes/core (runtime) — reproduced on 0.8.10 and 0.8.12; 0.8.2 is unaffected (it has no createMediaElementSource call site).
Summary
The runtime routes every playing <audio data-start> through
AudioContext.createMediaElementSource(). When the element's media is
cross-origin and the element has no crossorigin attribute, the Web Audio spec
requires the captured output to be zeroed:
If the media element's resource has a different origin than the document and
the element does not satisfy the CORS-cross-origin check, the node outputs
silence.
So the composition looks perfectly healthy and is completely silent:
- the element keeps playing,
currentTime advances, visuals animate
- no exception is thrown, nothing is logged
paused === false, muted === false, volume === 1
This is the common shape for any composition whose audio lives on a third-party
CDN (podcast enclosures, most media hosts), because such CDNs usually don't send
Access-Control-Allow-Origin, and adding crossorigin="anonymous" without it
makes the element fail to load at all.
Measurement
Same composition, same runtime, same player, only the audio origin differs.
Peak measured on the master bus with an AnalyserNode:
| audio |
capture |
master-bus peak |
cross-origin, no crossorigin attribute |
succeeds |
0 |
cross-origin, crossorigin="anonymous" + Access-Control-Allow-Origin: * |
succeeds |
0.366 |
| same-origin |
succeeds |
0.366 |
Repro
Two origins: A serves the page, B serves the audio with no CORS headers.
<!-- served from origin A, cross-origin audio from origin B -->
<!doctype html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/core@0.8.12/dist/hyperframe.runtime.iife.js"></script>
</head>
<body>
<div data-composition-id="demo" data-width="1080" data-height="1920" data-duration="8">
<audio id="demo-audio" src="https://origin-b.example/tone.wav" data-start="0" data-duration="8" data-volume="1"></audio>
</div>
</body>
</html>
Play it: the timeline runs, the element plays, there is no sound. Serve the same
file from origin A instead, or add Access-Control-Allow-Origin: * on B plus
crossorigin="anonymous" on the element, and the audio is heard.
The existing escape hatch can't be used reliably
postMessage({source: "hf-parent", type: "control", action: "set-web-audio-media-disabled", disabled: true})
does produce the right behaviour — but it can only ever race the problem:
- the capture happens during the runtime's own bootstrap play
createMediaElementSource is permanent for the lifetime of the element, so a
message that arrives after the first capture changes nothing (a later capture
attempt on that element throws InvalidStateError)
- the runtime's control-message listener registers during its DOMContentLoaded
bootstrap, while the player's ready probe polls for globals the runtime IIFE
sets at parse time — so a message sent on ready can land in the gap and be
dropped
In practice, sending it from the embedder on ready, and again on the runtime's
own {source: "hf-preview", type: "ready"} broadcast, still left roughly 4 out
of 5 page loads silent.
Suggested fixes
-
Guard before capturing. Skip createMediaElementSource for an element
whose currentSrc is cross-origin and which has no crossorigin attribute,
and fall through to the existing native-playback path — the same path the
runtime already takes when the capture fails or when there are no active
sources. A diagnostic would make the trade-off visible ("routing X natively:
capture would be silent without CORS").
This alone fixes the class of bug: the runtime never chooses a path that is
guaranteed to be silent.
-
Make the switch declarative. A player attribute (e.g.
web-audio-media="off") or a value the runtime reads at bootstrap would let
an embedder opt out without having to win a message race. Today the only
route is a control message whose delivery window is not under the embedder's
control.
-
(optional) A per-element opt-out, e.g. data-native-audio on the media
element, for compositions that mix same-origin FX-processed audio with a
third-party track.
Workaround, for anyone hitting this
Install a tiny script ahead of the runtime that makes
createMediaElementSource throw a SecurityError for exactly those elements —
cross-origin, no crossorigin attribute. The runtime already handles a failed
capture gracefully (it keeps no active source, and its transport tick then plays
the element natively), so the result is audible playback with no ordering
assumption. It is a no-op for same-origin and CORS-enabled media, which keep the
full Web Audio path.
createMediaElementSource silently mutes cross-origin media that has no CORS opt-in
Package:
@hyperframes/core(runtime) — reproduced on 0.8.10 and 0.8.12; 0.8.2 is unaffected (it has nocreateMediaElementSourcecall site).Summary
The runtime routes every playing
<audio data-start>throughAudioContext.createMediaElementSource(). When the element's media iscross-origin and the element has no
crossoriginattribute, the Web Audio specrequires the captured output to be zeroed:
So the composition looks perfectly healthy and is completely silent:
currentTimeadvances, visuals animatepaused === false,muted === false,volume === 1This is the common shape for any composition whose audio lives on a third-party
CDN (podcast enclosures, most media hosts), because such CDNs usually don't send
Access-Control-Allow-Origin, and addingcrossorigin="anonymous"without itmakes the element fail to load at all.
Measurement
Same composition, same runtime, same player, only the audio origin differs.
Peak measured on the master bus with an
AnalyserNode:crossoriginattributecrossorigin="anonymous"+Access-Control-Allow-Origin: *Repro
Two origins: A serves the page, B serves the audio with no CORS headers.
Play it: the timeline runs, the element plays, there is no sound. Serve the same
file from origin A instead, or add
Access-Control-Allow-Origin: *on B pluscrossorigin="anonymous"on the element, and the audio is heard.The existing escape hatch can't be used reliably
postMessage({source: "hf-parent", type: "control", action: "set-web-audio-media-disabled", disabled: true})does produce the right behaviour — but it can only ever race the problem:
createMediaElementSourceis permanent for the lifetime of the element, so amessage that arrives after the first capture changes nothing (a later capture
attempt on that element throws
InvalidStateError)bootstrap, while the player's
readyprobe polls for globals the runtime IIFEsets at parse time — so a message sent on
readycan land in the gap and bedropped
In practice, sending it from the embedder on
ready, and again on the runtime'sown
{source: "hf-preview", type: "ready"}broadcast, still left roughly 4 outof 5 page loads silent.
Suggested fixes
Guard before capturing. Skip
createMediaElementSourcefor an elementwhose
currentSrcis cross-origin and which has nocrossoriginattribute,and fall through to the existing native-playback path — the same path the
runtime already takes when the capture fails or when there are no active
sources. A diagnostic would make the trade-off visible ("routing X natively:
capture would be silent without CORS").
This alone fixes the class of bug: the runtime never chooses a path that is
guaranteed to be silent.
Make the switch declarative. A player attribute (e.g.
web-audio-media="off") or a value the runtime reads at bootstrap would letan embedder opt out without having to win a message race. Today the only
route is a control message whose delivery window is not under the embedder's
control.
(optional) A per-element opt-out, e.g.
data-native-audioon the mediaelement, for compositions that mix same-origin FX-processed audio with a
third-party track.
Workaround, for anyone hitting this
Install a tiny script ahead of the runtime that makes
createMediaElementSourcethrow aSecurityErrorfor exactly those elements —cross-origin, no
crossoriginattribute. The runtime already handles a failedcapture gracefully (it keeps no active source, and its transport tick then plays
the element natively), so the result is audible playback with no ordering
assumption. It is a no-op for same-origin and CORS-enabled media, which keep the
full Web Audio path.