diff --git a/whisper_local/microphone/_win32.py b/whisper_local/microphone/_win32.py new file mode 100644 index 0000000..a66f4ab --- /dev/null +++ b/whisper_local/microphone/_win32.py @@ -0,0 +1,188 @@ +# whisper_local/microphone/_win32.py +"""Windows Mikrofon-Monitor via IMMNotificationClient (Core Audio API).""" +import asyncio +import ctypes +import logging +from collections.abc import Awaitable, Callable + +import sounddevice as sd + +logger = logging.getLogger(__name__) + +_CLSID_MMDeviceEnumerator = "{BCDE0395-E52F-467C-8E3D-C4579291692E}" +_IID_IMMDeviceEnumerator = "{A95664D2-9614-4F35-A746-DE8DB63617E6}" +_IID_IMMNotificationClient = "{7991EEC9-7E89-4D85-8390-6C703CEC60C0}" + + +def _build_com_interfaces(): + """Definiert IMMDeviceEnumerator und IMMNotificationClient via comtypes.""" + import comtypes + from comtypes import COMMETHOD, GUID, HRESULT, IUnknown, POINTER + + class _IMMNotificationClient(IUnknown): + _iid_ = GUID(_IID_IMMNotificationClient) + _methods_ = [ + COMMETHOD([], HRESULT, "OnDeviceStateChanged", + (["in"], ctypes.c_wchar_p, "pwstrDeviceId"), + (["in"], ctypes.c_uint, "dwNewState")), + COMMETHOD([], HRESULT, "OnDeviceAdded", + (["in"], ctypes.c_wchar_p, "pwstrDeviceId")), + COMMETHOD([], HRESULT, "OnDeviceRemoved", + (["in"], ctypes.c_wchar_p, "pwstrDeviceId")), + COMMETHOD([], HRESULT, "OnDefaultDeviceChanged", + (["in"], ctypes.c_int, "flow"), + (["in"], ctypes.c_int, "role"), + (["in"], ctypes.c_wchar_p, "pwstrDefaultDeviceId")), + COMMETHOD([], HRESULT, "OnPropertyValueChanged", + (["in"], ctypes.c_wchar_p, "pwstrDeviceId"), + (["in"], ctypes.c_void_p, "key")), + ] + + class _IMMDeviceEnumerator(IUnknown): + _iid_ = GUID(_IID_IMMDeviceEnumerator) + _methods_ = [ + COMMETHOD([], HRESULT, "EnumAudioEndpoints", + (["in"], ctypes.c_int, "dataFlow"), + (["in"], ctypes.c_uint, "dwStateMask"), + (["out"], POINTER(IUnknown), "ppDevices")), + COMMETHOD([], HRESULT, "GetDefaultAudioEndpoint", + (["in"], ctypes.c_int, "dataFlow"), + (["in"], ctypes.c_int, "role"), + (["out"], POINTER(IUnknown), "ppEndpoint")), + COMMETHOD([], HRESULT, "GetDevice", + (["in"], ctypes.c_wchar_p, "pwstrId"), + (["out"], POINTER(IUnknown), "ppDevice")), + COMMETHOD([], HRESULT, "RegisterEndpointNotificationCallback", + (["in"], POINTER(_IMMNotificationClient), "pClient")), + COMMETHOD([], HRESULT, "UnregisterEndpointNotificationCallback", + (["in"], POINTER(_IMMNotificationClient), "pClient")), + ] + + return _IMMNotificationClient, _IMMDeviceEnumerator + + +def _build_client_class(IMMNotificationClient, callback): + """Erstellt eine comtypes.COMObject-Implementierung von IMMNotificationClient.""" + import comtypes + + class _NotificationClientImpl(comtypes.COMObject): + _com_interfaces_ = [IMMNotificationClient] + + def OnDeviceStateChanged(self, pwstrDeviceId, dwNewState): + callback() + return 0 + + def OnDeviceAdded(self, pwstrDeviceId): + callback() + return 0 + + def OnDeviceRemoved(self, pwstrDeviceId): + callback() + return 0 + + def OnDefaultDeviceChanged(self, flow, role, pwstrDefaultDeviceId): + return 0 + + def OnPropertyValueChanged(self, pwstrDeviceId, key): + return 0 + + return _NotificationClientImpl() + + +class Win32Monitor: + def __init__(self, configured_device: str | None): + self.configured_device = configured_device + 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._loop: asyncio.AbstractEventLoop | None = None + self._known_devices: set[str] = set() + self._enumerator = None + self._client = None + self._fallback = None + + 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._loop = asyncio.get_running_loop() + self._known_devices = self._current_devices() + + if ( + self.configured_device + and self.configured_device not in self._known_devices + and self.on_configured_missing + ): + await self.on_configured_missing() + + try: + self._start_com() + except Exception: + logger.warning( + "IMMNotificationClient nicht verfügbar, Fallback auf Polling", + exc_info=True, + ) + from whisper_local.microphone._poll import PollMonitor + fallback = PollMonitor(self.configured_device) + fallback.on_device_added = self.on_device_added + fallback.on_device_removed = self.on_device_removed + fallback._known_devices = self._known_devices + self._fallback = fallback + self._fallback._task = asyncio.create_task(self._fallback._loop()) + + def _start_com(self) -> None: + import comtypes + import comtypes.client + from comtypes import GUID + + comtypes.CoInitialize() + IMMNotificationClient, IMMDeviceEnumerator = _build_com_interfaces() + self._enumerator = comtypes.client.CreateObject( + GUID(_CLSID_MMDeviceEnumerator), + interface=IMMDeviceEnumerator, + ) + self._client = _build_client_class(IMMNotificationClient, self._on_com_event) + self._enumerator.RegisterEndpointNotificationCallback(self._client) + + def _on_com_event(self) -> None: + if self._loop is not None: + self._loop.call_soon_threadsafe( + lambda: asyncio.ensure_future(self._handle_change()) + ) + + async def _handle_change(self) -> None: + 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) + + def stop(self) -> None: + if self._fallback is not None: + self._fallback.stop() + return + if self._enumerator is not None and self._client is not None: + try: + self._enumerator.UnregisterEndpointNotificationCallback(self._client) + except Exception: + logger.warning("Fehler beim Deregistrieren des Notification-Clients") + try: + import comtypes + comtypes.CoUninitialize() + except Exception: + pass