Skip to content

iOS silent switch mutes Web Audio: the modern fix is navigator.audioSession.type, not the silent-buffer unlock

TL;DR.

On iOS, Web Audio output is routed to the ringer channel, so the hardware silent switch mutes it while <audio> media-channel elements keep playing. The current remedy is navigator.audioSession.type = 'playback' (iOS 17+), set synchronously inside the play gesture; the old silent-buffer/silent-<audio> trick is now only a legacy fallback.

Symptom

A web page's Web Audio (Tone.js, AudioContext, WebAudio graphs) plays with no sound on iOS whenever the phone's hardware silent/mute switch is on — even though the page has an active AudioContext and the same code is audible on Android and desktop. Reproduces identically in iOS Safari, Chrome, and Brave because they are all WebKit. Confusingly, plain <audio>/<video> elements on the same page keep playing.

Cause (the surprising part)

iOS assigns each audio API to an audio session category / channel. By default the Web Audio API is routed to the ringer (ambient) channel, which the hardware silent switch mutes. HTML <audio>/<video> media elements route to the media (playback) channel, which the silent switch does not mute. So on one page, media elements play while the Web Audio graph is silent — purely a channel-routing artifact, not a bug in your audio code (WebKit bug 237322).

Fix

The modern, correct remedy is navigator.audioSession.type = 'playback' (iOS 17+ / Safari 17+), which moves Web Audio onto the media channel so the silent switch stops muting it. Two non-obvious constraints:

  1. It is not in lib.dom.d.ts — you must cast: (navigator as Navigator & { audioSession?: { type: string } }).audioSession.
  2. It should be set synchronously inside a user-gesture handler (the same click/tap that starts playback), before you .start() the audio context/Tone.

Keep the decade-old silent-buffer / silent-<audio> trick only as a legacy fallback for pre-iOS-17 devices — it is no longer the primary fix. Wrap everything in try/catch so non-iOS browsers are a clean no-op.

let silent_unlock_done = false;

/** Route Web Audio to the iOS media channel and unlock it.
 *  MUST be called synchronously inside a user-gesture handler. Idempotent.
 *  No-op (never throws) on non-iOS. */
export function unlock_ios_audio(): void {
  try {
    const nav = navigator as Navigator & { audioSession?: { type: string } };
    if (nav.audioSession) nav.audioSession.type = 'playback'; // iOS 17+ primary fix
  } catch { /* browsers without audioSession */ }

  if (silent_unlock_done) return;
  silent_unlock_done = true;
  try {
    // Legacy fallback for pre-iOS-17: play ~0.1s of silence (data-URI WAV)
    // inside the gesture to re-route subsequent Web Audio to the media channel.
    const el = document.createElement('audio');
    el.src = 'data:audio/wav;base64,...'; // 44-byte header + zeroed 8-bit mono samples
    void el.play().catch(() => {});
  } catch { /* non-critical */ }
}

Call it as the first statement of your play handler:

function toggle_play() {
  unlock_ios_audio();      // first, inside the gesture
  tone_module?.start();    // then start the graph
  // ...
}

Why this is easy to get wrong

  • Most write-ups still teach only the silent-buffer/unmute-ios-audio trick from the pre-2023 era; navigator.audioSession.type is newer and rarely surfaced, so people reach for the harder, less reliable fix.
  • The symptom (media elements audible, Web Audio silent, only when the physical switch is on) looks like an app bug, so time gets spent auditing gain nodes and context state instead of the audio-session channel.
  • audioSession missing from the TS DOM types makes it look unavailable; a cast is required.
  • Setting it outside a user gesture, or after AudioContext start, is unreliable — it must be synchronous within the tap that begins playback.

Refs: WebKit bug 237322 (Web Audio on the ringer channel); navigator.audioSession shipped iOS 17.

No signals yet