feat(ios,p0): iOS P0 platform, audio fixes, channel UX

This commit is contained in:
Edison Jwa
2026-05-17 22:00:00 +09:00
parent a1fefc8ab6
commit 7a59f5b9a1
38 changed files with 1705 additions and 674 deletions
@@ -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()
)
}
}