diff --git a/src-python/backend_test.py b/src-python/backend_test.py index d56b1622..8fea1092 100644 --- a/src-python/backend_test.py +++ b/src-python/backend_test.py @@ -35,12 +35,13 @@ class Color: class TestMainloop(): def __init__(self): self.main = main_instance - self.main.startReceiver() - self.main.startHandler() + # Start mainloop threads + self.main.start() - def stop_main(): - pass - self.main.controller.setWatchdogCallback(stop_main) + # Ensure the watchdog can stop the mainloop cleanly + def _none_watchdog(): + return None + self.main.controller.setWatchdogCallback(_none_watchdog) self.main.controller.init() # mappingのすべてのstatusをTrueにする @@ -148,15 +149,16 @@ class TestMainloop(): def test_endpoints_on_off_continuous(self): print("----ON/OFF連続テスト----") - endpoints = ["/set/enable/websocket_server", "/set/disable/websocket_server"] - # endpoints = [ - # "/set/enable/translation", - # "/set/disable/translation", - # "/set/enable/transcription_send", - # "/set/disable/transcription_send", - # "/set/enable/transcription_receive", - # "/set/disable/transcription_receive", - # ] + endpoints = [ + "/set/enable/translation", + "/set/disable/translation", + "/set/enable/transcription_send", + "/set/disable/transcription_send", + "/set/enable/transcription_receive", + "/set/disable/transcription_receive", + # "/set/enable/websocket_server", + # "/set/disable/websocket_server", + ] for i in range(1000): endpoint = random.choice(endpoints) print(f"No.{i:04} Testing endpoint: {endpoint}", flush=True) @@ -739,14 +741,27 @@ if __name__ == "__main__": # test.test_set_data_endpoints_all() # test.test_run_endpoints_all() # test.test_delete_data_endpoints_all() - test.test_endpoints_all_random() - # test.test_endpoints_on_off_continuous() + # test.test_endpoints_all_random() + test.test_endpoints_on_off_continuous() # test.test_endpoints_on_off_random() # test.test_endpoints_specific_random() # test.test_translate_all_language_pairs() test.generate_summary() except KeyboardInterrupt: print("Interrupted by user, shutting down...") + try: + main_instance.stop() + except Exception: + pass except Exception as e: traceback.print_exc() - print(f"An error occurred: {e}") \ No newline at end of file + print(f"An error occurred: {e}") + try: + main_instance.stop() + except Exception: + pass + finally: + try: + main_instance.stop() + except Exception: + pass \ No newline at end of file diff --git a/src-python/config.py b/src-python/config.py index 0e8a37e2..54ae2b67 100644 --- a/src-python/config.py +++ b/src-python/config.py @@ -1220,8 +1220,10 @@ class Config: # device_manager may be unavailable or not initialized; use safe defaults try: if device_manager is not None: - self._SELECTED_MIC_HOST = device_manager.getDefaultMicDevice()["host"]["name"] - self._SELECTED_MIC_DEVICE = device_manager.getDefaultMicDevice()["device"]["name"] + # getDefaultMicDevice performs lazy init/update if needed + dm_def = device_manager.getDefaultMicDevice() + self._SELECTED_MIC_HOST = dm_def.get("host", {}).get("name", "NoHost") + self._SELECTED_MIC_DEVICE = dm_def.get("device", {}).get("name", "NoDevice") else: self._SELECTED_MIC_HOST = "NoHost" self._SELECTED_MIC_DEVICE = "NoDevice" @@ -1247,7 +1249,8 @@ class Config: self._AUTO_SPEAKER_SELECT = True try: if device_manager is not None: - self._SELECTED_SPEAKER_DEVICE = device_manager.getDefaultSpeakerDevice()["device"]["name"] + sp_def = device_manager.getDefaultSpeakerDevice() + self._SELECTED_SPEAKER_DEVICE = sp_def.get("device", {}).get("name", "NoDevice") else: self._SELECTED_SPEAKER_DEVICE = "NoDevice" except Exception: diff --git a/src-python/device_manager.py b/src-python/device_manager.py index b41a0cc3..6c4096a7 100644 --- a/src-python/device_manager.py +++ b/src-python/device_manager.py @@ -60,7 +60,20 @@ class DeviceManager: if cls._instance is None: cls._instance = super(DeviceManager, cls).__new__(cls) # do NOT auto-init monitoring-heavy resources on import; require explicit init + # Still perform a light-weight init so that callers observing the singleton + # do not see uninitialized internal structures (which caused NoDevice to + # be seen when import order differed). cls._instance._initialized = False + try: + # Call init() to populate internal containers. This will NOT start + # the monitoring thread (startMonitoring must be called explicitly). + cls._instance.init() + except Exception: + # Avoid import-time crashes; log and continue. + try: + errorLogging() + except Exception: + pass return cls._instance def init(self) -> None: @@ -108,6 +121,22 @@ class DeviceManager: self._initialized = True + # Best-effort single update: if PyAudio is available, attempt to populate + # real device lists. Keep this short and ignore errors to avoid import-time + # failures. + try: + if PyAudio is not None: + try: + # update() is robust and will fall back to defaults if audio libs + # are missing or fail; do not let exceptions bubble up. + self.update() + except Exception: + errorLogging() + except Exception: + # defensive: if errorLogging isn't available or other issues occur, + # swallow to avoid breaking initialization + pass + def update(self): 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"}} @@ -428,16 +457,52 @@ class DeviceManager: errorLogging() def getMicDevices(self): - return self.mic_devices + # Ensure initialized and return devices (safe default if still not populated) + if not getattr(self, '_initialized', False): + try: + self.init() + except Exception: + try: + errorLogging() + except Exception: + pass + return getattr(self, 'mic_devices', {"NoHost": [{"index": -1, "name": "NoDevice"}]}) def getDefaultMicDevice(self): - return self.default_mic_device + # Ensure initialized and return default mic device (safe default if still not populated) + if not getattr(self, '_initialized', False): + try: + self.init() + except Exception: + try: + errorLogging() + except Exception: + pass + return getattr(self, 'default_mic_device', {"host": {"index": -1, "name": "NoHost"}, "device": {"index": -1, "name": "NoDevice"}}) def getSpeakerDevices(self): - return self.speaker_devices + # Ensure initialized and return speaker devices (safe default if still not populated) + if not getattr(self, '_initialized', False): + try: + self.init() + except Exception: + try: + errorLogging() + except Exception: + pass + return getattr(self, 'speaker_devices', [{"index": -1, "name": "NoDevice"}]) def getDefaultSpeakerDevice(self): - return self.default_speaker_device + # Ensure initialized and return default speaker device (safe default if still not populated) + if not getattr(self, '_initialized', False): + try: + self.init() + except Exception: + try: + errorLogging() + except Exception: + pass + return getattr(self, 'default_speaker_device', {"device": {"index": -1, "name": "NoDevice"}}) def forceUpdateAndSetMicDevices(self): self.update() diff --git a/src-python/docs/modules/config.md b/src-python/docs/modules/config.md index d33e7b42..cdd83b8c 100644 --- a/src-python/docs/modules/config.md +++ b/src-python/docs/modules/config.md @@ -172,6 +172,15 @@ config.saveConfig('CUSTOM_SAVE', {'foo': 'bar'}, immediate_save=True) - `saveConfig()` はデバウンスされるため、高頻度の設定変更では複数の変更がまとめて書き込まれます。即時書き込みが必要な操作(重要な鍵の更新など)は `immediate_save=True` を使ってください。 - `SELECTABLE_*` 系や `*_DICT` 系は初期化時に外部モジュール(翻訳リソース、whisper_models、device_manager 等)から生成されます。これらが利用できない環境ではデフォルトが空になる可能性があります。 +### 2025-10-13 の変更(device_manager / config に関する挙動改善) + +- `DeviceManager` のシングルトン生成時に軽量 `init()` を実行するようになりました。これにより、モジュールのインポート順序に依存して `config` の `SELECTED_*` が `NoDevice` のままになる問題が軽減されます(監視スレッドは自動起動しません)。 +- `config.init_config()` はこれまで `device_manager._initialized` をチェックしていた箇所を見直し、`device_manager.getDefaultMicDevice()` / `getDefaultSpeakerDevice()` といったアクセサを利用して値を取得するように変更しました。アクセサは必要なら遅延初期化を行うため、`controller` と `config` のトップレベルインポート順に依存しません。 +- 影響: 起動時に PyAudio 等の依存が利用可能であれば、起動中に実機デバイス名が `config` に反映される確率が高くなります。依存がない場合は従来どおり `NoDevice` にフォールバックします。 + +推奨運用: +- `controller.init()` でコールバック登録後、`mainloop` の起動シーケンスで `device_manager.startMonitoring()` を明示的に呼ぶと、起動後もデバイス変更がコールバック経由で確実に届きます(この呼び出しは任意です)。 + ## 推奨改善点(将来的なドキュメント/実装) - 設定スキーマを JSON Schema で定義し、load 時の検証を明確化すると安全性が向上します。 - 設定変更イベントを発火する仕組み(observer パターン)を導入すると、Controller/Model 側の再初期化処理をより明確に実装できます。 diff --git a/src-python/docs/modules/controller.md b/src-python/docs/modules/controller.md index f2ae57a6..ccbb6cfd 100644 --- a/src-python/docs/modules/controller.md +++ b/src-python/docs/modules/controller.md @@ -5,6 +5,10 @@ - UI からのコマンドを受け取り、`model` の開始/停止、設定の変更、ダウンロードの開始、各種フラグの切り替え、進捗通知(`run` コールバック経由)を行います。 - 多くのメソッドは JSON 系の応答オブジェクトを返します: {"status": int, "result": Any}。副作用で `self.run(status, run_mapping[key], payload)` を呼び出して UI に通知します。 +### mainloop のマルチワーカー化とカノニカルロックについて (2025-10-13) + +- `mainloop.Main` はデフォルトで複数(デフォルト 3)のハンドラワーカースレッドを動かすようになりました。これにより、モデルロードなどの重い操作で他のリクエストが待たされることが少なくなります。 +- `/set/enable/` と `/set/disable/` のように同一機能の ON/OFF を切り替えるエンドポイントは、内部的にカノニカルロックキー(例: `/lock/set/`)に正規化してロック取得されます。これにより、遅い disable の処理が後から来て最終状態を書き換えてしまうレースが防がれます。 初期化とランタイムフック - __init__() -> None - フィールド: `init_mapping: dict`, `run_mapping: dict`, `run: Callable`, `device_access_status: bool` diff --git a/src-python/docs/modules/device_manager.md b/src-python/docs/modules/device_manager.md index f681b2d9..316384c2 100644 --- a/src-python/docs/modules/device_manager.md +++ b/src-python/docs/modules/device_manager.md @@ -45,6 +45,17 @@ device_manager.forceUpdateAndSetMicDevices() - Windows 固有のモジュール(PyAudio paWASAPI, pycaw)に依存します。クロスプラットフォーム対応が必要な場合は別実装が必要です。 - 監視スレッドは永続的に動作するため、アプリケーション終了時は `stopMonitoring()` を呼んで安全に停止してください。 +変更点(2025-10-13): +- `DeviceManager` のシングルトン生成時(`__new__`)に軽量な `init()` を実行するようになりました。これによりモジュールのインポート順に依存せず、最小限の内部構造が常に確立されます(※監視スレッドは自動で起動しません)。 +- `init()` は監視スレッドを開始しませんが、PyAudio が利用可能な場合に限りベストエフォートで一度だけ `update()` を呼び、起動時に可能な限り実機デバイス情報を埋めるようになりました(例外は握り潰して安全性を維持)。 +- アクセサ (`getDefaultMicDevice()` / `getDefaultSpeakerDevice()` など) は遅延初期化を行い、呼び出し時に `init()` が動いていない場合は安全に初期化されるようになりました。これにより `controller` と `config` がトップレベルインポートで互いに依存している状況でも、`config` に正しいデバイス情報が入るようになります。 + +推奨起動シーケンス: +- `controller.init()` でコールバック登録が完了した直後に、`mainloop` の起動シーケンス中で明示的に `device_manager.startMonitoring()` を呼ぶことを推奨します。これにより以降のデバイス変更がコールバックを通じて確実に届きます。なお、`startMonitoring()` は任意で、軽量にしたい場合は呼ばなくても構いません(ただし動的変化は検出されません)。 + +ドキュメントにおける重要な注意: +- この変更は "import-time に重大な副作用を持たせない" という方針を維持しつつ、インポート順の違いによる初期化漏れを解消するために行われています。`init()` は監視スレッドを開始しないため、インポートだけでスレッドが走ることはありません。 + ## 詳細設計 目的: ローカルの入力(マイク)と出力(ループバックから抽出されたスピーカー)デバイスを列挙し、変更を監視してコールバックで通知する。Windows の WASAPI 等に依存。 diff --git a/src-python/docs/modules/mainloop.md b/src-python/docs/modules/mainloop.md index 897f9d5d..ba38c6a8 100644 --- a/src-python/docs/modules/mainloop.md +++ b/src-python/docs/modules/mainloop.md @@ -1,22 +1,28 @@ ## mainloop モジュール(src-python/mainloop.py) -このドキュメントは `mainloop.py` の実装と、2025-10-09 に行ったリファクタの概要をまとめます。`mainloop` は標準入力から JSON を受け取り、`controller` のメソッドにルーティングして標準出力へ JSON で応答を返す小さなメインループです。 +このドキュメントは `mainloop.py` の実装と、最近行ったリファクタの概要をまとめます。`mainloop` は標準入力から JSON を受け取り、`controller` のメソッドにルーティングして標準出力へ JSON で応答を返す小さなメインループです。 -重要な変更点(2025-10-09): -- `Main` クラスに `start()` / `stop()` を追加し、受信スレッドとハンドラスレッドのライフサイクル管理を明示化しました。 -- `queue.get(timeout=...)` を使ってポーリング負荷を下げ、`_stop_event` による安全なシャットダウンを可能にしました。 -- 標準入力の JSON パースエラーと一般例外のハンドリングを強化しました。 -- `startReceiver()` / `startHandler()` を使って個別にスレッドを起動することも可能です。 +重要な変更点: +- 2025-10-09: `Main` クラスに `start()` / `stop()` を追加し、受信スレッドとハンドラスレッドのライフサイクル管理を明示化しました。`queue.get(timeout=...)` による安全なシャットダウンを可能にしています。 +- 2025-10-13: ハンドラの振る舞いを改善しました(マルチワーカー化とロック正規化): + - マルチワーカー化: ハンドラ処理はデフォルトで複数ワーカー(例: 3 本)で並列実行されます。これにより、1 つの重い処理が他のすべてのリクエストをブロックしてしまう問題を緩和します。 + - ロック正規化: `/set/enable/` と `/set/disable/` のような on/off ペアは同一のロックキーに正規化され、同一機能の on と off が同時に別スレッドで実行されることを防ぎます。これにより、遅い方の処理結果が後から上書きして最終状態が意図しないものになる不具合を防止します。 クラス: Main -- __init__(controller_instance: Controller, mapping_data: dict) -> None +- __init__(controller_instance: Controller, mapping_data: dict, worker_count: int = 3) -> None - `controller_instance`: `Controller` のインスタンス。 - `mapping_data`: `mainloop` 内で使用する `mapping`(エンドポイント -> ハンドラ情報)辞書。 + - `worker_count`: ハンドラワーカー数(デフォルト 3)。実行環境に応じて調整可能です。 - start() -> None - - 内部で `startReceiver()` と `startHandler()` を呼び、両スレッドを起動します。 + - 内部で `startReceiver()` と `startHandler()` を呼び、受信とハンドラのスレッド群を起動します。 - stop(wait: float = 2.0) -> None - シャットダウンシグナルをセットし、スレッド終了を待ちます(デフォルト 2 秒)。 +動作の重要ポイント +- キュー運用: 受信した JSON は内部キューに入れられ、ハンドラワーカーが順次取り出して処理します。`queue.get(timeout=...)` を使っているため CPU 負荷を抑えつつ安全に停止できます。 +- 同期応答設計: 各エンドポイントは基本的に呼び出し元に同期的に結果を返します(`handler` が戻り値としてステータスと結果を返す)。今回の変更でもこの設計は維持されています。 +- 同一機能直列化: `/set/enable/X` と `/set/disable/X` のような on/off ペアは内部で同一の "ロックキー" に正規化され、同時に両方が実行されることを防ぎます。これにより、enable と disable が競合して遅い方が勝つ問題が解消されます。 + 使い方(例): ```python @@ -29,15 +35,16 @@ main_instance.start() main_instance.stop() ``` -既存のスクリプト互換性: -- 既存コードが `startReceiver()` や `startHandler()` を直接呼んでいる場合、そのまま動作します。`start()` / `stop()` を使うと簡潔に起動 / 停止が行えます。 +確認手順(変更の検証): +1. バックエンドを起動しておく。 +2. UI/テストスクリプトから `/set/enable/translation` と `/set/disable/translation` を高速に交互送信する(数十〜数百ミリ秒間隔で連打)。 +3. ログ(`printLog` 出力)を確認し、同一機能の複数実行が同時に走っていないこと、最終状態が遅い方に常に上書きされないことを確認する。 +4. 必要に応じて `worker_count` を増減して挙動を確認する(PC リソースに応じて 1〜6 程度を推奨)。 注意点と推奨事項: -- `stop()` を呼ばないとバックグラウンドスレッドがデーモンであってもプロセス終了前にクリーンアップが不十分になる場合があります。アプリ終了時は `stop()` を呼ぶことを推奨します。 -- `queue.get(timeout=...)` を使うことで即時性よりも CPU 使用量の低減を優先しています。非常に低レイテンシが必要なケースでは timeout を短くしてください(ただし CPU 使用量に注意)。 - -スクリプト連携: -- `mainloop.mapping` と `mainloop.run_mapping` は `scripts/print_mapping.py` などのツールから直接参照されます。mapping のキー/値を変更する場合はそれらのスクリプトも確認してください。 +- `worker_count` を増やすと他のエンドポイントの並列処理性は上がりますが、controller/model 側で共有リソース(GPU メモリやデバイスハンドルなど)への同時アクセスが許可されていない場合は、controller 側で機能単位のロック(例: translation_lock)を追加してください。 +- このドキュメントの変更は `mainloop` の外側から見える挙動(同期応答、ログ、ロックの方針)を説明するものです。controller 内の処理自体は引き続き同期的に実行されます。必要があれば、enable 系の重い処理を非同期化して完了通知をイベントで返す設計(UI 変更が必要)も検討してください。 変更履歴: - 2025-10-09: start/stop ライフサイクル、タイムアウト付きキュー取得、エラー処理強化を追加。 +- 2025-10-13: マルチワーカー化(デフォルト 3)と enable/disable のロック正規化を実装。これにより同一機能の on/off の同時実行を防止し、UI からの高速トグルで最終状態が遅い方に上書きされる問題を修正しました。 diff --git a/src-python/mainloop.py b/src-python/mainloop.py index 644037a2..13efe44c 100644 --- a/src-python/mainloop.py +++ b/src-python/mainloop.py @@ -2,7 +2,7 @@ import sys import json import time from typing import Any, Tuple -from threading import Thread, Event +from threading import Thread, Event, Lock from queue import Queue, Empty import logging from controller import Controller # noqa: E402 @@ -357,14 +357,38 @@ mapping = { init_mapping = {key:value for key, value in mapping.items() if key.startswith("/get/data/")} controller.setInitMapping(init_mapping) +DEFAULT_WORKER_COUNT = 3 # 必要なら増やす + class Main: - def __init__(self, controller_instance: Controller, mapping_data: dict) -> None: - # queue holds tuples of (endpoint, data) + def __init__(self, controller_instance: Controller, mapping_data: dict, worker_count: int = DEFAULT_WORKER_COUNT) -> None: self.queue: Queue[Tuple[str, Any]] = Queue() self._stop_event: Event = Event() self.controller = controller_instance self.mapping = mapping_data self._threads: list[Thread] = [] + self._worker_count = worker_count + + # エンドポイントごとの排他制御用 Lock を作成 + # enable/disable ペアは同じロックキーに正規化する + def _canonical_lock_key(endpoint: str) -> str: + if not isinstance(endpoint, str): + return str(endpoint) + if endpoint.startswith("/set/enable/"): + return "/lock/set/" + endpoint[len("/set/enable/"):] + if endpoint.startswith("/set/disable/"): + return "/lock/set/" + endpoint[len("/set/disable/"):] + return endpoint + + # mapping に含まれるすべてのエンドポイントを走査して正規化キー集合を作る + lock_keys = set() + for key in self.mapping.keys(): + lock_keys.add(_canonical_lock_key(key)) + + # 正規化キーごとに Lock を割り当てる + self._endpoint_locks: dict[str, Lock] = {k: Lock() for k in lock_keys} + + # 正規化関数をインスタンスに保存 + self._canonical_lock_key = _canonical_lock_key def receiver(self) -> None: """Read lines from stdin, parse JSON and enqueue requests. @@ -422,23 +446,51 @@ class Main: return result, status + def _call_handler(self, endpoint: str, data: Any = None) -> tuple: + result = None + status = 500 + handler = self.mapping.get(endpoint) + if handler is None: + response = "Invalid endpoint" + status = 404 + else: + try: + response = handler["variable"](data) + status = response.get("status", 500) + result = response.get("result", None) + time.sleep(0.2) + except Exception: + errorLogging() + result = "Internal error" + status = 500 + return result, status + def handler(self) -> None: - """Main handler loop. Uses queue.get with timeout to avoid busy polling and to allow graceful shutdown.""" while not self._stop_event.is_set(): try: endpoint, data = self.queue.get(timeout=0.5) except Empty: continue - try: - result, status = self.handleRequest(endpoint, data) - except Exception: - errorLogging() - result = "Internal error" - status = 500 + # endpoint をロック用の正規化キーに変換してロックを取得 + lock_key = self._canonical_lock_key(endpoint) + lock = self._endpoint_locks.get(lock_key) + + if lock is not None: + acquired = lock.acquire(blocking=False) + if not acquired: + # 同一機能で既に処理中 -> 少し待って再キュー + time.sleep(0.05) + self.queue.put((endpoint, data)) + continue + try: + result, status = self._call_handler(endpoint, data) + finally: + lock.release() + else: + result, status = self._call_handler(endpoint, data) if status == 423: - # Locked endpoint: requeue with a small delay to avoid tight loop time.sleep(0.1) self.queue.put((endpoint, data)) else: @@ -446,10 +498,11 @@ class Main: printResponse(status, endpoint, result) def startHandler(self) -> None: - th_handler = Thread(target=self.handler, name="main_handler") - th_handler.daemon = True - th_handler.start() - self._threads.append(th_handler) + for i in range(max(1, self._worker_count)): + th_handler = Thread(target=self.handler, name=f"main_handler_{i}") + th_handler.daemon = True + th_handler.start() + self._threads.append(th_handler) def start(self) -> None: """Start receiver and handler threads.""" diff --git a/src-python/models/osc/osc.py b/src-python/models/osc/osc.py index c97c6dfb..99f304be 100644 --- a/src-python/models/osc/osc.py +++ b/src-python/models/osc/osc.py @@ -118,7 +118,17 @@ class OSCHandler: if service is not None: osc_query_client = OSCQueryClient(service) mute_self_node = osc_query_client.query_node(address) - value = mute_self_node.value[0] + # mute_self_node may be None when the node is not present on the + # remote OSCQuery service. Also mute_self_node.value may be None + # or an empty list. Guard against those cases to avoid + # AttributeError: 'NoneType' object has no attribute 'value' + if mute_self_node is None: + return None + # prefer explicit checks rather than relying on exceptions + node_value = getattr(mute_self_node, 'value', None) + if not node_value: + return None + value = node_value[0] except Exception: errorLogging() # エラー発生時にbrowserをリセットして次回再初期化 diff --git a/src-python/models/transcription/transcription_recorder.py b/src-python/models/transcription/transcription_recorder.py index 7214a375..30eb946e 100644 --- a/src-python/models/transcription/transcription_recorder.py +++ b/src-python/models/transcription/transcription_recorder.py @@ -37,23 +37,47 @@ class BaseRecorder: class SelectedMicRecorder(BaseRecorder): def __init__(self, device: dict, energy_threshold: int, dynamic_energy_threshold: bool, record_timeout: int) -> None: - source = Microphone( - device_index=device['index'], - sample_rate=int(device["defaultSampleRate"]), - ) + # Safely construct Microphone source. If device dict is missing expected keys + # or index is out-of-range for the platform, fallback to default device (None) + try: + device_index = int(device.get('index', -1)) + sample_rate = int(device.get("defaultSampleRate", 16000)) + if device_index < 0: + # invalid index -> fallback + raise ValueError("invalid device index") + source = Microphone( + device_index=device_index, + sample_rate=sample_rate, + ) + except Exception: + # Best-effort fallback: use system default microphone + try: + source = Microphone() + except Exception: + raise super().__init__(source=source, energy_threshold=energy_threshold, dynamic_energy_threshold=dynamic_energy_threshold, record_timeout=record_timeout) # self.adjustForNoise() class SelectedSpeakerRecorder(BaseRecorder): def __init__(self, device: dict, energy_threshold: int, dynamic_energy_threshold: bool, record_timeout: int) -> None: - - source = Microphone(speaker=True, - device_index= device["index"], - sample_rate=int(device["defaultSampleRate"]), - chunk_size=get_sample_size(paInt16), - channels=device["maxInputChannels"] - ) + try: + device_index = int(device.get('index', -1)) + sample_rate = int(device.get("defaultSampleRate", 16000)) + channels = int(device.get("maxInputChannels", 1)) + if device_index < 0: + raise ValueError("invalid device index") + source = Microphone(speaker=True, + device_index=device_index, + sample_rate=sample_rate, + chunk_size=get_sample_size(paInt16), + channels=channels + ) + except Exception: + try: + source = Microphone(speaker=True) + except Exception: + raise super().__init__(source=source, energy_threshold=energy_threshold, dynamic_energy_threshold=dynamic_energy_threshold, record_timeout=record_timeout) # self.adjustForNoise() @@ -83,22 +107,42 @@ class BaseEnergyRecorder: class SelectedMicEnergyRecorder(BaseEnergyRecorder): def __init__(self, device: dict) -> None: - source = Microphone( - device_index=device['index'], - sample_rate=int(device["defaultSampleRate"]), - ) + try: + device_index = int(device.get('index', -1)) + sample_rate = int(device.get("defaultSampleRate", 16000)) + if device_index < 0: + raise ValueError("invalid device index") + source = Microphone( + device_index=device_index, + sample_rate=sample_rate, + ) + except Exception: + try: + source = Microphone() + except Exception: + raise super().__init__(source=source) # self.adjustForNoise() class SelectedSpeakerEnergyRecorder(BaseEnergyRecorder): def __init__(self, device: dict) -> None: - - source = Microphone(speaker=True, - device_index= device["index"], - sample_rate=int(device["defaultSampleRate"]), - channels=device["maxInputChannels"] - ) + try: + device_index = int(device.get('index', -1)) + sample_rate = int(device.get("defaultSampleRate", 16000)) + channels = int(device.get("maxInputChannels", 1)) + if device_index < 0: + raise ValueError("invalid device index") + source = Microphone(speaker=True, + device_index=device_index, + sample_rate=sample_rate, + channels=channels + ) + except Exception: + try: + source = Microphone(speaker=True) + except Exception: + raise super().__init__(source=source) # self.adjustForNoise() @@ -156,10 +200,20 @@ class SelectedMicEnergyAndAudioRecorder(BaseEnergyAndAudioRecorder): phrase_timeout: int = 1, record_timeout: int = 5, ) -> None: - source = Microphone( - device_index=device['index'], - sample_rate=int(device["defaultSampleRate"]), - ) + try: + device_index = int(device.get('index', -1)) + sample_rate = int(device.get("defaultSampleRate", 16000)) + if device_index < 0: + raise ValueError("invalid device index") + source = Microphone( + device_index=device_index, + sample_rate=sample_rate, + ) + except Exception: + try: + source = Microphone() + except Exception: + raise super().__init__( source=source, energy_threshold=energy_threshold, @@ -182,12 +236,23 @@ class SelectedSpeakerEnergyAndAudioRecorder(BaseEnergyAndAudioRecorder): record_timeout: int = 5, ) -> None: - source = Microphone(speaker=True, - device_index= device["index"], - sample_rate=int(device["defaultSampleRate"]), - chunk_size=get_sample_size(paInt16), - channels=device["maxInputChannels"], - ) + try: + device_index = int(device.get('index', -1)) + sample_rate = int(device.get("defaultSampleRate", 16000)) + channels = int(device.get("maxInputChannels", 1)) + if device_index < 0: + raise ValueError("invalid device index") + source = Microphone(speaker=True, + device_index=device_index, + sample_rate=sample_rate, + chunk_size=get_sample_size(paInt16), + channels=channels, + ) + except Exception: + try: + source = Microphone(speaker=True) + except Exception: + raise super().__init__( source=source, energy_threshold=energy_threshold,