From de6c61aeb3ab46519f89b98b412fa0fdb654bc8e Mon Sep 17 00:00:00 2001 From: Vitali Graf Date: Thu, 14 May 2026 17:37:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(microphone):=20PollMonitor=20mit=20Ger?= =?UTF-8?q?=C3=A4teerkennung=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_microphone_monitor.py | 67 +++++++++++++++++++++++++++++++ whisper_local/microphone/_poll.py | 56 ++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 tests/test_microphone_monitor.py create mode 100644 whisper_local/microphone/_poll.py diff --git a/tests/test_microphone_monitor.py b/tests/test_microphone_monitor.py new file mode 100644 index 0000000..f4d8f43 --- /dev/null +++ b/tests/test_microphone_monitor.py @@ -0,0 +1,67 @@ +# tests/test_microphone_monitor.py +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from whisper_local.microphone._poll import PollMonitor + + +def _fake_devices(names: list[str]) -> list[dict]: + return [{"name": n, "max_input_channels": 1} for n in names] + + +@pytest.mark.asyncio +async def test_on_device_added_fires_when_device_appears(): + monitor = PollMonitor(configured_device=None, interval=0.05) + event = asyncio.Event() + added: list[str] = [] + + async def on_added(name: str) -> None: + added.append(name) + event.set() + + monitor.on_device_added = on_added + call_count = 0 + + def fake_query(): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _fake_devices(["Mic A"]) + return _fake_devices(["Mic A", "Mic B"]) + + with patch("sounddevice.query_devices", side_effect=fake_query): + await monitor.start() + await asyncio.wait_for(event.wait(), timeout=1.0) + monitor.stop() + + assert added == ["Mic B"] + + +@pytest.mark.asyncio +async def test_on_device_removed_fires_when_device_disappears(): + monitor = PollMonitor(configured_device=None, interval=0.05) + event = asyncio.Event() + removed: list[str] = [] + + async def on_removed(name: str) -> None: + removed.append(name) + event.set() + + monitor.on_device_removed = on_removed + call_count = 0 + + def fake_query(): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _fake_devices(["Mic A", "Mic B"]) + return _fake_devices(["Mic A"]) + + with patch("sounddevice.query_devices", side_effect=fake_query): + await monitor.start() + await asyncio.wait_for(event.wait(), timeout=1.0) + monitor.stop() + + assert removed == ["Mic B"] diff --git a/whisper_local/microphone/_poll.py b/whisper_local/microphone/_poll.py new file mode 100644 index 0000000..44c768f --- /dev/null +++ b/whisper_local/microphone/_poll.py @@ -0,0 +1,56 @@ +# whisper_local/microphone/_poll.py +"""Polling-basierter Mikrofon-Monitor (cross-platform).""" +import asyncio +import logging +from collections.abc import Awaitable, Callable + +import sounddevice as sd + +logger = logging.getLogger(__name__) + + +class PollMonitor: + def __init__(self, configured_device: str | None, interval: float = 2.5): + self.configured_device = configured_device + self.interval = interval + self.on_device_added: Callable[[str], Awaitable[None]] | None = None + self.on_device_removed: Callable[[str], Awaitable[None]] | None = None + self.on_configured_missing: Callable[[], Awaitable[None]] | None = None + self._task: asyncio.Task | None = None + self._known_devices: set[str] = set() + + def _current_devices(self) -> set[str]: + try: + return { + dev["name"] + for dev in sd.query_devices() + if dev["max_input_channels"] > 0 + } + except Exception: + logger.exception("Fehler beim Abfragen der Audiogeräte") + return self._known_devices.copy() + + async def start(self) -> None: + self._known_devices = self._current_devices() + self._task = asyncio.create_task(self._loop()) + + def stop(self) -> None: + if self._task is not None: + self._task.cancel() + self._task = None + + async def _loop(self) -> None: + while True: + await asyncio.sleep(self.interval) + current = self._current_devices() + added = current - self._known_devices + removed = self._known_devices - current + self._known_devices = current + + for name in added: + if self.on_device_added: + await self.on_device_added(name) + + for name in removed: + if self.on_device_removed: + await self.on_device_removed(name)