Files
chanora/apps/chanora_flutter/macos/Runner/MacOSAudioLifecycle.swift
Edison Jwa fe5dc1cda4 feat(macos): add macOS audio lifecycle MethodChannel
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).
2026-06-07 23:27:46 +09:00

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)
}
}
}
}