Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
217 lines
9.0 KiB
Swift
217 lines
9.0 KiB
Swift
import CoreAudio
|
|
import Foundation
|
|
import OSLog
|
|
|
|
final class AudioInputDeviceObserver {
|
|
private let logger = Logger(subsystem: "ai.openclaw", category: "audio.devices")
|
|
private var isActive = false
|
|
private var devicesListener: AudioObjectPropertyListenerBlock?
|
|
private var defaultInputListener: AudioObjectPropertyListenerBlock?
|
|
|
|
static func defaultInputDeviceUID() -> String? {
|
|
guard let deviceID = self.defaultInputDeviceID() else { return nil }
|
|
return self.deviceUID(for: deviceID)
|
|
}
|
|
|
|
static func aliveInputDeviceUIDs() -> Set<String> {
|
|
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: kAudioHardwarePropertyDevices,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
var size: UInt32 = 0
|
|
var status = AudioObjectGetPropertyDataSize(systemObject, &address, 0, nil, &size)
|
|
guard status == noErr, size > 0 else { return [] }
|
|
|
|
let count = Int(size) / MemoryLayout<AudioObjectID>.size
|
|
var deviceIDs = [AudioObjectID](repeating: 0, count: count)
|
|
status = AudioObjectGetPropertyData(systemObject, &address, 0, nil, &size, &deviceIDs)
|
|
guard status == noErr else { return [] }
|
|
|
|
var output = Set<String>()
|
|
for deviceID in deviceIDs {
|
|
guard self.deviceIsAlive(deviceID) else { continue }
|
|
guard self.deviceHasInput(deviceID) else { continue }
|
|
if let uid = self.deviceUID(for: deviceID) {
|
|
output.insert(uid)
|
|
}
|
|
}
|
|
return output
|
|
}
|
|
|
|
/// Returns true when the system default input device exists and is alive with input channels.
|
|
/// Use this preflight before accessing `AVAudioEngine.inputNode` to avoid SIGABRT on Macs
|
|
/// without a built-in microphone (Mac mini, Mac Pro, Mac Studio) or when an external mic
|
|
/// is disconnected.
|
|
static func hasUsableDefaultInputDevice() -> Bool {
|
|
guard let uid = self.defaultInputDeviceUID() else { return false }
|
|
return self.aliveInputDeviceUIDs().contains(uid)
|
|
}
|
|
|
|
static func defaultInputDeviceSummary() -> String {
|
|
guard let deviceID = self.defaultInputDeviceID() else {
|
|
return "defaultInput=unknown"
|
|
}
|
|
let uid = self.deviceUID(for: deviceID) ?? "unknown"
|
|
let name = self.deviceName(for: deviceID) ?? "unknown"
|
|
return "defaultInput=\(name) (\(uid))"
|
|
}
|
|
|
|
private static func defaultInputDeviceID() -> AudioObjectID? {
|
|
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
var deviceID = AudioObjectID(0)
|
|
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
|
let status = AudioObjectGetPropertyData(
|
|
systemObject,
|
|
&address,
|
|
0,
|
|
nil,
|
|
&size,
|
|
&deviceID)
|
|
guard status == noErr, deviceID != 0 else { return nil }
|
|
return deviceID
|
|
}
|
|
|
|
func start(onChange: @escaping @Sendable () -> Void) {
|
|
guard !self.isActive else { return }
|
|
self.isActive = true
|
|
|
|
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
|
let queue = DispatchQueue.main
|
|
|
|
var devicesAddress = AudioObjectPropertyAddress(
|
|
mSelector: kAudioHardwarePropertyDevices,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
let devicesListener: AudioObjectPropertyListenerBlock = { _, _ in
|
|
self.logDefaultInputChange(reason: "devices")
|
|
onChange()
|
|
}
|
|
let devicesStatus = AudioObjectAddPropertyListenerBlock(
|
|
systemObject,
|
|
&devicesAddress,
|
|
queue,
|
|
devicesListener)
|
|
|
|
var defaultInputAddress = AudioObjectPropertyAddress(
|
|
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
let defaultInputListener: AudioObjectPropertyListenerBlock = { _, _ in
|
|
self.logDefaultInputChange(reason: "default")
|
|
onChange()
|
|
}
|
|
let defaultStatus = AudioObjectAddPropertyListenerBlock(
|
|
systemObject,
|
|
&defaultInputAddress,
|
|
queue,
|
|
defaultInputListener)
|
|
|
|
if devicesStatus != noErr || defaultStatus != noErr {
|
|
self.logger.error("audio device observer install failed devices=\(devicesStatus) default=\(defaultStatus)")
|
|
}
|
|
|
|
self.logger.info("audio device observer started (\(Self.defaultInputDeviceSummary(), privacy: .public))")
|
|
|
|
self.devicesListener = devicesListener
|
|
self.defaultInputListener = defaultInputListener
|
|
}
|
|
|
|
func stop() {
|
|
guard self.isActive else { return }
|
|
self.isActive = false
|
|
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
|
|
|
if let devicesListener {
|
|
var devicesAddress = AudioObjectPropertyAddress(
|
|
mSelector: kAudioHardwarePropertyDevices,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
_ = AudioObjectRemovePropertyListenerBlock(
|
|
systemObject,
|
|
&devicesAddress,
|
|
DispatchQueue.main,
|
|
devicesListener)
|
|
}
|
|
|
|
if let defaultInputListener {
|
|
var defaultInputAddress = AudioObjectPropertyAddress(
|
|
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
_ = AudioObjectRemovePropertyListenerBlock(
|
|
systemObject,
|
|
&defaultInputAddress,
|
|
DispatchQueue.main,
|
|
defaultInputListener)
|
|
}
|
|
|
|
self.devicesListener = nil
|
|
self.defaultInputListener = nil
|
|
}
|
|
|
|
private static func deviceUID(for deviceID: AudioObjectID) -> String? {
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: kAudioDevicePropertyDeviceUID,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
var uid: Unmanaged<CFString>?
|
|
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
|
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &uid)
|
|
guard status == noErr, let uid else { return nil }
|
|
return uid.takeUnretainedValue() as String
|
|
}
|
|
|
|
private static func deviceName(for deviceID: AudioObjectID) -> String? {
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: kAudioObjectPropertyName,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
var name: Unmanaged<CFString>?
|
|
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
|
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &name)
|
|
guard status == noErr, let name else { return nil }
|
|
return name.takeUnretainedValue() as String
|
|
}
|
|
|
|
private static func deviceIsAlive(_ deviceID: AudioObjectID) -> Bool {
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: kAudioDevicePropertyDeviceIsAlive,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
var alive: UInt32 = 0
|
|
var size = UInt32(MemoryLayout<UInt32>.size)
|
|
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &alive)
|
|
return status == noErr && alive != 0
|
|
}
|
|
|
|
private static func deviceHasInput(_ deviceID: AudioObjectID) -> Bool {
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: kAudioDevicePropertyStreamConfiguration,
|
|
mScope: kAudioDevicePropertyScopeInput,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
var size: UInt32 = 0
|
|
var status = AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size)
|
|
guard status == noErr, size > 0 else { return false }
|
|
|
|
let raw = UnsafeMutableRawPointer.allocate(
|
|
byteCount: Int(size),
|
|
alignment: MemoryLayout<AudioBufferList>.alignment)
|
|
defer { raw.deallocate() }
|
|
let bufferList = raw.bindMemory(to: AudioBufferList.self, capacity: 1)
|
|
status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, bufferList)
|
|
guard status == noErr else { return false }
|
|
|
|
let buffers = UnsafeMutableAudioBufferListPointer(bufferList)
|
|
return buffers.contains(where: { $0.mNumberChannels > 0 })
|
|
}
|
|
|
|
private func logDefaultInputChange(reason: StaticString) {
|
|
self.logger.info("audio input changed (\(reason)) (\(Self.defaultInputDeviceSummary(), privacy: .public))")
|
|
}
|
|
}
|