feat(voice): add iOS VAD runtime support
This commit is contained in:
@@ -5,6 +5,7 @@ import AVFoundation
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
||||
private var iosPlatformChannel: FlutterMethodChannel?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
@@ -134,32 +135,12 @@ import AVFoundation
|
||||
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)")
|
||||
}
|
||||
}
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleMediaServicesReset(_:)),
|
||||
name: AVAudioSession.mediaServicesWereResetNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
@@ -182,7 +163,7 @@ import AVFoundation
|
||||
// wrong rate, causing pitch + timing artifacts).
|
||||
logAudioSessionState(context: "setActive")
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
let ins = s.currentRoute.inputs.map { "\($0.portType.rawValue)/\($0.portName)" }.joined(separator: ",")
|
||||
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||
NSLog(
|
||||
"chanora_flutter: AVAudioSession actual: " +
|
||||
"sampleRate=\(s.sampleRate) " +
|
||||
@@ -221,9 +202,18 @@ import AVFoundation
|
||||
let outputs = routeDescription.outputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||
NSLog("chanora_flutter: route change reason=\(reason.rawValue) outputs=\(outputs)")
|
||||
|
||||
if reason == .oldDeviceUnavailable || reason == .newDeviceAvailable {
|
||||
iosAudioLifecycleChannel?.invokeMethod("handleRouteChange", arguments: nil)
|
||||
}
|
||||
// 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) {
|
||||
@@ -249,11 +239,115 @@ import AVFoundation
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
||||
NSLog("chanora_flutter: media services reset")
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<!-- Opt into ProMotion / high-refresh-rate CADisplayLink ranges on
|
||||
supported iPhones. Flutter's iOS embedder reads this key; no
|
||||
additional Flutter package is required for dynamic refresh. -->
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -74,11 +77,6 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<!-- Make Chanora's Documents folder visible to the Files.app and
|
||||
accessible via iTunes / Finder file-sharing. We write
|
||||
diagnostic logs (chanora.log) into Documents/ so users can
|
||||
export them for support. Both keys are required for the
|
||||
"On My iPhone -> Chanora" listing to appear in Files.app. -->
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
|
||||
Reference in New Issue
Block a user