feat(ios,p0): iOS P0 platform, audio fixes, channel UX
This commit is contained in:
@@ -4,6 +4,8 @@ import AVFoundation
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
@@ -98,7 +100,10 @@ import AVFoundation
|
||||
// output-only Bluetooth devices.
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
||||
)
|
||||
NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/default + defaultToSpeaker)")
|
||||
// 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)")
|
||||
}
|
||||
@@ -115,6 +120,20 @@ import AVFoundation
|
||||
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
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -161,17 +180,13 @@ import AVFoundation
|
||||
// 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 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 ins = s.currentRoute.inputs.map { "\($0.portType.rawValue)/\($0.portName)" }.joined(separator: ",")
|
||||
NSLog(
|
||||
"chanora_flutter: AVAudioSession actual: " +
|
||||
"category=\(s.category.rawValue) " +
|
||||
"mode=\(s.mode.rawValue) " +
|
||||
"sampleRate=\(s.sampleRate) " +
|
||||
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " +
|
||||
"outputs=[\(outs)] " +
|
||||
"inputs=[\(ins)] " +
|
||||
"outputVolume=\(s.outputVolume)"
|
||||
)
|
||||
@@ -180,7 +195,65 @@ import AVFoundation
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)")
|
||||
|
||||
if reason == .oldDeviceUnavailable || reason == .newDeviceAvailable {
|
||||
iosAudioLifecycleChannel?.invokeMethod("handleRouteChange", arguments: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
iosAudioLifecycleChannel = FlutterMethodChannel(
|
||||
name: "chanora/ios_audio_lifecycle",
|
||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user