From 9e6c11e5882ce73ee022f86e3cb320d728a5256c Mon Sep 17 00:00:00 2001 From: Username Date: Tue, 24 Feb 2026 23:50:23 +0100 Subject: [PATCH] audio: add DeviceMonitor for device list polling --- src/tuimble/audio.py | 56 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/tuimble/audio.py b/src/tuimble/audio.py index c7d9399..79f43d2 100644 --- a/src/tuimble/audio.py +++ b/src/tuimble/audio.py @@ -10,6 +10,8 @@ from __future__ import annotations import array import logging import queue +import threading +from typing import Callable log = logging.getLogger(__name__) @@ -30,6 +32,60 @@ def _apply_gain(pcm: bytes, gain: float) -> bytes: return samples.tobytes() +class DeviceMonitor: + """Poll sounddevice for device list changes. + + Runs a daemon thread that compares a string fingerprint of + ``sounddevice.query_devices()`` against a cached snapshot every + *interval* seconds. Fires *callback* when the list changes. + + Uses ``threading.Event`` for cancellation (same pattern as + ``ReconnectManager``). + """ + + def __init__(self, callback: Callable[[], None], interval: float = 2.0): + self._callback = callback + self._interval = interval + self._cancel = threading.Event() + self._snapshot = self._device_fingerprint() + self._thread: threading.Thread | None = None + + @staticmethod + def _device_fingerprint() -> str: + """Return a string snapshot of the current device list.""" + try: + import sounddevice as sd + + return str(sd.query_devices()) + except Exception: + return "" + + def start(self) -> None: + """Start polling in a daemon thread.""" + if self._thread is not None and self._thread.is_alive(): + return + self._cancel.clear() + self._snapshot = self._device_fingerprint() + self._thread = threading.Thread(target=self._poll, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Signal the poll loop to exit.""" + self._cancel.set() + self._thread = None + + def _poll(self) -> None: + while not self._cancel.wait(timeout=self._interval): + current = self._device_fingerprint() + if current != self._snapshot: + self._snapshot = current + log.info("audio device list changed") + try: + self._callback() + except Exception: + log.exception("device change callback failed") + + class AudioPipeline: """Manages audio input/output streams and Opus codec."""