Merge pull request 'No audio over Bluetooth on iOS: .defaultToSpeaker is an override that outranks A2DP' (#126) from worktree-ios-bluetooth-audio-route into main
ci / bun-nix (push) Successful in 37s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 19s
docker / builders-arm64cross (push) Successful in 14s
ci / web (push) Successful in 2m17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m32s
ci / docs-site (push) Successful in 2m37s
ci / rust-arm64 (push) Successful in 3m44s
apple / swift (push) Successful in 1m37s
ci / rust (push) Successful in 6m29s
docker / deploy-docs (push) Failing after 6m50s
release / apple (push) Successful in 9m45s
apple / screenshots (push) Successful in 5m52s
ci / bun-nix (push) Successful in 37s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 19s
docker / builders-arm64cross (push) Successful in 14s
ci / web (push) Successful in 2m17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m32s
ci / docs-site (push) Successful in 2m37s
ci / rust-arm64 (push) Successful in 3m44s
apple / swift (push) Successful in 1m37s
ci / rust (push) Successful in 6m29s
docker / deploy-docs (push) Failing after 6m50s
release / apple (push) Successful in 9m45s
apple / screenshots (push) Successful in 5m52s
Reviewed-on: #126
This commit was merged in pull request #126.
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