358 lines
16 KiB
Python
358 lines
16 KiB
Python
"""macOS default audio-device monitoring.
|
|
|
|
The monitor deliberately depends on a small backend protocol. Core Audio invokes
|
|
listeners on an arbitrary thread, while VoiceAgent consumes snapshots on its
|
|
asyncio loop; :class:`AudioDeviceMonitor` is the lifecycle and thread boundary
|
|
between those two worlds. The backend can be replaced by a deterministic fake
|
|
in unit tests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import ctypes
|
|
import ctypes.util
|
|
import inspect
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Callable, Protocol
|
|
|
|
|
|
class DeviceChangeReason(str, Enum):
|
|
INITIAL = "initial"
|
|
DEFAULT_CHANGED = "default_changed"
|
|
DEVICE_ADDED = "device_added"
|
|
DEVICE_REMOVED = "device_removed"
|
|
DEVICE_RECONFIGURED = "device_reconfigured"
|
|
PROFILE_CHANGED = "profile_changed"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AudioDevice:
|
|
uid: str
|
|
name: str
|
|
can_input: bool
|
|
can_output: bool
|
|
transport: str = "unknown"
|
|
alive: bool = True
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AudioDeviceSnapshot:
|
|
generation: int
|
|
default_input_uid: str | None
|
|
default_output_uid: str | None
|
|
devices: dict[str, AudioDevice] = field(default_factory=dict)
|
|
reason: DeviceChangeReason = DeviceChangeReason.INITIAL
|
|
|
|
|
|
Listener = Callable[[], None]
|
|
|
|
|
|
class AudioDeviceBackend(Protocol):
|
|
"""The minimal Core Audio surface required by the monitor."""
|
|
|
|
def enumerate_devices(self) -> list[AudioDevice]: ...
|
|
def default_input_uid(self) -> str | None: ...
|
|
def default_output_uid(self) -> str | None: ...
|
|
def add_listener(self, callback: Listener) -> object: ...
|
|
def remove_listener(self, token: object) -> None: ...
|
|
|
|
|
|
class AudioDeviceMonitor:
|
|
"""Coalesces Core Audio notifications and dispatches snapshots on one loop."""
|
|
|
|
def __init__(self, backend: AudioDeviceBackend, *, debounce_seconds: float = 0.05,
|
|
loop: asyncio.AbstractEventLoop | None = None):
|
|
self._backend = backend
|
|
self._debounce_seconds = debounce_seconds
|
|
self._loop = loop
|
|
self._callback: Callable[[AudioDeviceSnapshot], object] | None = None
|
|
self._snapshot = AudioDeviceSnapshot(0, None, None)
|
|
self._generation = 0
|
|
self._listener_tokens: list[object] = []
|
|
self._debounce_handle: asyncio.TimerHandle | None = None
|
|
self._running = False
|
|
self._refresh_scheduled = False
|
|
self._refresh_task: asyncio.Task[None] | None = None
|
|
|
|
async def start(self, on_change: Callable[[AudioDeviceSnapshot], object]) -> None:
|
|
"""Register listeners once and publish an initial snapshot.
|
|
|
|
``on_change`` may be synchronous or return an awaitable. It is always
|
|
called on the loop used by this monitor, never on a Core Audio thread.
|
|
"""
|
|
if self._running:
|
|
return
|
|
self._loop = self._loop or asyncio.get_running_loop()
|
|
self._callback = on_change
|
|
self._running = True
|
|
try:
|
|
# Register all listeners before the initial read so a concurrent
|
|
# device change cannot be missed.
|
|
self._listener_tokens = [self._backend.add_listener(self._on_backend_event) for _ in range(1)]
|
|
await self._refresh(DeviceChangeReason.INITIAL)
|
|
except Exception:
|
|
await self.stop()
|
|
raise
|
|
|
|
async def stop(self) -> None:
|
|
"""Stop idempotently and ensure no callback can be scheduled afterward."""
|
|
self._running = False
|
|
if self._debounce_handle:
|
|
self._debounce_handle.cancel()
|
|
self._debounce_handle = None
|
|
current_task = asyncio.current_task()
|
|
if (self._refresh_task and not self._refresh_task.done()
|
|
and self._refresh_task is not current_task):
|
|
self._refresh_task.cancel()
|
|
try:
|
|
await self._refresh_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self._refresh_task = None
|
|
tokens, self._listener_tokens = self._listener_tokens, []
|
|
for token in tokens:
|
|
self._backend.remove_listener(token)
|
|
self._callback = None
|
|
self._refresh_scheduled = False
|
|
|
|
def snapshot(self) -> AudioDeviceSnapshot:
|
|
return self._snapshot
|
|
|
|
def _on_backend_event(self) -> None:
|
|
"""Core Audio callback entry point; safe to call from any thread."""
|
|
if not self._running or not self._loop:
|
|
return
|
|
self._loop.call_soon_threadsafe(self._schedule_refresh)
|
|
|
|
def _schedule_refresh(self) -> None:
|
|
if not self._running or self._refresh_scheduled:
|
|
return
|
|
self._refresh_scheduled = True
|
|
if self._debounce_handle:
|
|
self._debounce_handle.cancel()
|
|
self._debounce_handle = self._loop.call_later(self._debounce_seconds, self._start_refresh)
|
|
|
|
def _start_refresh(self) -> None:
|
|
self._debounce_handle = None
|
|
self._refresh_scheduled = False
|
|
if self._running:
|
|
self._refresh_task = self._loop.create_task(self._refresh(self._infer_reason()))
|
|
self._refresh_task.add_done_callback(self._consume_refresh_failure)
|
|
|
|
@staticmethod
|
|
def _consume_refresh_failure(task: asyncio.Task[None]) -> None:
|
|
"""Retrieve scheduled refresh failures after cleanup has completed."""
|
|
if not task.cancelled():
|
|
task.exception()
|
|
|
|
def _infer_reason(self) -> DeviceChangeReason:
|
|
# The backend intentionally keeps the callback payload-free. The
|
|
# resulting snapshot is authoritative; callers can inspect UID/device
|
|
# differences. A generic device reconfiguration is safest here.
|
|
return DeviceChangeReason.DEFAULT_CHANGED
|
|
|
|
async def _refresh(self, reason: DeviceChangeReason) -> None:
|
|
if not self._running:
|
|
return
|
|
devices = {d.uid: d for d in self._backend.enumerate_devices() if d.uid and d.alive}
|
|
new_input = self._backend.default_input_uid()
|
|
new_output = self._backend.default_output_uid()
|
|
# An unavailable default is represented as None rather than a stale UID.
|
|
if new_input not in devices or not devices[new_input].can_input:
|
|
new_input = None
|
|
if new_output not in devices or not devices[new_output].can_output:
|
|
new_output = None
|
|
old = self._snapshot
|
|
if (old.default_input_uid == new_input and old.default_output_uid == new_output
|
|
and old.devices == devices and old.generation != 0):
|
|
return
|
|
if old.generation and reason != DeviceChangeReason.INITIAL:
|
|
old_uids, new_uids = set(old.devices), set(devices)
|
|
if old.default_input_uid != new_input or old.default_output_uid != new_output:
|
|
reason = DeviceChangeReason.DEFAULT_CHANGED
|
|
elif new_uids - old_uids:
|
|
reason = DeviceChangeReason.DEVICE_ADDED
|
|
elif old_uids - new_uids:
|
|
reason = DeviceChangeReason.DEVICE_REMOVED
|
|
elif any(old.devices[uid] != device for uid, device in devices.items()
|
|
if uid in old.devices):
|
|
reason = DeviceChangeReason.PROFILE_CHANGED
|
|
self._generation += 1
|
|
self._snapshot = AudioDeviceSnapshot(self._generation, new_input, new_output, devices, reason)
|
|
callback = self._callback
|
|
if callback and self._running:
|
|
try:
|
|
result = callback(self._snapshot)
|
|
if inspect.isawaitable(result):
|
|
await result
|
|
except Exception:
|
|
# A failed consumer must not leave a native listener active with
|
|
# an unusable callback. ``stop`` handles the current refresh
|
|
# task specially so this cleanup is safe from inside _refresh.
|
|
await self.stop()
|
|
raise
|
|
|
|
|
|
class MacOSCoreAudioBackend:
|
|
"""Core Audio backend hook.
|
|
|
|
PyObjC's CoreAudio listener ABI differs between macOS releases. Keeping the
|
|
native adapter behind this class lets packaging provide the matching adapter
|
|
without exposing it to the async monitor or its tests.
|
|
"""
|
|
|
|
def __init__(self, adapter):
|
|
self._adapter = adapter
|
|
|
|
def enumerate_devices(self) -> list[AudioDevice]:
|
|
return list(self._adapter.enumerate_devices())
|
|
|
|
def default_input_uid(self) -> str | None:
|
|
return self._adapter.default_input_uid()
|
|
|
|
def default_output_uid(self) -> str | None:
|
|
return self._adapter.default_output_uid()
|
|
|
|
def add_listener(self, callback: Listener) -> object:
|
|
return self._adapter.add_device_listener(callback)
|
|
|
|
def remove_listener(self, token: object) -> None:
|
|
self._adapter.remove_device_listener(token)
|
|
|
|
|
|
class NativeMacOSCoreAudioAdapter:
|
|
"""Native Core Audio adapter using stable device UIDs, never PortAudio IDs."""
|
|
|
|
_SYSTEM_OBJECT = 1
|
|
_GLOBAL = int.from_bytes(b"glob", "big")
|
|
_INPUT_SCOPE = int.from_bytes(b"inpt", "big")
|
|
_OUTPUT_SCOPE = int.from_bytes(b"outp", "big")
|
|
_DEFAULT_INPUT = int.from_bytes(b"dIn ", "big")
|
|
_DEFAULT_OUTPUT = int.from_bytes(b"dOut", "big")
|
|
_DEVICES = int.from_bytes(b"dev#", "big")
|
|
_UID = int.from_bytes(b"uid ", "big")
|
|
_NAME = int.from_bytes(b"lnam", "big")
|
|
_ALIVE = int.from_bytes(b"livn", "big")
|
|
_TRANSPORT = int.from_bytes(b"tran", "big")
|
|
_STREAMS = int.from_bytes(b"stm#", "big")
|
|
|
|
class _Address(ctypes.Structure):
|
|
_fields_ = [("selector", ctypes.c_uint32), ("scope", ctypes.c_uint32), ("element", ctypes.c_uint32)]
|
|
|
|
def __init__(self):
|
|
if sys.platform != "darwin":
|
|
raise RuntimeError("Core Audio is only available on macOS")
|
|
core_audio = ctypes.util.find_library("CoreAudio")
|
|
core_foundation = ctypes.util.find_library("CoreFoundation")
|
|
if not core_audio or not core_foundation:
|
|
raise RuntimeError("CoreAudio.framework is unavailable")
|
|
self._lib = ctypes.CDLL(core_audio)
|
|
self._cf = ctypes.CDLL(core_foundation)
|
|
self._listener_type = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_uint32, ctypes.c_uint32,
|
|
ctypes.POINTER(self._Address), ctypes.c_void_p)
|
|
self._lib.AudioObjectGetPropertyData.argtypes = [ctypes.c_uint32, ctypes.POINTER(self._Address), ctypes.c_uint32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32), ctypes.c_void_p]
|
|
self._lib.AudioObjectGetPropertyData.restype = ctypes.c_int32
|
|
self._lib.AudioObjectGetPropertyDataSize.argtypes = [ctypes.c_uint32, ctypes.POINTER(self._Address), ctypes.c_uint32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32)]
|
|
self._lib.AudioObjectGetPropertyDataSize.restype = ctypes.c_int32
|
|
self._lib.AudioObjectAddPropertyListener.argtypes = [ctypes.c_uint32, ctypes.POINTER(self._Address), self._listener_type, ctypes.c_void_p]
|
|
self._lib.AudioObjectRemovePropertyListener.argtypes = [ctypes.c_uint32, ctypes.POINTER(self._Address), self._listener_type, ctypes.c_void_p]
|
|
self._cf.CFStringGetCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_long, ctypes.c_uint32]
|
|
self._callbacks = {}
|
|
|
|
def _address(self, selector, scope=None):
|
|
return self._Address(selector, self._GLOBAL if scope is None else scope, 0)
|
|
|
|
def _value(self, object_id, selector, value_type, scope=None):
|
|
value, size = value_type(), ctypes.c_uint32(ctypes.sizeof(value_type))
|
|
status = self._lib.AudioObjectGetPropertyData(object_id, ctypes.byref(self._address(selector, scope)), 0, None, ctypes.byref(size), ctypes.byref(value))
|
|
if status:
|
|
raise OSError(f"AudioObjectGetPropertyData({selector}) failed: {status}")
|
|
return value.value
|
|
|
|
def _string(self, object_id, selector):
|
|
ref = self._value(object_id, selector, ctypes.c_void_p)
|
|
if not ref:
|
|
return ""
|
|
buffer = ctypes.create_string_buffer(1024)
|
|
if not self._cf.CFStringGetCString(ref, buffer, len(buffer), 0x08000100):
|
|
return ""
|
|
return buffer.value.decode("utf-8", "replace")
|
|
|
|
def _device_ids(self):
|
|
address, size = self._address(self._DEVICES), ctypes.c_uint32()
|
|
status = self._lib.AudioObjectGetPropertyDataSize(self._SYSTEM_OBJECT, ctypes.byref(address), 0, None, ctypes.byref(size))
|
|
if status:
|
|
raise OSError(f"Audio device enumeration failed: {status}")
|
|
devices = (ctypes.c_uint32 * (size.value // ctypes.sizeof(ctypes.c_uint32)))()
|
|
status = self._lib.AudioObjectGetPropertyData(self._SYSTEM_OBJECT, ctypes.byref(address), 0, None, ctypes.byref(size), devices)
|
|
if status:
|
|
raise OSError(f"Audio device enumeration failed: {status}")
|
|
return list(devices)
|
|
|
|
def _has_streams(self, device_id, scope):
|
|
address, size = self._address(self._STREAMS, scope), ctypes.c_uint32()
|
|
status = self._lib.AudioObjectGetPropertyDataSize(device_id, ctypes.byref(address), 0, None, ctypes.byref(size))
|
|
return not status and bool(size.value)
|
|
|
|
def enumerate_devices(self):
|
|
devices = []
|
|
for device_id in self._device_ids():
|
|
try:
|
|
uid = self._string(device_id, self._UID)
|
|
if not uid:
|
|
continue
|
|
devices.append(AudioDevice(uid, self._string(device_id, self._NAME) or uid,
|
|
self._has_streams(device_id, self._INPUT_SCOPE), self._has_streams(device_id, self._OUTPUT_SCOPE),
|
|
self._fourcc(self._value(device_id, self._TRANSPORT, ctypes.c_uint32)), bool(self._value(device_id, self._ALIVE, ctypes.c_uint32))))
|
|
except OSError:
|
|
continue
|
|
return devices
|
|
|
|
@staticmethod
|
|
def _fourcc(value):
|
|
return value.to_bytes(4, "big").decode("ascii", "replace").strip() or "unknown"
|
|
|
|
def default_input_uid(self):
|
|
return self._uid_for_id(self._value(self._SYSTEM_OBJECT, self._DEFAULT_INPUT, ctypes.c_uint32))
|
|
|
|
def default_output_uid(self):
|
|
return self._uid_for_id(self._value(self._SYSTEM_OBJECT, self._DEFAULT_OUTPUT, ctypes.c_uint32))
|
|
|
|
def _uid_for_id(self, device_id):
|
|
return self._string(device_id, self._UID) if device_id else None
|
|
|
|
def add_device_listener(self, callback):
|
|
addresses = [(self._SYSTEM_OBJECT, self._address(selector)) for selector in (self._DEFAULT_INPUT, self._DEFAULT_OUTPUT, self._DEVICES)]
|
|
addresses += [(device_id, self._address(selector, scope)) for device_id in self._device_ids() for selector, scope in ((self._ALIVE, None), (self._STREAMS, self._INPUT_SCOPE), (self._STREAMS, self._OUTPUT_SCOPE))]
|
|
native_callback = self._listener_type(lambda *_: (callback(), 0)[1])
|
|
registered = []
|
|
try:
|
|
for object_id, address in addresses:
|
|
status = self._lib.AudioObjectAddPropertyListener(object_id, ctypes.byref(address), native_callback, None)
|
|
if status:
|
|
raise OSError(f"AudioObjectAddPropertyListener failed: {status}")
|
|
registered.append((object_id, address))
|
|
except Exception:
|
|
for object_id, address in registered:
|
|
self._lib.AudioObjectRemovePropertyListener(object_id, ctypes.byref(address), native_callback, None)
|
|
raise
|
|
token = (native_callback, registered)
|
|
self._callbacks[id(token)] = token
|
|
return token
|
|
|
|
def remove_device_listener(self, token):
|
|
native_callback, addresses = token
|
|
for object_id, address in addresses:
|
|
self._lib.AudioObjectRemovePropertyListener(object_id, ctypes.byref(address), native_callback, None)
|
|
self._callbacks.pop(id(token), None)
|
|
|
|
|
|
def create_macos_audio_monitor(*, adapter=None, **kwargs) -> AudioDeviceMonitor:
|
|
"""Create a native Core Audio monitor (or a supplied test adapter)."""
|
|
if adapter is None:
|
|
adapter = NativeMacOSCoreAudioAdapter()
|
|
return AudioDeviceMonitor(MacOSCoreAudioBackend(adapter), **kwargs)
|