デバイス管理モジュールのインポートをガードし、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 time import sleep
from threading import Thread from threading import Thread
import comtypes
from pyaudiowpatch import PyAudio, paWASAPI # Optional, Windows-specific dependencies. Guard imports so module can be imported on non-Windows systems.
from pycaw.callbacks import MMNotificationClient try:
from pycaw.utils import AudioUtilities 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 from utils import errorLogging
class Client(MMNotificationClient): class Client(MMNotificationClient):
def __init__(self): """Callback client used by pycaw to detect device changes.
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__() super().__init__()
self.loop = True except Exception:
pass
self.loop: bool = True
def on_default_device_changed(self, flow, flow_id, role, role_id, default_device_id): def on_default_device_changed(self, *args: Any, **kwargs: Any) -> None:
self.loop = False self.loop = False
def on_device_added(self, added_device_id): def on_device_added(self, *args: Any, **kwargs: Any) -> None:
self.loop = False self.loop = False
def on_device_removed(self, removed_device_id): def on_device_removed(self, *args: Any, **kwargs: Any) -> None:
self.loop = False 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 self.loop = False
# def on_property_value_changed(self, device_id, key): # def on_property_value_changed(self, device_id, key):
@@ -33,47 +59,72 @@ class DeviceManager:
def __new__(cls): def __new__(cls):
if cls._instance is None: if cls._instance is None:
cls._instance = super(DeviceManager, cls).__new__(cls) 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 return cls._instance
def init(self): def init(self) -> None:
self.mic_devices = {"NoHost": [{"index": -1, "name": "NoDevice"}]} """Initialize internal state. This is intentionally separate from object
self.default_mic_device = {"host": {"index": -1, "name": "NoHost"}, "device": {"index": -1, "name": "NoDevice"}} creation so importing the module won't start threads or access OS
self.speaker_devices = [{"index": -1, "name": "NoDevice"}] audio APIs. Call `device_manager.init()` and then
self.default_speaker_device = {"device": {"index": -1, "name": "NoDevice"}} `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] # Initialize previous state trackers
self.prev_mic_devices = self.mic_devices self.prev_mic_host: List[str] = [host for host in self.mic_devices]
self.prev_default_mic_device = self.default_mic_device self.prev_mic_devices: Dict[str, List[Dict[str, Any]]] = self.mic_devices
self.prev_speaker_devices = self.speaker_devices self.prev_default_mic_device: Dict[str, Any] = self.default_mic_device
self.prev_default_speaker_device = self.default_speaker_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 # Update flags
self.update_flag_default_speaker_device = False self.update_flag_default_mic_device: bool = False
self.update_flag_host_list = False self.update_flag_default_speaker_device: bool = False
self.update_flag_mic_device_list = False self.update_flag_host_list: bool = False
self.update_flag_speaker_device_list = False self.update_flag_mic_device_list: bool = False
self.update_flag_speaker_device_list: bool = False
self.callback_default_mic_device = None # Callbacks
self.callback_default_speaker_device = None self.callback_default_mic_device: Optional[Callable[..., None]] = None
self.callback_host_list = None self.callback_default_speaker_device: Optional[Callable[..., None]] = None
self.callback_mic_device_list = None self.callback_host_list: Optional[Callable[..., None]] = None
self.callback_speaker_device_list = None self.callback_mic_device_list: Optional[Callable[..., None]] = None
self.callback_process_before_update_devices = None self.callback_speaker_device_list: Optional[Callable[..., None]] = None
self.callback_process_after_update_devices = 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 # Monitoring control
self.startMonitoring() self.monitoring_flag: bool = False
self.th_monitoring: Optional[Thread] = None
self._initialized = True
def update(self): def update(self):
buffer_mic_devices = {} buffer_mic_devices: Dict[str, List[Dict[str, Any]]] = {}
buffer_default_mic_device = {"host": {"index": -1, "name": "NoHost"}, "device": {"index": -1, "name": "NoDevice"}} buffer_default_mic_device: Dict[str, Any] = {"host": {"index": -1, "name": "NoHost"}, "device": {"index": -1, "name": "NoDevice"}}
buffer_speaker_devices = [] buffer_speaker_devices: List[Dict[str, Any]] = []
buffer_default_speaker_device = {"device": {"index": -1, "name": "NoDevice"}} buffer_default_speaker_device: Dict[str, Any] = {"device": {"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
try:
with PyAudio() as p: with PyAudio() as p:
# gather input devices grouped by host
for host_index in range(p.get_host_api_count()): for host_index in range(p.get_host_api_count()):
host = p.get_host_api_info_by_index(host_index) host = p.get_host_api_info_by_index(host_index)
device_count = host.get('deviceCount', 0) device_count = host.get('deviceCount', 0)
@@ -85,55 +136,72 @@ class DeviceManager:
buffer_mic_devices = {"NoHost": [{"index": -1, "name": "NoDevice"}]} buffer_mic_devices = {"NoHost": [{"index": -1, "name": "NoDevice"}]}
api_info = p.get_default_host_api_info() api_info = p.get_default_host_api_info()
default_mic_device = api_info["defaultInputDevice"] default_mic_device = api_info.get("defaultInputDevice", -1)
for host_index in range(p.get_host_api_count()): for host_index in range(p.get_host_api_count()):
host = p.get_host_api_info_by_index(host_index) host = p.get_host_api_info_by_index(host_index)
device_count = host.get('deviceCount', 0) device_count = host.get('deviceCount', 0)
for device_index in range(device_count): for device_index in range(device_count):
device = p.get_device_info_by_host_api_device_index(host_index, device_index) device = p.get_device_info_by_host_api_device_index(host_index, device_index)
if device["index"] == default_mic_device: if device.get("index") == default_mic_device:
buffer_default_mic_device = {"host": host, "device": device} buffer_default_mic_device = {"host": host, "device": device}
break break
else: else:
continue continue
break break
speaker_devices = [] # 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_info = p.get_host_api_info_by_type(paWASAPI)
wasapi_name = wasapi_info["name"] wasapi_name = wasapi_info.get("name")
for host_index in range(p.get_host_api_count()): for host_index in range(p.get_host_api_count()):
host = p.get_host_api_info_by_index(host_index) host = p.get_host_api_info_by_index(host_index)
if host["name"] == wasapi_name: if host.get("name") == wasapi_name:
device_count = host.get('deviceCount', 0) device_count = host.get('deviceCount', 0)
for device_index in range(device_count): for device_index in range(device_count):
device = p.get_device_info_by_host_api_device_index(host_index, device_index) device = p.get_device_info_by_host_api_device_index(host_index, device_index)
if not device.get("isLoopbackDevice", True): if not device.get("isLoopbackDevice", True):
for loopback in p.get_loopback_device_info_generator(): for loopback in p.get_loopback_device_info_generator():
if device["name"] in loopback["name"]: # match by name inclusion
if device.get("name") in loopback.get("name", ""):
speaker_devices.append(loopback) 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"}] 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']) 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) wasapi_info = p.get_host_api_info_by_type(paWASAPI)
default_speaker_device_index = wasapi_info["defaultOutputDevice"] default_speaker_device_index = wasapi_info.get("defaultOutputDevice", -1)
for host_index in range(p.get_host_api_count()): for host_index in range(p.get_host_api_count()):
host_info = p.get_host_api_info_by_index(host_index) host_info = p.get_host_api_info_by_index(host_index)
device_count = host_info.get('deviceCount', 0) device_count = host_info.get('deviceCount', 0)
for device_index in range(0, device_count): for device_index in range(0, device_count):
device = p.get_device_info_by_host_api_device_index(host_index, device_index) device = p.get_device_info_by_host_api_device_index(host_index, device_index)
if device["index"] == default_speaker_device_index: if device.get("index") == default_speaker_device_index:
default_speakers = device default_speakers = device
if not default_speakers.get("isLoopbackDevice", True): if not default_speakers.get("isLoopbackDevice", True):
for loopback in p.get_loopback_device_info_generator(): for loopback in p.get_loopback_device_info_generator():
if default_speakers["name"] in loopback["name"]: if default_speakers.get("name") in loopback.get("name", ""):
buffer_default_speaker_device = {"device": loopback} buffer_default_speaker_device = {"device": loopback}
break break
break break
if buffer_default_speaker_device["device"]["name"] != "NoDevice": if buffer_default_speaker_device["device"].get("name") != "NoDevice":
break break
except Exception:
# best-effort; ignore failures
pass
except Exception:
errorLogging()
self.mic_devices = buffer_mic_devices self.mic_devices = buffer_mic_devices
self.default_mic_device = buffer_default_mic_device self.default_mic_device = buffer_default_mic_device
@@ -169,15 +237,28 @@ class DeviceManager:
def monitoring(self): def monitoring(self):
try: try:
while self.monitoring_flag is True: while self.monitoring_flag is True:
try:
# 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: try:
comtypes.CoInitialize() comtypes.CoInitialize()
cb = Client() cb = Client()
enumerator = AudioUtilities.GetDeviceEnumerator() enumerator = AudioUtilities.GetDeviceEnumerator()
enumerator.RegisterEndpointNotificationCallback(cb) enumerator.RegisterEndpointNotificationCallback(cb)
while cb.loop is True: while cb.loop is True and self.monitoring_flag is True:
sleep(1) sleep(1)
try:
enumerator.UnregisterEndpointNotificationCallback(cb) enumerator.UnregisterEndpointNotificationCallback(cb)
except Exception:
# best-effort unregister
pass
comtypes.CoUninitialize() comtypes.CoUninitialize()
except Exception:
# if COM monitoring fails, log and fall through to polling
errorLogging()
# polling and update cycle
self.runProcessBeforeUpdateMicDevices() self.runProcessBeforeUpdateMicDevices()
self.runProcessBeforeUpdateSpeakerDevices() self.runProcessBeforeUpdateSpeakerDevices()
sleep(2) sleep(2)
@@ -191,12 +272,12 @@ class DeviceManager:
self.runProcessAfterUpdateSpeakerDevices() self.runProcessAfterUpdateSpeakerDevices()
except Exception: except Exception:
errorLogging() errorLogging()
finally:
pass
except Exception: except Exception:
errorLogging() errorLogging()
def startMonitoring(self): def startMonitoring(self):
if self.monitoring_flag:
return
self.monitoring_flag = True self.monitoring_flag = True
self.th_monitoring = Thread(target=self.monitoring) self.th_monitoring = Thread(target=self.monitoring)
self.th_monitoring.daemon = True self.th_monitoring.daemon = True
@@ -204,7 +285,12 @@ class DeviceManager:
def stopMonitoring(self): def stopMonitoring(self):
self.monitoring_flag = False 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): def setCallbackDefaultMicDevice(self, callback):
self.callback_default_mic_device = callback self.callback_default_mic_device = callback
@@ -244,7 +330,10 @@ class DeviceManager:
def runProcessBeforeUpdateMicDevices(self): def runProcessBeforeUpdateMicDevices(self):
if isinstance(self.callback_process_before_update_mic_devices, Callable): if isinstance(self.callback_process_before_update_mic_devices, Callable):
try:
self.callback_process_before_update_mic_devices() self.callback_process_before_update_mic_devices()
except Exception:
errorLogging()
def setCallbackProcessAfterUpdateMicDevices(self, callback): def setCallbackProcessAfterUpdateMicDevices(self, callback):
self.callback_process_after_update_mic_devices = callback self.callback_process_after_update_mic_devices = callback
@@ -254,7 +343,10 @@ class DeviceManager:
def runProcessAfterUpdateMicDevices(self): def runProcessAfterUpdateMicDevices(self):
if isinstance(self.callback_process_after_update_mic_devices, Callable): if isinstance(self.callback_process_after_update_mic_devices, Callable):
try:
self.callback_process_after_update_mic_devices() self.callback_process_after_update_mic_devices()
except Exception:
errorLogging()
def setCallbackProcessBeforeUpdateSpeakerDevices(self, callback): def setCallbackProcessBeforeUpdateSpeakerDevices(self, callback):
self.callback_process_before_update_speaker_devices = callback self.callback_process_before_update_speaker_devices = callback
@@ -264,7 +356,10 @@ class DeviceManager:
def runProcessBeforeUpdateSpeakerDevices(self): def runProcessBeforeUpdateSpeakerDevices(self):
if isinstance(self.callback_process_before_update_speaker_devices, Callable): if isinstance(self.callback_process_before_update_speaker_devices, Callable):
try:
self.callback_process_before_update_speaker_devices() self.callback_process_before_update_speaker_devices()
except Exception:
errorLogging()
def setCallbackProcessAfterUpdateSpeakerDevices(self, callback): def setCallbackProcessAfterUpdateSpeakerDevices(self, callback):
self.callback_process_after_update_speaker_devices = callback self.callback_process_after_update_speaker_devices = callback
@@ -274,7 +369,10 @@ class DeviceManager:
def runProcessAfterUpdateSpeakerDevices(self): def runProcessAfterUpdateSpeakerDevices(self):
if isinstance(self.callback_process_after_update_speaker_devices, Callable): if isinstance(self.callback_process_after_update_speaker_devices, Callable):
try:
self.callback_process_after_update_speaker_devices() self.callback_process_after_update_speaker_devices()
except Exception:
errorLogging()
def noticeUpdateDevices(self): def noticeUpdateDevices(self):
if self.update_flag_default_mic_device is True: if self.update_flag_default_mic_device is True:
@@ -296,23 +394,38 @@ class DeviceManager:
def setMicDefaultDevice(self): def setMicDefaultDevice(self):
if isinstance(self.callback_default_mic_device, Callable): if isinstance(self.callback_default_mic_device, Callable):
try:
self.callback_default_mic_device(self.default_mic_device["host"]["name"], self.default_mic_device["device"]["name"]) self.callback_default_mic_device(self.default_mic_device["host"]["name"], self.default_mic_device["device"]["name"])
except Exception:
errorLogging()
def setSpeakerDefaultDevice(self): def setSpeakerDefaultDevice(self):
if isinstance(self.callback_default_speaker_device, Callable): if isinstance(self.callback_default_speaker_device, Callable):
try:
self.callback_default_speaker_device(self.default_speaker_device["device"]["name"]) self.callback_default_speaker_device(self.default_speaker_device["device"]["name"])
except Exception:
errorLogging()
def setMicHostList(self): def setMicHostList(self):
if isinstance(self.callback_host_list, Callable): if isinstance(self.callback_host_list, Callable):
try:
self.callback_host_list() self.callback_host_list()
except Exception:
errorLogging()
def setMicDeviceList(self): def setMicDeviceList(self):
if isinstance(self.callback_mic_device_list, Callable): if isinstance(self.callback_mic_device_list, Callable):
try:
self.callback_mic_device_list() self.callback_mic_device_list()
except Exception:
errorLogging()
def setSpeakerDeviceList(self): def setSpeakerDeviceList(self):
if isinstance(self.callback_speaker_device_list, Callable): if isinstance(self.callback_speaker_device_list, Callable):
try:
self.callback_speaker_device_list() self.callback_speaker_device_list()
except Exception:
errorLogging()
def getMicDevices(self): def getMicDevices(self):
return self.mic_devices return self.mic_devices
@@ -337,13 +450,15 @@ class DeviceManager:
self.setSpeakerDeviceList() self.setSpeakerDeviceList()
self.setSpeakerDefaultDevice() 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() device_manager = DeviceManager()
if __name__ == "__main__": if __name__ == "__main__":
# print("getMicDevices()", device_manager.getMicDevices()) print("DeviceManager demo. Call device_manager.init() and device_manager.startMonitoring() to run live monitoring.")
# print("getDefaultMicDevice()", device_manager.getDefaultMicDevice()) try:
# print("getSpeakerDevices()", device_manager.getSpeakerDevices())
# print("getDefaultSpeakerDevice()", device_manager.getDefaultSpeakerDevice())
while True: while True:
sleep(1) sleep(1)
except KeyboardInterrupt:
print("exiting")

View File

@@ -0,0 +1,93 @@
# device_manager.py — デバイス検出と監視 (改訂版)
### 概要
`device_manager.py` はローカルのマイク(入力)とスピーカー(ループバックから抽出)を列挙し、デフォルトデバイスの変更やデバイスリストの変化を監視してコールバックで通知するユーティリティです。
設計上のポイント:
- Windows 固有の依存 (`comtypes`, `pyaudiowpatch` (PyAudio + WASAPI), `pycaw`) はオプショナルです。モジュールを import してもこれらが無ければ例外にならず、プレースホルダ値を返すようになっています。
- モジュールの import 時点では監視は開始されません。リソースやスレッドの副作用を避けるため、`init()``startMonitoring()` は呼び出し側で明示的に実行してください。
---
### 使い方(簡単な流れ)
1. モジュールをインポート
```py
from device_manager import device_manager
```
2. 初期化(内部状態のセットアップ)
```py
device_manager.init()
```
3. 監視の開始(バックグラウンドスレッド)
```py
device_manager.startMonitoring()
```
4. 停止(アプリ終了時など)
```py
device_manager.stopMonitoring()
```
---
### 主な API
- `device_manager.init()`
- internal state の初期化。import 後に必ず呼ぶ必要はないが、実機デバイスを取得する前に呼ぶことを推奨します。
- `device_manager.startMonitoring()` / `device_manager.stopMonitoring()`
- 監視の開始 / 停止。`startMonitoring()` はデーモンスレッドを作成します。`stopMonitoring()` は best-effort で join を試みます。
- `device_manager.getMicDevices()`
- ホストごとにグループ化された入力デバイスの辞書を返します。例: `{ 'Realtek': [ {index: 2, name: 'Microphone (Realtek)'} ] }`
- `device_manager.getDefaultMicDevice()` / `device_manager.getSpeakerDevices()` / `device_manager.getDefaultSpeakerDevice()`
- デフォルトデバイスやスピーカーループバックの情報を返します。
- `device_manager.forceUpdateAndSetMicDevices()` / `device_manager.forceUpdateAndSetSpeakerDevices()`
- 即時に update() を実行して対応するコールバックを呼びます。
---
### コールバック登録(例)
コールバックは例外を内部で捕捉してログを出すため、コールバック実装側でもエラーハンドリングしてください。
- `setCallbackDefaultMicDevice(callback)` — デフォルト入力が変わったときに `callback(host_name, device_name)` が呼ばれます。
- `setCallbackDefaultSpeakerDevice(callback)` — デフォルト出力が変わったときに `callback(device_name)` が呼ばれます。
- `setCallbackHostList(callback)` / `setCallbackMicDeviceList(callback)` / `setCallbackSpeakerDeviceList(callback)` — それぞれ list 変更時に `callback()` が呼ばれます。
- `setCallbackProcessBeforeUpdateMicDevices(callback)` / `setCallbackProcessAfterUpdateMicDevices(callback)` — 更新の前後に呼ばれるフックです。
簡単な例:
```py
from device_manager import device_manager
def on_default_mic(host, device):
print('default mic changed', host, device)
device_manager.init()
device_manager.setCallbackDefaultMicDevice(on_default_mic)
device_manager.startMonitoring()
# 後で停止
# device_manager.stopMonitoring()
```
---
### 注意点 / トラブルシュート
- Windows 固有の依存が無い場合、`getMicDevices()` などはデフォルトのプレースホルダ(`NoHost` / `NoDevice`)を返します。実機のデバイス検出や WASAPI によるループバック検出は Windows 環境でのみ保証されます。
- `startMonitoring()` は監視用のデーモンスレッドを作るため、アプリケーションの終了時には `stopMonitoring()` を呼ぶかプロセスを終了してください。`stopMonitoring()` は join を行いますが、失敗した場合でも致命的にならないよう best-effort 実装です。
- コールバック内部で例外が発生してもモジュール側で捕捉してログ出力します(`utils.errorLogging()`)。コールバック側で詳細なハンドリングやリトライが必要な場合は呼び出し側で行ってください。
---
### 実装メモ
- `monitoring()` は可能なら Windows の COM (pycaw / MMNotificationClient) を使ってイベント駆動で待ち受け、失敗時や非Windows 環境では PyAudio を使ったポーリング(定期的な update()) にフォールバックします。
- 外部ライブラリが原因の例外は内部で捕捉し、`errorLogging()` を呼んで記録する設計です。