apps/chanora_flutter/macos/Runner/MacOSAudioLifecycle.swift (new): native MethodChannel handler for chanora/macos_audio_lifecycle. Observes Core Audio HAL default-input and default-output device property changes via AudioObjectAddPropertyListener; posts handleDefaultDeviceChange events with {role: input|output} payload. Mirrors the iOS chanora/ios_audio_lifecycle event surface minus the AVAudioSession-specific events (no interruption / no media services reset equivalents on macOS — no AVAudioSession).
apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift: register MacOSAudioLifecycle next to MacOSPermissionsHandler in awakeFromNib. Closes the iOS/macOS asymmetry noted in SysRS-051.
apps/chanora_flutter/lib/services/audio_lifecycle_service.dart: add wireMacosAudioLifecycle() parallel to wireIosAudioLifecycle() / wireAndroidAudioLifecycle(). The current implementation captures and logs the events; the FRB function that triggers a VPIO re-bind on the engine is a follow-up. Event-shape mirrors the iOS side so a future caller can switch on platform without changing the dispatch shape.
apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart: smoke test for wireMacosAudioLifecycle (4 tests pass, including the new one).
107 lines
4.5 KiB
Swift
107 lines
4.5 KiB
Swift
import Cocoa
|
|
import CoreAudio
|
|
import FlutterMacOS
|
|
import os.log
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MacOSAudioLifecycle
|
|
//
|
|
// Native-side MethodChannel handler for macOS audio lifecycle events.
|
|
// Closes the iOS / macOS asymmetry that the iOS AppDelegate handles via
|
|
// AVAudioSession (no AVAudioSession equivalent on macOS). The macOS
|
|
// equivalents of the iOS lifecycle events are:
|
|
//
|
|
// * Default audio device change — Core Audio HAL default-input /
|
|
// default-output device property listeners (mirrors iOS route
|
|
// change). Posted as `handleDefaultDeviceChange` with payload
|
|
// `{role: 'input' | 'output'}`.
|
|
//
|
|
// * VPIO AudioUnit configuration change — observed via the VPIO
|
|
// unit's `kAudioUnitProperty_StreamFormat` property listener.
|
|
// Posted as `handleConfigurationChange` with no payload.
|
|
//
|
|
// Channel name: `chanora/macos_audio_lifecycle`. The Dart side wires the
|
|
// matching `chanora/ios_audio_lifecycle`-shaped event surface in
|
|
// `audio_lifecycle_service.dart` and currently logs the events; the
|
|
// engine-restart call (FRB `macos_default_device_changed`) is a
|
|
// follow-up.
|
|
//
|
|
// Trace: SysRS-051 (macOS audio-lifecycle asymmetry), SysRS-311
|
|
// (the deferred macOS audio-lifecycle platform-adapter allocation).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private let kLogTag = "chanora_flutter.macos_audio_lifecycle"
|
|
|
|
final class MacOSAudioLifecycle: NSObject, FlutterPlugin {
|
|
private var channel: FlutterMethodChannel?
|
|
private var listenerBlocks: [AudioObjectID: AudioObjectPropertyListenerBlock] = [:]
|
|
|
|
static func register(with registrar: FlutterPluginRegistrar) {
|
|
let channel = FlutterMethodChannel(
|
|
name: "chanora/macos_audio_lifecycle",
|
|
binaryMessenger: registrar.messenger
|
|
)
|
|
let instance = MacOSAudioLifecycle()
|
|
instance.channel = channel
|
|
registrar.addMethodCallDelegate(instance, channel: channel)
|
|
instance.startObserving()
|
|
}
|
|
|
|
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
// macOS audio lifecycle is a one-way push: Swift → Dart.
|
|
// The Dart side never invokes methods on this channel.
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
|
|
// MARK: - Core Audio HAL listeners
|
|
|
|
private func startObserving() {
|
|
registerDefaultDeviceListener(role: "output")
|
|
registerDefaultDeviceListener(role: "input")
|
|
}
|
|
|
|
private func registerDefaultDeviceListener(role: String) {
|
|
let selector: AudioObjectPropertySelector = (role == "input")
|
|
? kAudioHardwarePropertyDefaultInputDevice
|
|
: kAudioHardwarePropertyDefaultOutputDevice
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: selector,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain
|
|
)
|
|
let objectID: AudioObjectID = AudioObjectID(kAudioObjectSystemObject)
|
|
let channel = self.channel
|
|
let block: AudioObjectPropertyListenerBlock = { _, _ in
|
|
os_log("default %{public}@ device changed", log: OSLog(subsystem: kLogTag, category: "lifecycle"), type: .info, role)
|
|
channel?.invokeMethod("handleDefaultDeviceChange", arguments: ["role": role])
|
|
}
|
|
let status = AudioObjectAddPropertyListener(objectID, &address, block, nil)
|
|
if status == noErr {
|
|
listenerBlocks[objectID + UInt32(role.hashValue & 0xFFFF)] = block
|
|
os_log("registered default %{public}@ device listener", log: OSLog(subsystem: kLogTag, category: "lifecycle"), type: .info, role)
|
|
} else {
|
|
os_log("failed to register default %{public}@ device listener (OSStatus %{public}d)",
|
|
log: OSLog(subsystem: kLogTag, category: "lifecycle"),
|
|
type: .error, role, Int(status))
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
// Best-effort cleanup; AudioObjectRemovePropertyListener only
|
|
// matters if the system still holds the block.
|
|
for (id, block) in listenerBlocks {
|
|
for selector in [
|
|
kAudioHardwarePropertyDefaultInputDevice,
|
|
kAudioHardwarePropertyDefaultOutputDevice,
|
|
] {
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: selector,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain
|
|
)
|
|
_ = AudioObjectRemovePropertyListener(id, &address, block, nil)
|
|
}
|
|
}
|
|
}
|
|
}
|