No audio over Bluetooth on iOS: .defaultToSpeaker is an override that outranks A2DP #126

Merged
enricobuehler merged 1 commits from worktree-ios-bluetooth-audio-route into main 2026-08-08 21:54:28 +00:00
Owner

Field report on v0.25.0, iOS: "No audio over Bluetooth on iOS, plays through speakers if Mic input is enabled."

Both halves are one bug, and it hit every Bluetooth listener on stock settings. The shipped v0.25.0 tag carries it (SessionAudio.swift:145).

What was wrong

The iOS session set options: [.allowBluetoothA2DP, .defaultToSpeaker].

.defaultToSpeaker reads like "prefer the speaker over the earpiece". It is actually an output override, and it outranks an A2DP route — with it set, a connected Bluetooth headset loses the stream to the phone's own speaker.

Wired headphones beat it; Bluetooth does not. A cable is the one way to test this and get the right answer, which is exactly what the comment sitting on that line asserted:

// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone EARPIECE; only
// affects the built-in route (headphones/BT still win).

A wrong comment that agrees with a cable test is why this survived.

And it was not an edge case: EffectiveSettings defaults micEnabled = true and echoCancel = true, so the stock iOS session is .playAndRecord — the broken branch. The reporter's own workaround is the tell: mic off takes .playback, which routes to A2DP happily and always did.

The fix

Decide against the route we were actually given, rather than pre-emptively:

  1. Drop .defaultToSpeaker; keep .allowBluetoothA2DP.
  2. After activation, steerBuiltInOutputToSpeaker — override to the speaker only if the current output is .builtInReceiver. Bluetooth, wired, CarPlay and AirPlay are left strictly alone. Idempotent: an override in force reads back as .builtInSpeaker, never .builtInReceiver, so it never fights its own result.
  3. Add a routeChangeNotification observer. This is mandatory, not polish — iOS drops the override on every route change (that is what lets a newly-connected headset win), so it has to be re-applied per route. Without it, removing .defaultToSpeaker would be a regression: dropping Bluetooth mid-stream would hand the game to the earpiece.

The observer is installed only for a .playAndRecord session (a .playback one needs no steering), removed in stop() before the session deactivate, with deinit as a backstop, and its work is hopped onto the shared sessionQueue since the session API blocks on the audio server.

Deliberately not .allowBluetooth

It would make a headset's mic usable, but buys that by dragging the whole route onto HFP/SCO and collapsing game audio to narrowband. High-quality A2DP output plus the built-in mic is the better trade for a game-streaming client.

