デバイス管理モジュールのインポートをガードし、Windows固有の依存関係をオプショナルに変更。クラスの初期化メソッドを修正し、デフォルトデバイス変更時のコールバックを追加。ドキュメントを新規作成し、使用例や注意点を明示化。エラーハンドリングを強化し、コードの可読性を向上。

This commit is contained in:
misyaguziya
2025-10-09 19:04:31 +09:00
parent eca5e31429
commit 61cbe07f0f
2 changed files with 331 additions and 123 deletions

View File

@@ -1,27 +1,53 @@
from typing import Callable
from typing import Callable, Dict, List, Optional, Any
from time import sleep
from threading import Thread
import comtypes
from pyaudiowpatch import PyAudio, paWASAPI
from pycaw.callbacks import MMNotificationClient
from pycaw.utils import AudioUtilities
# Optional, Windows-specific dependencies. Guard imports so module can be imported on non-Windows systems.
try:
import comtypes
except Exception: # pragma: no cover - optional runtime
comtypes = None # type: ignore
try:
from pyaudiowpatch import PyAudio, paWASAPI
except Exception: # pragma: no cover - optional runtime
PyAudio = None # type: ignore
paWASAPI = None # type: ignore
try:
from pycaw.callbacks import MMNotificationClient
from pycaw.utils import AudioUtilities
except Exception: # pragma: no cover - optional runtime
MMNotificationClient = object # type: ignore
AudioUtilities = None # type: ignore
from utils import errorLogging
class Client(MMNotificationClient):
def __init__(self):
super().__init__()
self.loop = True
"""Callback client used by pycaw to detect device changes.
def on_default_device_changed(self, flow, flow_id, role, role_id, default_device_id):
This subclass is lightweight: it flips a flag when events arrive so the
monitoring loop can break and refresh device lists.
"""
def __init__(self) -> None:
# If MMNotificationClient is the placeholder object (non-windows), avoid calling super
try:
super().__init__()
except Exception:
pass
self.loop: bool = True
def on_default_device_changed(self, *args: Any, **kwargs: Any) -> None:
self.loop = False
def on_device_added(self, added_device_id):
def on_device_added(self, *args: Any, **kwargs: Any) -> None:
self.loop = False
def on_device_removed(self, removed_device_id):
def on_device_removed(self, *args: Any, **kwargs: Any) -> None:
self.loop = False
def on_device_state_changed(self, device_id, state):
def on_device_state_changed(self, *args: Any, **kwargs: Any) -> None:
self.loop = False
# def on_property_value_changed(self, device_id, key):
@@ -33,108 +59,150 @@ class DeviceManager:
def __new__(cls):
if cls._instance is None:
cls._instance = super(DeviceManager, cls).__new__(cls)
cls._instance.init()
# do NOT auto-init monitoring-heavy resources on import; require explicit init
cls._instance._initialized = False
return cls._instance
def init(self):
self.mic_devices = {"NoHost": [{"index": -1, "name": "NoDevice"}]}
self.default_mic_device = {"host": {"index": -1, "name": "NoHost"}, "device": {"index": -1, "name": "NoDevice"}}
self.speaker_devices = [{"index": -1, "name": "NoDevice"}]
self.default_speaker_device = {"device": {"index": -1, "name": "NoDevice"}}
def init(self) -> None:
"""Initialize internal state. This is intentionally separate from object
creation so importing the module won't start threads or access OS
audio APIs. Call `device_manager.init()` and then
`device_manager.startMonitoring()` explicitly when ready.
"""
if getattr(self, "_initialized", False):
return
self.update()
self.mic_devices: Dict[str, List[Dict[str, Any]]] = {"NoHost": [{"index": -1, "name": "NoDevice"}]}
self.default_mic_device: Dict[str, Any] = {"host": {"index": -1, "name": "NoHost"}, "device": {"index": -1, "name": "NoDevice"}}
self.speaker_devices: List[Dict[str, Any]] = [{"index": -1, "name": "NoDevice"}]
self.default_speaker_device: Dict[str, Any] = {"device": {"index": -1, "name": "NoDevice"}}
self.prev_mic_host = [host for host in self.mic_devices]
self.prev_mic_devices = self.mic_devices
self.prev_default_mic_device = self.default_mic_device
self.prev_speaker_devices = self.speaker_devices
self.prev_default_speaker_device = self.default_speaker_device
# Initialize previous state trackers
self.prev_mic_host: List[str] = [host for host in self.mic_devices]
self.prev_mic_devices: Dict[str, List[Dict[str, Any]]] = self.mic_devices
self.prev_default_mic_device: Dict[str, Any] = self.default_mic_device
self.prev_speaker_devices: List[Dict[str, Any]] = self.speaker_devices
self.prev_default_speaker_device: Dict[str, Any] = self.default_speaker_device
self.update_flag_default_mic_device = False
self.update_flag_default_speaker_device = False
self.update_flag_host_list = False
self.update_flag_mic_device_list = False
self.update_flag_speaker_device_list = False
# Update flags
self.update_flag_default_mic_device: bool = False
self.update_flag_default_speaker_device: bool = False
self.update_flag_host_list: bool = False
self.update_flag_mic_device_list: bool = False
self.update_flag_speaker_device_list: bool = False
self.callback_default_mic_device = None
self.callback_default_speaker_device = None
self.callback_host_list = None
self.callback_mic_device_list = None
self.callback_speaker_device_list = None
self.callback_process_before_update_devices = None
self.callback_process_after_update_devices = None
# Callbacks
self.callback_default_mic_device: Optional[Callable[..., None]] = None
self.callback_default_speaker_device: Optional[Callable[..., None]] = None
self.callback_host_list: Optional[Callable[..., None]] = None
self.callback_mic_device_list: Optional[Callable[..., None]] = None
self.callback_speaker_device_list: Optional[Callable[..., None]] = None
self.callback_process_before_update_mic_devices: Optional[Callable[..., None]] = None
self.callback_process_after_update_mic_devices: Optional[Callable[..., None]] = None
self.callback_process_before_update_speaker_devices: Optional[Callable[..., None]] = None
self.callback_process_after_update_speaker_devices: Optional[Callable[..., None]] = None
self.monitoring_flag = False
self.startMonitoring()
# Monitoring control
self.monitoring_flag: bool = False
self.th_monitoring: Optional[Thread] = None
self._initialized = True
def update(self):
buffer_mic_devices = {}
buffer_default_mic_device = {"host": {"index": -1, "name": "NoHost"}, "device": {"index": -1, "name": "NoDevice"}}
buffer_speaker_devices = []
buffer_default_speaker_device = {"device": {"index": -1, "name": "NoDevice"}}
buffer_mic_devices: Dict[str, List[Dict[str, Any]]] = {}
buffer_default_mic_device: Dict[str, Any] = {"host": {"index": -1, "name": "NoHost"}, "device": {"index": -1, "name": "NoDevice"}}
buffer_speaker_devices: List[Dict[str, Any]] = []
buffer_default_speaker_device: Dict[str, Any] = {"device": {"index": -1, "name": "NoDevice"}}
with PyAudio() as p:
for host_index in range(p.get_host_api_count()):
host = p.get_host_api_info_by_index(host_index)
device_count = host.get('deviceCount', 0)
for device_index in range(device_count):
device = p.get_device_info_by_host_api_device_index(host_index, device_index)
if device.get("maxInputChannels", 0) > 0 and not device.get("isLoopbackDevice", True):
buffer_mic_devices.setdefault(host["name"], []).append(device)
if not buffer_mic_devices:
buffer_mic_devices = {"NoHost": [{"index": -1, "name": "NoDevice"}]}
if PyAudio is None:
# PyAudio not available; leave defaults in place
self.mic_devices = buffer_mic_devices or {"NoHost": [{"index": -1, "name": "NoDevice"}]}
self.default_mic_device = buffer_default_mic_device
self.speaker_devices = buffer_speaker_devices or [{"index": -1, "name": "NoDevice"}]
self.default_speaker_device = buffer_default_speaker_device
return
api_info = p.get_default_host_api_info()
default_mic_device = api_info["defaultInputDevice"]
for host_index in range(p.get_host_api_count()):
host = p.get_host_api_info_by_index(host_index)
device_count = host.get('deviceCount', 0)
for device_index in range(device_count):
device = p.get_device_info_by_host_api_device_index(host_index, device_index)
if device["index"] == default_mic_device:
buffer_default_mic_device = {"host": host, "device": device}
break
else:
continue
break
speaker_devices = []
wasapi_info = p.get_host_api_info_by_type(paWASAPI)
wasapi_name = wasapi_info["name"]
for host_index in range(p.get_host_api_count()):
host = p.get_host_api_info_by_index(host_index)
if host["name"] == wasapi_name:
try:
with PyAudio() as p:
# gather input devices grouped by host
for host_index in range(p.get_host_api_count()):
host = p.get_host_api_info_by_index(host_index)
device_count = host.get('deviceCount', 0)
for device_index in range(device_count):
device = p.get_device_info_by_host_api_device_index(host_index, device_index)
if not device.get("isLoopbackDevice", True):
for loopback in p.get_loopback_device_info_generator():
if device["name"] in loopback["name"]:
speaker_devices.append(loopback)
speaker_devices = [dict(t) for t in {tuple(d.items()) for d in speaker_devices}] or [{"index": -1, "name": "NoDevice"}]
buffer_speaker_devices = sorted(speaker_devices, key=lambda d: d['index'])
if device.get("maxInputChannels", 0) > 0 and not device.get("isLoopbackDevice", True):
buffer_mic_devices.setdefault(host["name"], []).append(device)
if not buffer_mic_devices:
buffer_mic_devices = {"NoHost": [{"index": -1, "name": "NoDevice"}]}
wasapi_info = p.get_host_api_info_by_type(paWASAPI)
default_speaker_device_index = wasapi_info["defaultOutputDevice"]
api_info = p.get_default_host_api_info()
default_mic_device = api_info.get("defaultInputDevice", -1)
for host_index in range(p.get_host_api_count()):
host_info = p.get_host_api_info_by_index(host_index)
device_count = host_info.get('deviceCount', 0)
for device_index in range(0, device_count):
device = p.get_device_info_by_host_api_device_index(host_index, device_index)
if device["index"] == default_speaker_device_index:
default_speakers = device
if not default_speakers.get("isLoopbackDevice", True):
for loopback in p.get_loopback_device_info_generator():
if default_speakers["name"] in loopback["name"]:
buffer_default_speaker_device = {"device": loopback}
break
break
if buffer_default_speaker_device["device"]["name"] != "NoDevice":
for host_index in range(p.get_host_api_count()):
host = p.get_host_api_info_by_index(host_index)
device_count = host.get('deviceCount', 0)
for device_index in range(device_count):
device = p.get_device_info_by_host_api_device_index(host_index, device_index)
if device.get("index") == default_mic_device:
buffer_default_mic_device = {"host": host, "device": device}
break
else:
continue
break
# collect speaker loopback devices (requires WASAPI)
speaker_devices: List[Dict[str, Any]] = []
if paWASAPI is not None:
try:
wasapi_info = p.get_host_api_info_by_type(paWASAPI)
wasapi_name = wasapi_info.get("name")
for host_index in range(p.get_host_api_count()):
host = p.get_host_api_info_by_index(host_index)
if host.get("name") == wasapi_name:
device_count = host.get('deviceCount', 0)
for device_index in range(device_count):
device = p.get_device_info_by_host_api_device_index(host_index, device_index)
if not device.get("isLoopbackDevice", True):
for loopback in p.get_loopback_device_info_generator():
# match by name inclusion
if device.get("name") in loopback.get("name", ""):
speaker_devices.append(loopback)
except Exception:
# WASAPI not available or failed; ignore and continue
pass
# deduplicate and sort
speaker_devices = [dict(t) for t in {tuple(d.items()) for d in speaker_devices}] or [{"index": -1, "name": "NoDevice"}]
buffer_speaker_devices = sorted(speaker_devices, key=lambda d: d.get('index', -1))
# default speaker
if paWASAPI is not None:
try:
wasapi_info = p.get_host_api_info_by_type(paWASAPI)
default_speaker_device_index = wasapi_info.get("defaultOutputDevice", -1)
for host_index in range(p.get_host_api_count()):
host_info = p.get_host_api_info_by_index(host_index)
device_count = host_info.get('deviceCount', 0)
for device_index in range(0, device_count):
device = p.get_device_info_by_host_api_device_index(host_index, device_index)
if device.get("index") == default_speaker_device_index:
default_speakers = device
if not default_speakers.get("isLoopbackDevice", True):
for loopback in p.get_loopback_device_info_generator():
if default_speakers.get("name") in loopback.get("name", ""):
buffer_default_speaker_device = {"device": loopback}
break
break
if buffer_default_speaker_device["device"].get("name") != "NoDevice":
break
except Exception:
# best-effort; ignore failures
pass
except Exception:
errorLogging()
self.mic_devices = buffer_mic_devices
self.default_mic_device = buffer_default_mic_device
self.speaker_devices = buffer_speaker_devices
@@ -170,14 +238,27 @@ class DeviceManager:
try:
while self.monitoring_flag is True:
try:
comtypes.CoInitialize()
cb = Client()
enumerator = AudioUtilities.GetDeviceEnumerator()
enumerator.RegisterEndpointNotificationCallback(cb)
while cb.loop is True:
sleep(1)
enumerator.UnregisterEndpointNotificationCallback(cb)
comtypes.CoUninitialize()
# Use COM only when available (Windows). If comtypes is not present,
# fall back to periodic polling using PyAudio only.
if comtypes is not None and AudioUtilities is not None:
try:
comtypes.CoInitialize()
cb = Client()
enumerator = AudioUtilities.GetDeviceEnumerator()
enumerator.RegisterEndpointNotificationCallback(cb)
while cb.loop is True and self.monitoring_flag is True:
sleep(1)
try:
enumerator.UnregisterEndpointNotificationCallback(cb)
except Exception:
# best-effort unregister
pass
comtypes.CoUninitialize()
except Exception:
# if COM monitoring fails, log and fall through to polling
errorLogging()
# polling and update cycle
self.runProcessBeforeUpdateMicDevices()
self.runProcessBeforeUpdateSpeakerDevices()
sleep(2)
@@ -191,12 +272,12 @@ class DeviceManager:
self.runProcessAfterUpdateSpeakerDevices()
except Exception:
errorLogging()
finally:
pass
except Exception:
errorLogging()
def startMonitoring(self):
if self.monitoring_flag:
return
self.monitoring_flag = True
self.th_monitoring = Thread(target=self.monitoring)
self.th_monitoring.daemon = True
@@ -204,7 +285,12 @@ class DeviceManager:
def stopMonitoring(self):
self.monitoring_flag = False
self.th_monitoring.join()
if getattr(self, "th_monitoring", None) is not None:
try:
self.th_monitoring.join(timeout=5)
except Exception:
# If join fails or thread is not joinable, ignore - it's a best-effort stop
pass
def setCallbackDefaultMicDevice(self, callback):
self.callback_default_mic_device = callback
@@ -244,7 +330,10 @@ class DeviceManager:
def runProcessBeforeUpdateMicDevices(self):
if isinstance(self.callback_process_before_update_mic_devices, Callable):
self.callback_process_before_update_mic_devices()
try:
self.callback_process_before_update_mic_devices()
except Exception:
errorLogging()
def setCallbackProcessAfterUpdateMicDevices(self, callback):
self.callback_process_after_update_mic_devices = callback
@@ -254,7 +343,10 @@ class DeviceManager:
def runProcessAfterUpdateMicDevices(self):
if isinstance(self.callback_process_after_update_mic_devices, Callable):
self.callback_process_after_update_mic_devices()
try:
self.callback_process_after_update_mic_devices()
except Exception:
errorLogging()
def setCallbackProcessBeforeUpdateSpeakerDevices(self, callback):
self.callback_process_before_update_speaker_devices = callback
@@ -264,7 +356,10 @@ class DeviceManager:
def runProcessBeforeUpdateSpeakerDevices(self):
if isinstance(self.callback_process_before_update_speaker_devices, Callable):
self.callback_process_before_update_speaker_devices()
try:
self.callback_process_before_update_speaker_devices()
except Exception:
errorLogging()
def setCallbackProcessAfterUpdateSpeakerDevices(self, callback):
self.callback_process_after_update_speaker_devices = callback
@@ -274,7 +369,10 @@ class DeviceManager:
def runProcessAfterUpdateSpeakerDevices(self):
if isinstance(self.callback_process_after_update_speaker_devices, Callable):
self.callback_process_after_update_speaker_devices()
try:
self.callback_process_after_update_speaker_devices()
except Exception:
errorLogging()
def noticeUpdateDevices(self):
if self.update_flag_default_mic_device is True:
@@ -296,23 +394,38 @@ class DeviceManager:
def setMicDefaultDevice(self):
if isinstance(self.callback_default_mic_device, Callable):
self.callback_default_mic_device(self.default_mic_device["host"]["name"], self.default_mic_device["device"]["name"])
try:
self.callback_default_mic_device(self.default_mic_device["host"]["name"], self.default_mic_device["device"]["name"])
except Exception:
errorLogging()
def setSpeakerDefaultDevice(self):
if isinstance(self.callback_default_speaker_device, Callable):
self.callback_default_speaker_device(self.default_speaker_device["device"]["name"])
try:
self.callback_default_speaker_device(self.default_speaker_device["device"]["name"])
except Exception:
errorLogging()
def setMicHostList(self):
if isinstance(self.callback_host_list, Callable):
self.callback_host_list()
try:
self.callback_host_list()
except Exception:
errorLogging()
def setMicDeviceList(self):
if isinstance(self.callback_mic_device_list, Callable):
self.callback_mic_device_list()
try:
self.callback_mic_device_list()
except Exception:
errorLogging()
def setSpeakerDeviceList(self):
if isinstance(self.callback_speaker_device_list, Callable):
self.callback_speaker_device_list()
try:
self.callback_speaker_device_list()
except Exception:
errorLogging()
def getMicDevices(self):
return self.mic_devices
@@ -337,13 +450,15 @@ class DeviceManager:
self.setSpeakerDeviceList()
self.setSpeakerDefaultDevice()
# Provide a module-level singleton. Call `device_manager.init()` explicitly to
# initialize audio resources and `device_manager.startMonitoring()` to begin
# background monitoring. This avoids side-effects during simple imports.
device_manager = DeviceManager()
if __name__ == "__main__":
# print("getMicDevices()", device_manager.getMicDevices())
# print("getDefaultMicDevice()", device_manager.getDefaultMicDevice())
# print("getSpeakerDevices()", device_manager.getSpeakerDevices())
# print("getDefaultSpeakerDevice()", device_manager.getDefaultSpeakerDevice())
while True:
sleep(1)
print("DeviceManager demo. Call device_manager.init() and device_manager.startMonitoring() to run live monitoring.")
try:
while True:
sleep(1)
except KeyboardInterrupt:
print("exiting")