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
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
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.
This commit is contained in:
@@ -79,6 +79,13 @@ public final class SessionAudio {
|
||||
/// session's activate.
|
||||
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
|
||||
#endif
|
||||
#if os(iOS)
|
||||
/// Live only for a `.playAndRecord` session: the token for the route-change observer that
|
||||
/// keeps the BUILT-IN output on the speaker rather than the earpiece (see
|
||||
/// `steerBuiltInOutputToSpeaker`). A `.playback` session already prefers the speaker and
|
||||
/// never needs steering, so the mic-off path installs nothing. Guarded by `stateLock`.
|
||||
private var routeObserver: NSObjectProtocol?
|
||||
#endif
|
||||
|
||||
public init(connection: PunktfunkConnection) {
|
||||
self.connection = connection
|
||||
@@ -89,6 +96,11 @@ public final class SessionAudio {
|
||||
/// Engine teardown still belongs to stop().
|
||||
deinit {
|
||||
flag.stop()
|
||||
#if os(iOS)
|
||||
// The observer only holds self weakly, so we can be deinited with it still registered;
|
||||
// drop the token here too rather than leaking it when an owner skips stop().
|
||||
if let routeObserver { NotificationCenter.default.removeObserver(routeObserver) }
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
|
||||
@@ -138,11 +150,29 @@ public final class SessionAudio {
|
||||
do {
|
||||
#if os(iOS)
|
||||
if micEnabled {
|
||||
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone EARPIECE; only
|
||||
// affects the built-in route (headphones/BT still win).
|
||||
// NO .defaultToSpeaker here, deliberately. It reads like "prefer the speaker over
|
||||
// the earpiece", and the comment that used to sit here claimed headphones and
|
||||
// Bluetooth still won. That is true of WIRED headphones and false of Bluetooth —
|
||||
// a cable is the one way to test this and see the right answer. It is an
|
||||
// OVERRIDE, and it outranks an A2DP route: with it set, every Bluetooth headset
|
||||
// lost the stream to the phone's own speaker. That is the 0.25 field report ("no
|
||||
// audio over Bluetooth ... plays through speakers if Mic input is enabled") — mic
|
||||
// and echo cancellation both default to ON, so this branch is the DEFAULT path
|
||||
// and every Bluetooth listener hit it; turning the mic off was the accidental
|
||||
// workaround, because that lands on `.playback` below, which routes to A2DP
|
||||
// happily.
|
||||
//
|
||||
// The earpiece problem it was reaching for is real, so it is solved after
|
||||
// activation instead, against the route we were ACTUALLY given —
|
||||
// see `steerBuiltInOutputToSpeaker`.
|
||||
//
|
||||
// `.allowBluetoothA2DP` alone, also deliberately: adding `.allowBluetooth` would
|
||||
// make a headset's MIC usable, but it 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.
|
||||
try session.setCategory(
|
||||
.playAndRecord, mode: .default,
|
||||
options: [.allowBluetoothA2DP, .defaultToSpeaker])
|
||||
options: [.allowBluetoothA2DP])
|
||||
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
|
||||
// quantum is most of the mic path's burst latency). Best-effort — the hardware
|
||||
// has the final word (a Bluetooth route will ignore both), and whatever quantum
|
||||
@@ -156,12 +186,66 @@ public final class SessionAudio {
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
#endif
|
||||
try session.setActive(true)
|
||||
#if os(iOS)
|
||||
// Only the `.playAndRecord` session can land on the earpiece, and only it accepts an
|
||||
// output override — so the mic-off (`.playback`) path deliberately does neither.
|
||||
if micEnabled {
|
||||
steerBuiltInOutputToSpeaker(session)
|
||||
installRouteObserver()
|
||||
}
|
||||
#endif
|
||||
} catch {
|
||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// `.playAndRecord` parks the BUILT-IN output on the earpiece — right for a phone call,
|
||||
/// useless for a game. Move it to the speaker, but ONLY when the route we were actually given
|
||||
/// is the receiver: anything external (Bluetooth, wired, CarPlay, AirPlay) is left strictly
|
||||
/// alone. That "look first" is the whole difference between this and the `.defaultToSpeaker`
|
||||
/// option it replaced, which forced the speaker unconditionally and so beat Bluetooth.
|
||||
///
|
||||
/// Idempotent and cheap, so the route observer can simply call it again.
|
||||
private func steerBuiltInOutputToSpeaker(_ session: AVAudioSession) {
|
||||
// An override already in force shows up as `.builtInSpeaker`, not `.builtInReceiver`, so
|
||||
// re-running this never fights its own previous result.
|
||||
guard session.currentRoute.outputs.contains(where: { $0.portType == .builtInReceiver })
|
||||
else { return }
|
||||
do {
|
||||
try session.overrideOutputAudioPort(.speaker)
|
||||
} catch {
|
||||
log.warning("could not move audio off the earpiece: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes change under a live session: a headset connects mid-stream, or disconnects and hands
|
||||
/// the stream back to the built-in output. iOS drops an output override whenever the route
|
||||
/// changes — which is what lets a newly-connected headset win — so the earpiece steer is a
|
||||
/// property of the CURRENT route and has to be re-applied per route. Without this, dropping
|
||||
/// Bluetooth mid-stream would land the game on the earpiece.
|
||||
private func installRouteObserver() {
|
||||
let observer = NotificationCenter.default.addObserver(
|
||||
forName: AVAudioSession.routeChangeNotification,
|
||||
object: AVAudioSession.sharedInstance(), queue: nil
|
||||
) { [weak self] _ in
|
||||
// Arrives on whatever thread AVFoundation posts it from, and the session API blocks
|
||||
// on the audio server — so do the work on the shared session queue, like every
|
||||
// other call into it.
|
||||
SessionAudio.sessionQueue.async {
|
||||
guard let self, !self.flag.isStopped else { return }
|
||||
self.steerBuiltInOutputToSpeaker(AVAudioSession.sharedInstance())
|
||||
}
|
||||
}
|
||||
stateLock.lock()
|
||||
let stale = routeObserver
|
||||
routeObserver = observer
|
||||
stateLock.unlock()
|
||||
if let stale { NotificationCenter.default.removeObserver(stale) }
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Build + start the engines — combined (voice-processed) or split, per `wantsCombined` —
|
||||
/// with the mic uplink only when enabled + authorized. Main thread (engine setup); on
|
||||
/// iOS/tvOS the session is already active by the time this runs.
|
||||
@@ -249,7 +333,16 @@ public final class SessionAudio {
|
||||
combinedEngine = nil
|
||||
let wasDraining = drainStarted
|
||||
drainStarted = false
|
||||
#if os(iOS)
|
||||
let route = routeObserver
|
||||
routeObserver = nil
|
||||
#endif
|
||||
stateLock.unlock()
|
||||
#if os(iOS)
|
||||
// Before the deactivate below, so a route change during teardown can't re-steer a session
|
||||
// we are in the middle of releasing.
|
||||
if let route { NotificationCenter.default.removeObserver(route) }
|
||||
#endif
|
||||
if let capture {
|
||||
capture.inputNode.removeTap(onBus: 0)
|
||||
capture.stop()
|
||||
|
||||
Reference in New Issue
Block a user