Skip to content

createMediaElementSource silently mutes cross-origin media that has no CORS opt-in #3458

Description

@desenmeng

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

  1. 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.

  2. 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.

  3. (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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions