Add structured NSLog instrumentation to AppDelegate.swift and debugPrint chains in voice_compact.dart::_AudioOutputPickerSheetState so we can correlate user picker taps with what iOS actually does to the route. Three diagnostic streams: * 'chanora.session[<tag>]' from Swift — full session snapshot (category, mode, sampleRate, ioBufferDuration, current route inputs+outputs, preferredInput) emitted on every setActive and every AVAudioSession.routeChangeNotification with the reason decoded (override / routeConfigurationChange / newDeviceAvailable / etc). * 'chanora.route[<tag>]' from Dart — current route's inputs+outputs emitted before/after every overrideOutputAudioPort or setPreferredInput call, plus a delayed re-check at +250 ms to detect silent reverts. * Existing 'chanora: ...' debugPrint lines from the picker now include the OK case (override returned, setPreferredInput returned) so we see a positive signal in the log when the API didn't throw. Used to root-cause the 'speaker selector not working' issue: the hypothesis is that cpal's RemoteIO AudioUnit reacts to its own format configuration notifications by triggering routeConfigurationChange that reverts our Dart-side override. The logs will confirm or deny this — if we see 'chanora.session[routeChange.override] out=Speaker' followed by 'chanora.session[routeChange.routeConfigurationChange] out=Receiver' within a few hundred ms, that's the smoking gun. Pure diagnostic commit. No behavioural change. Logs are NSLog + debugPrint so they appear in Xcode console / 'flutter logs' / the device log via Console.app or 'devicectl device log'.
207 lines
9.6 KiB
Swift
207 lines
9.6 KiB
Swift
import UIKit
|
|
import Flutter
|
|
import AVFoundation
|
|
|
|
@main
|
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
|
override func application(
|
|
_ application: UIApplication,
|
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
|
) -> Bool {
|
|
// Configure the iOS AVAudioSession **category + mode** at
|
|
// app-launch time, but DEFER setActive(true) until the scene
|
|
// is foregrounded. Calling setActive in didFinishLaunching is
|
|
// racy on iOS 17+ devices: if the user launches the app from a
|
|
// cold state, the UIApplication isn't yet `.active` and
|
|
// setActive returns `AVAudioSessionErrorCodeCannotStartPlaying`
|
|
// (561017449) — the iOS audio policy server refuses to grant
|
|
// the audio session because the app is not yet considered the
|
|
// foreground priority owner. Symptom in production builds:
|
|
// 'AVAudioSession setup failed: Error 561017449 "Session
|
|
// activation failed"' in NSLog, after which the audio engine
|
|
// is unusable until the user backgrounds + foregrounds the
|
|
// app.
|
|
//
|
|
// The category itself can be set whenever; only the active
|
|
// state needs to be deferred. We listen for
|
|
// didBecomeActiveNotification and activate then. Most
|
|
// production iOS voice apps (Discord, Zoom, FaceTime) follow
|
|
// this same shape.
|
|
do {
|
|
let session = AVAudioSession.sharedInstance()
|
|
try session.setCategory(
|
|
.playAndRecord,
|
|
mode: .voiceChat,
|
|
// Category options rationale:
|
|
//
|
|
// .allowBluetoothHFP : permit Bluetooth Hands-Free
|
|
// Profile headsets as both input
|
|
// and output. This is the protocol
|
|
// AirPods et al. use for two-way
|
|
// voice. Renamed from .allowBluetooth
|
|
// in iOS 26.
|
|
// .allowBluetoothA2DP : permit higher-quality A2DP
|
|
// output-only Bluetooth devices
|
|
// (no mic). Keeping both gives the
|
|
// broadest BT support.
|
|
//
|
|
// .defaultToSpeaker was REMOVED from this options set after
|
|
// the user-reported "speaker change not work" bug. With
|
|
// .voiceChat mode, the framework default output route is
|
|
// the receiver/earpiece (matches a phone-call UX). Setting
|
|
// .defaultToSpeaker overrides that to speakerphone by
|
|
// default \u2014 but then overrideOutputAudioPort(.none) (which
|
|
// we use when the user picks "iPhone receiver") cannot
|
|
// restore the receiver because .none simply removes the
|
|
// speaker OVERRIDE, leaving us back at the .defaultToSpeaker
|
|
// baseline which is speakerphone. So the "Receiver" picker
|
|
// option silently no-op'd.
|
|
//
|
|
// Without .defaultToSpeaker:
|
|
// * Default = receiver/earpiece (matches phone UX)
|
|
// * overrideOutputAudioPort(.speaker) -> speakerphone
|
|
// * overrideOutputAudioPort(.none) -> back to receiver
|
|
// * BT/AirPods connected -> route follows BT
|
|
// * Wired headphones -> route follows wire
|
|
//
|
|
// Net: every row in our audio output picker now has a
|
|
// route-change effect that matches its label.
|
|
options: [.allowBluetoothHFP, .allowBluetoothA2DP]
|
|
)
|
|
NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/voiceChat)")
|
|
} catch {
|
|
NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)")
|
|
}
|
|
|
|
// Activate the session once the app is actually foreground. The
|
|
// notification fires immediately after the cold-launch settles,
|
|
// and again on every resume-from-background — both safe
|
|
// moments to call setActive(true). Repeated activation while
|
|
// already-active is a no-op per the docs.
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(activateAudioSession),
|
|
name: UIApplication.didBecomeActiveNotification,
|
|
object: nil
|
|
)
|
|
|
|
// Diagnostic: subscribe to every AVAudioSession route change so
|
|
// we can correlate user picker actions with what iOS actually
|
|
// does to the route. The reason printed here ("override",
|
|
// "newDeviceAvailable", "categoryChange", etc.) tells us
|
|
// whether our Dart-side override was honoured or silently
|
|
// reverted by another component (e.g. cpal's RemoteIO unit
|
|
// reacting to its own configuration change).
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleRouteChange(_:)),
|
|
name: AVAudioSession.routeChangeNotification,
|
|
object: nil
|
|
)
|
|
|
|
// Request microphone access on first launch rather than waiting
|
|
// for the user's first voice-channel join. The latter is
|
|
// surprising: the user has only tapped "connect to server" and
|
|
// suddenly iOS pops the permission prompt because joining a
|
|
// text channel happens to trigger audio engine startup. Asking
|
|
// up-front matches user expectations for a voice-chat client.
|
|
//
|
|
// The request is asynchronous and non-blocking. If the user
|
|
// denies, voice_join will surface a clearer error later when
|
|
// the audio engine fails to open the input device. The
|
|
// permission state is cached by iOS so subsequent launches
|
|
// skip the prompt.
|
|
//
|
|
// Deferred ~1 s so iOS finishes initialising the keyboard /
|
|
// text-input subsystem before the permission alert appears.
|
|
// Firing the alert too early steals focus from the not-yet-
|
|
// ready text-input layer, with the symptom that the first tap
|
|
// on a TextField does nothing (the second tap works because
|
|
// by then iOS has caught up). DispatchQueue.main.asyncAfter
|
|
// keeps everything on the main thread; the permission API
|
|
// itself must be called there too.
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
|
NSLog("chanora_flutter: microphone permission granted=\(granted)")
|
|
}
|
|
}
|
|
|
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
|
}
|
|
|
|
/// Called by `didBecomeActiveNotification` (cold-launch settle +
|
|
/// every resume-from-background). Activates the AVAudioSession.
|
|
/// Repeated activation is a no-op when the session is already
|
|
/// active so this is safe to call on every foreground.
|
|
@objc private func activateAudioSession() {
|
|
do {
|
|
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
|
NSLog("chanora_flutter: AVAudioSession activated on foreground")
|
|
logSessionState(tag: "didBecomeActive")
|
|
} catch {
|
|
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)")
|
|
}
|
|
}
|
|
|
|
/// Log the full AVAudioSession state with a diagnostic tag. Used
|
|
/// after every state transition (setActive, route change) so we
|
|
/// can correlate user-perceived audio bugs with what iOS thinks
|
|
/// the session looks like. Output is parseable by grep
|
|
/// `chanora.session\[`.
|
|
private func logSessionState(tag: String) {
|
|
let s = AVAudioSession.sharedInstance()
|
|
let route = s.currentRoute
|
|
let outs = route.outputs.map { "\($0.portType.rawValue)/\($0.portName)" }.joined(separator: ",")
|
|
let ins = route.inputs.map { "\($0.portType.rawValue)/\($0.portName)" }.joined(separator: ",")
|
|
let preferredInput = s.preferredInput?.portName ?? "<nil>"
|
|
NSLog("""
|
|
chanora.session[\(tag)] \
|
|
cat=\(s.category.rawValue) \
|
|
mode=\(s.mode.rawValue) \
|
|
sr=\(s.sampleRate) \
|
|
ioBuf=\(String(format: "%.4f", s.ioBufferDuration)) \
|
|
out=[\(outs)] in=[\(ins)] \
|
|
preferredInput=\(preferredInput)
|
|
""")
|
|
}
|
|
|
|
/// Called by `routeChangeNotification`. iOS posts this whenever
|
|
/// the route is reconfigured for any reason — user toggles
|
|
/// Bluetooth, plugs in headphones, our `overrideOutputAudioPort`
|
|
/// call, OR (the suspected bug here) when cpal's RemoteIO
|
|
/// AudioUnit reacts to its own format change and resets the
|
|
/// route. The `reason` field tells us which case it is:
|
|
///
|
|
/// * `.override` : our Dart-side override took effect.
|
|
/// * `.routeConfigurationChange` : something else (cpal?)
|
|
/// triggered an internal reconfig.
|
|
/// * `.newDeviceAvailable` / `.oldDeviceUnavailable` :
|
|
/// user hardware change.
|
|
/// * `.categoryChange` : someone (us or another app) set a
|
|
/// new category.
|
|
@objc private func handleRouteChange(_ note: Notification) {
|
|
guard let reasonValue = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
|
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
|
|
NSLog("chanora.routeChange[unknown-reason]")
|
|
return
|
|
}
|
|
let reasonName: String
|
|
switch reason {
|
|
case .unknown: reasonName = "unknown"
|
|
case .newDeviceAvailable: reasonName = "newDeviceAvailable"
|
|
case .oldDeviceUnavailable: reasonName = "oldDeviceUnavailable"
|
|
case .categoryChange: reasonName = "categoryChange"
|
|
case .override: reasonName = "override"
|
|
case .wakeFromSleep: reasonName = "wakeFromSleep"
|
|
case .noSuitableRouteForCategory: reasonName = "noSuitableRouteForCategory"
|
|
case .routeConfigurationChange: reasonName = "routeConfigurationChange"
|
|
@unknown default: reasonName = "default(\(reasonValue))"
|
|
}
|
|
logSessionState(tag: "routeChange.\(reasonName)")
|
|
}
|
|
|
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
|
}
|
|
}
|