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, // Mode rationale (revisited after the iOS VPIO migration): // // We previously tried .default mode to fix the // "speaker/receiver toggle is silent" bug \u2014 that bug was // ultimately caused by cpal's iOS RemoteIO unit binding to // a stale physical transducer, not by .voiceChat itself. // 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 // is the canonical voice unit and natively re-binds on // overrideOutputAudioPort. So .default lost its only // benefit. // // Under .default mode, VPIO's output-side voice processing // chain (echo subtraction, noise gating) interprets low- // amplitude playback signal as "no farend audio" and // aggressively gates inter-phoneme content. Symptom in // testing: musicbot (loud, continuous signal) plays fine, // human voice (peaks ~-6 dB, average ~-40 dB, classic // 20 dB peak-to-average ratio) sounds broken and // unintelligible \u2014 the quiet samples between phonemes // get gated out, destroying intelligibility. // // Apple's documentation explicitly pairs VPIO with // AVAudioSessionModeVoiceChat. WebRTC's reference iOS // ADM implementation uses the same pair. Under // .voiceChat mode VPIO's internal AGC/AEC/NS thresholds // are tuned for telephony-style speech and pass quiet // inter-phoneme content through cleanly. // // Category options unchanged \u2014 .allowBluetoothHFP + // .allowBluetoothA2DP permit BT headsets in both // directions regardless of mode. 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 ) // 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") } catch { NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)") } } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) } }