329 lines
14 KiB
Swift
329 lines
14 KiB
Swift
import UIKit
|
|
import Flutter
|
|
import AVFoundation
|
|
|
|
@main
|
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
|
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
|
private var iosPlatformChannel: FlutterMethodChannel?
|
|
|
|
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,
|
|
// Mode rationale (May 2026, .voiceChat reinstated):
|
|
//
|
|
// We previously used .default mode after discovering that
|
|
// .voiceChat routed output through iOS's in-call audio
|
|
// channel, which made speaker output barely audible. That
|
|
// bug was caused by cpal's RemoteIO unit binding to a stale
|
|
// physical transducer — after migrating to coreaudio-rs +
|
|
// kAudioUnitSubType_VoiceProcessingIO (see
|
|
// crates/chanora_audio/src/ios_voice_unit.rs) the route
|
|
// binding is correct under either mode because VPIO re-binds
|
|
// on overrideOutputAudioPort.
|
|
//
|
|
// .voiceChat advantages over .default:
|
|
// * Tells iOS this is a VoIP session — other apps' audio
|
|
// is properly ducked/paused instead of competing.
|
|
// * Enables correct Bluetooth HFP negotiation without
|
|
// manual workarounds.
|
|
// * iOS treats the audio session as a "call" for priority
|
|
// purposes (won't be interrupted by notification sounds).
|
|
// * System-level CallKit integration (lock-screen controls).
|
|
//
|
|
// .defaultToSpeaker ensures output goes to the main speaker
|
|
// (not the earpiece) by default when no headphones are
|
|
// connected, compensating for the in-call channel's tendency
|
|
// to route to the earpiece.
|
|
//
|
|
// References:
|
|
// * https://github.com/twilio/video-quickstart-ios/issues/522
|
|
// * https://stackoverflow.com/questions/79834998 (Daily.co)
|
|
//
|
|
// Options:
|
|
// .defaultToSpeaker : route output to the main speaker
|
|
// (not the earpiece) by default
|
|
// when no headphones are connected.
|
|
// .allowBluetoothHFP : permit Bluetooth Hands-Free
|
|
// Profile headsets as both input
|
|
// and output.
|
|
// .allowBluetoothA2DP : permit higher-quality A2DP
|
|
// output-only Bluetooth devices.
|
|
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
|
)
|
|
// Match VPIO / Opus frame cadence to reduce callback pressure.
|
|
try session.setPreferredIOBufferDuration(0.02)
|
|
try session.setPreferredSampleRate(48000.0)
|
|
logAudioSessionState(context: "setCategory")
|
|
} 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
|
|
)
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleRouteChange(_:)),
|
|
name: AVAudioSession.routeChangeNotification,
|
|
object: nil
|
|
)
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleInterruption(_:)),
|
|
name: AVAudioSession.interruptionNotification,
|
|
object: nil
|
|
)
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleMediaServicesReset(_:)),
|
|
name: AVAudioSession.mediaServicesWereResetNotification,
|
|
object: nil
|
|
)
|
|
|
|
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")
|
|
// Read back the ACTUAL session state. preferredSampleRate /
|
|
// preferredIOBufferDuration are hints; iOS may pick something
|
|
// else depending on hardware + currently-engaged effects.
|
|
// Without these we can't tell whether VPIO is running at
|
|
// 48 kHz mono (what our render callback assumes) or at e.g.
|
|
// 44.1 kHz (which would explain the user's broken playback
|
|
// \u2014 our render callback would be writing samples at the
|
|
// wrong rate, causing pitch + timing artifacts).
|
|
logAudioSessionState(context: "setActive")
|
|
let s = AVAudioSession.sharedInstance()
|
|
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
|
NSLog(
|
|
"chanora_flutter: AVAudioSession actual: " +
|
|
"sampleRate=\(s.sampleRate) " +
|
|
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " +
|
|
"inputs=[\(ins)] " +
|
|
"outputVolume=\(s.outputVolume)"
|
|
)
|
|
} catch {
|
|
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)")
|
|
}
|
|
}
|
|
|
|
/// Reads back the actual AVAudioSession state and logs it for
|
|
/// SDD-098 compliance. Called after both setCategory and setActive
|
|
/// to verify that the session accepted the requested configuration.
|
|
private func logAudioSessionState(context: String) {
|
|
let s = AVAudioSession.sharedInstance()
|
|
NSLog("chanora_flutter: [\(context)] category=\(s.category.rawValue) mode=\(s.mode.rawValue) options=\(s.categoryOptions.rawValue) route outputs=\(s.currentRoute.outputs.map { "\($0.portType.rawValue)" })")
|
|
if s.sampleRate != 48000.0 {
|
|
NSLog("chanora_flutter: WARNING: actual sample rate \(s.sampleRate) != requested 48000")
|
|
}
|
|
if s.ioBufferDuration > 0.025 {
|
|
NSLog("chanora_flutter: WARNING: IO buffer duration \(s.ioBufferDuration) > 25ms, may cause latency")
|
|
}
|
|
}
|
|
|
|
@objc private func handleRouteChange(_ notification: Notification) {
|
|
guard let userInfo = notification.userInfo,
|
|
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
|
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
|
|
else {
|
|
return
|
|
}
|
|
|
|
let routeDescription = AVAudioSession.sharedInstance().currentRoute
|
|
let outputs = routeDescription.outputs.map { $0.portType.rawValue }.joined(separator: ",")
|
|
NSLog("chanora_flutter: route change reason=\(reason.rawValue) outputs=\(outputs)")
|
|
|
|
// P1: Send the detailed route class to Rust on every route change,
|
|
// not just device plug/unplug. This covers:
|
|
// - .newDeviceAvailable / .oldDeviceUnavailable (headset plug/unplug)
|
|
// - .override (speaker/earpiece toggle)
|
|
// - .categoryChange (session category changed)
|
|
// - .wakeFromSleep (device woke from sleep)
|
|
// - .routeConfigurationChange (BT HFP connect/disconnect)
|
|
// The Rust side uses the route class to recompute the processing
|
|
// policy (route_policy.rs) and reset AEC delay state if needed.
|
|
let routeClass = classifyAudioRoute(routeDescription)
|
|
NSLog("chanora_flutter: route class=\(routeClass) reason=\(reason.rawValue)")
|
|
iosAudioLifecycleChannel?.invokeMethod("handleRouteChange", arguments: routeClass)
|
|
}
|
|
|
|
@objc private func handleInterruption(_ notification: Notification) {
|
|
guard let userInfo = notification.userInfo,
|
|
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
|
else {
|
|
return
|
|
}
|
|
|
|
switch type {
|
|
case .began:
|
|
NSLog("chanora_flutter: audio interruption began")
|
|
iosAudioLifecycleChannel?.invokeMethod("handleInterruptionBegan", arguments: nil)
|
|
case .ended:
|
|
let shouldResume = (userInfo[AVAudioSessionInterruptionOptionKey] as? UInt)
|
|
.map { $0 & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0 }
|
|
?? false
|
|
NSLog("chanora_flutter: audio interruption ended shouldResume=\(shouldResume)")
|
|
iosAudioLifecycleChannel?.invokeMethod("handleInterruptionEnded", arguments: shouldResume)
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
|
|
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
|
NSLog("chanora_flutter: media services reset")
|
|
do {
|
|
let session = AVAudioSession.sharedInstance()
|
|
try session.setCategory(
|
|
.playAndRecord,
|
|
mode: .voiceChat,
|
|
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
|
)
|
|
try session.setPreferredIOBufferDuration(0.02)
|
|
try session.setPreferredSampleRate(48000.0)
|
|
try session.setActive(true, options: [])
|
|
logAudioSessionState(context: "mediaServicesWereReset")
|
|
} catch {
|
|
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
|
}
|
|
// P1: After rebuilding the session, send the current route class to
|
|
// Rust so it can recompute the processing policy and reset the
|
|
// AudioUnit. The Rust side handles this via ios_handle_media_services_reset
|
|
// which calls ios_restart_voice_unit.
|
|
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
|
|
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
|
|
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
|
|
}
|
|
|
|
override func applicationWillResignActive(_ application: UIApplication) {
|
|
iosAudioLifecycleChannel?.invokeMethod("handleWillResignActive", arguments: nil)
|
|
}
|
|
|
|
override func applicationDidEnterBackground(_ application: UIApplication) {
|
|
iosAudioLifecycleChannel?.invokeMethod("handleDidEnterBackground", arguments: nil)
|
|
}
|
|
|
|
override func applicationWillEnterForeground(_ application: UIApplication) {
|
|
iosAudioLifecycleChannel?.invokeMethod("handleWillEnterForeground", arguments: nil)
|
|
}
|
|
|
|
override func applicationWillTerminate(_ application: UIApplication) {
|
|
iosAudioLifecycleChannel?.invokeMethod("handleWillTerminate", arguments: nil)
|
|
}
|
|
|
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
|
iosAudioLifecycleChannel = FlutterMethodChannel(
|
|
name: "chanora/ios_audio_lifecycle",
|
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
|
)
|
|
iosPlatformChannel = FlutterMethodChannel(
|
|
name: "chanora/ios_platform",
|
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
|
)
|
|
iosPlatformChannel?.setMethodCallHandler { call, result in
|
|
switch call.method {
|
|
case "getMicrophonePermissionState":
|
|
result(self.microphonePermissionStateString())
|
|
case "requestMicrophonePermission":
|
|
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
|
DispatchQueue.main.async {
|
|
result(granted ? "Granted" : self.microphonePermissionStateString())
|
|
}
|
|
}
|
|
case "openAppSettings":
|
|
guard let url = URL(string: UIApplication.openSettingsURLString) else {
|
|
result(false)
|
|
return
|
|
}
|
|
UIApplication.shared.open(url, options: [:]) { opened in
|
|
result(opened)
|
|
}
|
|
default:
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func classifyAudioRoute(_ route: AVAudioSessionRouteDescription) -> String {
|
|
for output in route.outputs {
|
|
switch output.portType {
|
|
case .builtInReceiver:
|
|
return "Earpiece"
|
|
case .builtInSpeaker:
|
|
return "Speaker"
|
|
case .headphones, .usbAudio:
|
|
return "WiredHeadset"
|
|
case .bluetoothHFP:
|
|
return "BluetoothHfp"
|
|
case .bluetoothA2DP:
|
|
return "BluetoothA2dp"
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
return "Unknown"
|
|
}
|
|
|
|
private func microphonePermissionStateString() -> String {
|
|
switch AVAudioSession.sharedInstance().recordPermission {
|
|
case .granted:
|
|
return "Granted"
|
|
case .denied:
|
|
return "Denied"
|
|
case .undetermined:
|
|
return "NotDetermined"
|
|
@unknown default:
|
|
return "Unknown"
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
}
|