Verification

  • arm64-apple-ios17.0Build complete! (the triple that actually compiles these #if os(iOS) blocks; a plain swift build is macOS and skips them entirely)
  • arm64-apple-tvos17.0 — Build complete!
  • macOS — Build complete!
  • 257 Swift tests, 0 failures (5 skipped)

On-glass iPhone + Bluetooth listen still owed. The routing claim is settled from the reporter's symptom and it compiles on all three platforms, but nobody has heard the fix yet.

Adjacent gap found, not fixed here

There is no AVAudioEngine.configurationChangeNotification observer anywhere in the Apple client. A route change that alters the hardware format stops the engine and invalidates its connections, and nothing restarts it — a likely suspect for any "audio just stopped after I plugged/unplugged something mid-stream" report. Left alone on purpose: engine-rebuild surgery is not worth doing blind, without a device to test on.

Field report on **v0.25.0, iOS**: *"No audio over Bluetooth on iOS, plays through speakers if Mic input is enabled."* Both halves are one bug, and it hit **every** Bluetooth listener on stock settings. The shipped `v0.25.0` tag carries it (`SessionAudio.swift:145`). ## What was wrong The iOS session set `options: [.allowBluetoothA2DP, .defaultToSpeaker]`. `.defaultToSpeaker` reads like "prefer the speaker over the earpiece". It is actually an **output override, and it outranks an A2DP route** — with it set, a connected Bluetooth headset loses the stream to the phone's own speaker. **Wired headphones beat it; Bluetooth does not.** A cable is the one way to test this and get the *right* answer, which is exactly what the comment sitting on that line asserted: ```swift // .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone EARPIECE; only // affects the built-in route (headphones/BT still win). ``` A wrong comment that agrees with a cable test is why this survived. And it was not an edge case: `EffectiveSettings` defaults **`micEnabled = true` and `echoCancel = true`**, so the stock iOS session is `.playAndRecord` — the broken branch. The reporter's own workaround is the tell: mic off takes `.playback`, which routes to A2DP happily and always did. ## The fix Decide against the route we were **actually given**, rather than pre-emptively: 1. Drop `.defaultToSpeaker`; keep `.allowBluetoothA2DP`. 2. After activation, `steerBuiltInOutputToSpeaker` — override to the speaker **only if** the current output is `.builtInReceiver`. Bluetooth, wired, CarPlay and AirPlay are left strictly alone. Idempotent: an override in force reads back as `.builtInSpeaker`, never `.builtInReceiver`, so it never fights its own result. 3. Add a `routeChangeNotification` observer. **This is mandatory, not polish** — iOS drops the override on every route change (that is what lets a newly-connected headset win), so it has to be re-applied per route. Without it, removing `.defaultToSpeaker` would be a *regression*: dropping Bluetooth mid-stream would hand the game to the earpiece. The observer is installed only for a `.playAndRecord` session (a `.playback` one needs no steering), removed in `stop()` *before* the session deactivate, with `deinit` as a backstop, and its work is hopped onto the shared `sessionQueue` since the session API blocks on the audio server. ### Deliberately not `.allowBluetooth` It would make a headset's *mic* usable, but buys that by dragging the whole route onto HFP/SCO and collapsing game audio to narrowband. High-quality A2DP output plus the built-in mic is the better trade for a game-streaming client. ## Verification - `arm64-apple-ios17.0` — **Build complete!** (the triple that actually compiles these `#if os(iOS)` blocks; a plain `swift build` is macOS and skips them entirely) - `arm64-apple-tvos17.0` — Build complete! - macOS — Build complete! - **257 Swift tests, 0 failures** (5 skipped) ⏳ **On-glass iPhone + Bluetooth listen still owed.** The routing claim is settled from the reporter's symptom and it compiles on all three platforms, but nobody has *heard* the fix yet. ## Adjacent gap found, not fixed here There is **no `AVAudioEngine.configurationChangeNotification` observer anywhere in the Apple client**. A route change that alters the hardware format stops the engine and invalidates its connections, and nothing restarts it — a likely suspect for any "audio just stopped after I plugged/unplugged something mid-stream" report. Left alone on purpose: engine-rebuild surgery is not worth doing blind, without a device to test on.
enricobuehler added 1 commit 2026-08-08 21:51:52 +00:00
fix(apple): .defaultToSpeaker outranks Bluetooth, so every headset lost the stream
ci / bun-nix (pull_request) Successful in 57s
ci / docs-site (pull_request) Successful in 1m21s
ci / web (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 2m42s
apple / swift (pull_request) Successful in 1m55s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 12m44s
07f6d6f324
Field report on 0.25, iOS: "no audio over Bluetooth ... plays through speakers
if Mic input is enabled".

Both halves are one bug. `micEnabled` and `echoCancel` both default to true
(EffectiveSettings.swift), so the DEFAULT iOS session is `.playAndRecord` — and
that branch set `.defaultToSpeaker`. That option is not the polite preference it
reads as: it is an output OVERRIDE, and it outranks an A2DP route. Wired
headphones beat it, Bluetooth does not, so a cable is the one way to test it and
get the right answer — which is what the comment sitting on it asserted
("headphones/BT still win"). Every Bluetooth listener on the default settings got
the phone's own speaker instead. Turning the mic off was the accidental
workaround the reporter found: that path takes `.playback`, which routes to A2DP
happily and always did.

The earpiece problem `.defaultToSpeaker` was reaching for is real —
`.playAndRecord` really does park the built-in output on the receiver. So solve
it against the route we were ACTUALLY given rather than pre-emptively: after
activation, if the current output is `.builtInReceiver`, override to the speaker;
anything external (Bluetooth, wired, CarPlay, AirPlay) is left strictly alone.

That override is a property of the current route — iOS drops it whenever the
route changes, which is exactly what lets a newly-connected headset win — so it
has to be re-applied per route. Hence the route-change observer: without it,
dropping Bluetooth mid-stream would hand the game to the earpiece. Registered
only for a `.playAndRecord` session (a `.playback` one needs no steering),
removed in stop() before the session deactivate, with deinit as a backstop.

Deliberately NOT adding `.allowBluetooth`: it would make a headset's mic usable,
but buys that by dragging the whole route onto HFP/SCO and collapsing game audio
to narrowband. High-quality A2DP output plus the built-in mic is the better trade
for a game-streaming client.

Verified: builds clean on arm64-apple-ios17.0 (the triple that actually compiles
these `#if os(iOS)` blocks — a plain `swift build` is macOS and skips them),
arm64-apple-tvos17.0, and macOS; 257 Swift tests pass, 0 failures.
On-glass iPhone + Bluetooth listen still owed.
enricobuehler merged commit e22082ac2a into main 2026-08-08 21:54:28 +00:00
enricobuehler deleted branch worktree-ios-bluetooth-audio-route 2026-08-08 21:54:32 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#126