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) } }