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:
Drop .defaultToSpeaker; keep .allowBluetoothA2DP.
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.
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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.0tag carries it (SessionAudio.swift:145).What was wrong
The iOS session set
options: [.allowBluetoothA2DP, .defaultToSpeaker]..defaultToSpeakerreads 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:
A wrong comment that agrees with a cable test is why this survived.
And it was not an edge case:
EffectiveSettingsdefaultsmicEnabled = trueandechoCancel = 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:
.defaultToSpeaker; keep.allowBluetoothA2DP.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.routeChangeNotificationobserver. 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.defaultToSpeakerwould be a regression: dropping Bluetooth mid-stream would hand the game to the earpiece.The observer is installed only for a
.playAndRecordsession (a.playbackone needs no steering), removed instop()before the session deactivate, withdeinitas a backstop, and its work is hopped onto the sharedsessionQueuesince the session API blocks on the audio server.Deliberately not
.allowBluetoothIt 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 plainswift buildis macOS and skips them entirely)arm64-apple-tvos17.0— Build complete!⏳ 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.configurationChangeNotificationobserver 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 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.