mainloopのスレッド管理を改善し、マルチワーカー化を実装。デバイス管理の初期化を遅延させ、エラーハンドリングを強化。ドキュメントを更新し、設定の変更点を明示化。

This commit is contained in:
misyaguziya
2025-10-13 08:28:27 +09:00
parent 0130792682
commit 914789c9cb
10 changed files with 328 additions and 86 deletions

View File

@@ -35,12 +35,13 @@ class Color:
class TestMainloop(): class TestMainloop():
def __init__(self): def __init__(self):
self.main = main_instance self.main = main_instance
self.main.startReceiver() # Start mainloop threads
self.main.startHandler() self.main.start()
def stop_main(): # Ensure the watchdog can stop the mainloop cleanly
pass def _none_watchdog():
self.main.controller.setWatchdogCallback(stop_main) return None
self.main.controller.setWatchdogCallback(_none_watchdog)
self.main.controller.init() self.main.controller.init()
# mappingのすべてのstatusをTrueにする # mappingのすべてのstatusをTrueにする
@@ -148,15 +149,16 @@ class TestMainloop():
def test_endpoints_on_off_continuous(self): def test_endpoints_on_off_continuous(self):
print("----ON/OFF連続テスト----") print("----ON/OFF連続テスト----")
endpoints = ["/set/enable/websocket_server", "/set/disable/websocket_server"] endpoints = [
# endpoints = [ "/set/enable/translation",
# "/set/enable/translation", "/set/disable/translation",
# "/set/disable/translation", "/set/enable/transcription_send",
# "/set/enable/transcription_send", "/set/disable/transcription_send",
# "/set/disable/transcription_send", "/set/enable/transcription_receive",
# "/set/enable/transcription_receive", "/set/disable/transcription_receive",
# "/set/disable/transcription_receive", # "/set/enable/websocket_server",
# ] # "/set/disable/websocket_server",
]
for i in range(1000): for i in range(1000):
endpoint = random.choice(endpoints) endpoint = random.choice(endpoints)
print(f"No.{i:04} Testing endpoint: {endpoint}", flush=True) 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_set_data_endpoints_all()
# test.test_run_endpoints_all() # test.test_run_endpoints_all()
# test.test_delete_data_endpoints_all() # test.test_delete_data_endpoints_all()
test.test_endpoints_all_random() # test.test_endpoints_all_random()
# test.test_endpoints_on_off_continuous() test.test_endpoints_on_off_continuous()
# test.test_endpoints_on_off_random() # test.test_endpoints_on_off_random()
# test.test_endpoints_specific_random() # test.test_endpoints_specific_random()
# test.test_translate_all_language_pairs() # test.test_translate_all_language_pairs()
test.generate_summary() test.generate_summary()
except KeyboardInterrupt: except KeyboardInterrupt:
print("Interrupted by user, shutting down...") print("Interrupted by user, shutting down...")
try:
main_instance.stop()
except Exception:
pass
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
print(f"An error occurred: {e}") print(f"An error occurred: {e}")
try:
main_instance.stop()
except Exception:
pass
finally:
try:
main_instance.stop()
except Exception:
pass

View File

@@ -1220,8 +1220,10 @@ class Config:
# device_manager may be unavailable or not initialized; use safe defaults # device_manager may be unavailable or not initialized; use safe defaults
try: try:
if device_manager is not None: if device_manager is not None:
self._SELECTED_MIC_HOST = device_manager.getDefaultMicDevice()["host"]["name"] # getDefaultMicDevice performs lazy init/update if needed
self._SELECTED_MIC_DEVICE = device_manager.getDefaultMicDevice()["device"]["name"] 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: else:
self._SELECTED_MIC_HOST = "NoHost" self._SELECTED_MIC_HOST = "NoHost"
self._SELECTED_MIC_DEVICE = "NoDevice" self._SELECTED_MIC_DEVICE = "NoDevice"
@@ -1247,7 +1249,8 @@ class Config:
self._AUTO_SPEAKER_SELECT = True self._AUTO_SPEAKER_SELECT = True
try: try:
if device_manager is not None: 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: else:
self._SELECTED_SPEAKER_DEVICE = "NoDevice" self._SELECTED_SPEAKER_DEVICE = "NoDevice"
except Exception: except Exception:

View File

@@ -60,7 +60,20 @@ class DeviceManager:
if cls._instance is None: if cls._instance is None:
cls._instance = super(DeviceManager, cls).__new__(cls) cls._instance = super(DeviceManager, cls).__new__(cls)
# do NOT auto-init monitoring-heavy resources on import; require explicit init # 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 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 return cls._instance
def init(self) -> None: def init(self) -> None:
@@ -108,6 +121,22 @@ class DeviceManager:
self._initialized = True 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): def update(self):
buffer_mic_devices: Dict[str, List[Dict[str, Any]]] = {} 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_default_mic_device: Dict[str, Any] = {"host": {"index": -1, "name": "NoHost"}, "device": {"index": -1, "name": "NoDevice"}}
@@ -428,16 +457,52 @@ class DeviceManager:
errorLogging() errorLogging()
def getMicDevices(self): 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): 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): 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): 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): def forceUpdateAndSetMicDevices(self):
self.update() self.update()

View File

@@ -172,6 +172,15 @@ config.saveConfig('CUSTOM_SAVE', {'foo': 'bar'}, immediate_save=True)
- `saveConfig()` はデバウンスされるため、高頻度の設定変更では複数の変更がまとめて書き込まれます。即時書き込みが必要な操作(重要な鍵の更新など)は `immediate_save=True` を使ってください。 - `saveConfig()` はデバウンスされるため、高頻度の設定変更では複数の変更がまとめて書き込まれます。即時書き込みが必要な操作(重要な鍵の更新など)は `immediate_save=True` を使ってください。
- `SELECTABLE_*` 系や `*_DICT` 系は初期化時に外部モジュール翻訳リソース、whisper_models、device_manager 等)から生成されます。これらが利用できない環境ではデフォルトが空になる可能性があります。 - `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 時の検証を明確化すると安全性が向上します。 - 設定スキーマを JSON Schema で定義し、load 時の検証を明確化すると安全性が向上します。
- 設定変更イベントを発火する仕組みobserver パターンを導入すると、Controller/Model 側の再初期化処理をより明確に実装できます。 - 設定変更イベントを発火する仕組みobserver パターンを導入すると、Controller/Model 側の再初期化処理をより明確に実装できます。

View File

@@ -5,6 +5,10 @@
- UI からのコマンドを受け取り、`model` の開始/停止、設定の変更、ダウンロードの開始、各種フラグの切り替え、進捗通知(`run` コールバック経由)を行います。 - UI からのコマンドを受け取り、`model` の開始/停止、設定の変更、ダウンロードの開始、各種フラグの切り替え、進捗通知(`run` コールバック経由)を行います。
- 多くのメソッドは JSON 系の応答オブジェクトを返します: {"status": int, "result": Any}。副作用で `self.run(status, run_mapping[key], payload)` を呼び出して UI に通知します。 - 多くのメソッドは JSON 系の応答オブジェクトを返します: {"status": int, "result": Any}。副作用で `self.run(status, run_mapping[key], payload)` を呼び出して UI に通知します。
### mainloop のマルチワーカー化とカノニカルロックについて (2025-10-13)
- `mainloop.Main` はデフォルトで複数(デフォルト 3のハンドラワーカースレッドを動かすようになりました。これにより、モデルロードなどの重い操作で他のリクエストが待たされることが少なくなります。
- `/set/enable/<feature>``/set/disable/<feature>` のように同一機能の ON/OFF を切り替えるエンドポイントは、内部的にカノニカルロックキー(例: `/lock/set/<feature>`)に正規化してロック取得されます。これにより、遅い disable の処理が後から来て最終状態を書き換えてしまうレースが防がれます。
初期化とランタイムフック 初期化とランタイムフック
- __init__() -> None - __init__() -> None
- フィールド: `init_mapping: dict`, `run_mapping: dict`, `run: Callable`, `device_access_status: bool` - フィールド: `init_mapping: dict`, `run_mapping: dict`, `run: Callable`, `device_access_status: bool`

View File

@@ -45,6 +45,17 @@ device_manager.forceUpdateAndSetMicDevices()
- Windows 固有のモジュールPyAudio paWASAPI, pycawに依存します。クロスプラットフォーム対応が必要な場合は別実装が必要です。 - Windows 固有のモジュールPyAudio paWASAPI, pycawに依存します。クロスプラットフォーム対応が必要な場合は別実装が必要です。
- 監視スレッドは永続的に動作するため、アプリケーション終了時は `stopMonitoring()` を呼んで安全に停止してください。 - 監視スレッドは永続的に動作するため、アプリケーション終了時は `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 等に依存。 目的: ローカルの入力マイクと出力ループバックから抽出されたスピーカーデバイスを列挙し、変更を監視してコールバックで通知する。Windows の WASAPI 等に依存。

View File

@@ -1,22 +1,28 @@
## mainloop モジュールsrc-python/mainloop.py ## 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()` を追加し、受信スレッドとハンドラスレッドのライフサイクル管理を明示化しました。 - 2025-10-09: `Main` クラスに `start()` / `stop()` を追加し、受信スレッドとハンドラスレッドのライフサイクル管理を明示化しました。`queue.get(timeout=...)` による安全なシャットダウンを可能にしています。
- `queue.get(timeout=...)` を使ってポーリング負荷を下げ、`_stop_event` による安全なシャットダウンを可能にしました。 - 2025-10-13: ハンドラの振る舞いを改善しました(マルチワーカー化とロック正規化):
- 標準入力の JSON パースエラーと一般例外のハンドリングを強化しました - マルチワーカー化: ハンドラ処理はデフォルトで複数ワーカー(例: 3 本で並列実行されます。これにより、1 つの重い処理が他のすべてのリクエストをブロックしてしまう問題を緩和します
- `startReceiver()` / `startHandler()` を使って個別にスレッドを起動することも可能です。 - ロック正規化: `/set/enable/<feature>` `/set/disable/<feature>` のような on/off ペアは同一のロックキーに正規化され、同一機能の on と off が同時に別スレッドで実行されることを防ぎます。これにより、遅い方の処理結果が後から上書きして最終状態が意図しないものになる不具合を防止します。
クラス: Main クラス: Main
- __init__(controller_instance: Controller, mapping_data: dict) -> None - __init__(controller_instance: Controller, mapping_data: dict, worker_count: int = 3) -> None
- `controller_instance`: `Controller` のインスタンス。 - `controller_instance`: `Controller` のインスタンス。
- `mapping_data`: `mainloop` 内で使用する `mapping`(エンドポイント -> ハンドラ情報)辞書。 - `mapping_data`: `mainloop` 内で使用する `mapping`(エンドポイント -> ハンドラ情報)辞書。
- `worker_count`: ハンドラワーカー数(デフォルト 3。実行環境に応じて調整可能です。
- start() -> None - start() -> None
- 内部で `startReceiver()``startHandler()` を呼び、スレッドを起動します。 - 内部で `startReceiver()``startHandler()` を呼び、受信とハンドラのスレッドを起動します。
- stop(wait: float = 2.0) -> None - stop(wait: float = 2.0) -> None
- シャットダウンシグナルをセットし、スレッド終了を待ちます(デフォルト 2 秒)。 - シャットダウンシグナルをセットし、スレッド終了を待ちます(デフォルト 2 秒)。
動作の重要ポイント
- キュー運用: 受信した JSON は内部キューに入れられ、ハンドラワーカーが順次取り出して処理します。`queue.get(timeout=...)` を使っているため CPU 負荷を抑えつつ安全に停止できます。
- 同期応答設計: 各エンドポイントは基本的に呼び出し元に同期的に結果を返します(`handler` が戻り値としてステータスと結果を返す)。今回の変更でもこの設計は維持されています。
- 同一機能直列化: `/set/enable/X``/set/disable/X` のような on/off ペアは内部で同一の "ロックキー" に正規化され、同時に両方が実行されることを防ぎます。これにより、enable と disable が競合して遅い方が勝つ問題が解消されます。
使い方(例): 使い方(例):
```python ```python
@@ -29,15 +35,16 @@ main_instance.start()
main_instance.stop() 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()` を呼ぶことを推奨します - `worker_count` を増やすと他のエンドポイントの並列処理性は上がりますが、controller/model 側で共有リソースGPU メモリやデバイスハンドルなどへの同時アクセスが許可されていない場合は、controller 側で機能単位のロック(例: translation_lockを追加してください
- `queue.get(timeout=...)` を使うことで即時性よりも CPU 使用量の低減を優先しています。非常に低レイテンシが必要なケースでは timeout を短くしてください(ただし CPU 使用量に注意) - このドキュメントの変更は `mainloop` の外側から見える挙動同期応答、ログ、ロックの方針を説明するものです。controller 内の処理自体は引き続き同期的に実行されます。必要があれば、enable 系の重い処理を非同期化して完了通知をイベントで返す設計UI 変更が必要)も検討してください
スクリプト連携:
- `mainloop.mapping``mainloop.run_mapping``scripts/print_mapping.py` などのツールから直接参照されます。mapping のキー/値を変更する場合はそれらのスクリプトも確認してください。
変更履歴: 変更履歴:
- 2025-10-09: start/stop ライフサイクル、タイムアウト付きキュー取得、エラー処理強化を追加。 - 2025-10-09: start/stop ライフサイクル、タイムアウト付きキュー取得、エラー処理強化を追加。
- 2025-10-13: マルチワーカー化(デフォルト 3と enable/disable のロック正規化を実装。これにより同一機能の on/off の同時実行を防止し、UI からの高速トグルで最終状態が遅い方に上書きされる問題を修正しました。

View File

@@ -2,7 +2,7 @@ import sys
import json import json
import time import time
from typing import Any, Tuple from typing import Any, Tuple
from threading import Thread, Event from threading import Thread, Event, Lock
from queue import Queue, Empty from queue import Queue, Empty
import logging import logging
from controller import Controller # noqa: E402 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/")} init_mapping = {key:value for key, value in mapping.items() if key.startswith("/get/data/")}
controller.setInitMapping(init_mapping) controller.setInitMapping(init_mapping)
DEFAULT_WORKER_COUNT = 3 # 必要なら増やす
class Main: class Main:
def __init__(self, controller_instance: Controller, mapping_data: dict) -> None: def __init__(self, controller_instance: Controller, mapping_data: dict, worker_count: int = DEFAULT_WORKER_COUNT) -> None:
# queue holds tuples of (endpoint, data)
self.queue: Queue[Tuple[str, Any]] = Queue() self.queue: Queue[Tuple[str, Any]] = Queue()
self._stop_event: Event = Event() self._stop_event: Event = Event()
self.controller = controller_instance self.controller = controller_instance
self.mapping = mapping_data self.mapping = mapping_data
self._threads: list[Thread] = [] 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: def receiver(self) -> None:
"""Read lines from stdin, parse JSON and enqueue requests. """Read lines from stdin, parse JSON and enqueue requests.
@@ -422,23 +446,51 @@ class Main:
return result, status 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: 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(): while not self._stop_event.is_set():
try: try:
endpoint, data = self.queue.get(timeout=0.5) endpoint, data = self.queue.get(timeout=0.5)
except Empty: except Empty:
continue continue
try: # endpoint をロック用の正規化キーに変換してロックを取得
result, status = self.handleRequest(endpoint, data) lock_key = self._canonical_lock_key(endpoint)
except Exception: lock = self._endpoint_locks.get(lock_key)
errorLogging()
result = "Internal error" if lock is not None:
status = 500 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: if status == 423:
# Locked endpoint: requeue with a small delay to avoid tight loop
time.sleep(0.1) time.sleep(0.1)
self.queue.put((endpoint, data)) self.queue.put((endpoint, data))
else: else:
@@ -446,10 +498,11 @@ class Main:
printResponse(status, endpoint, result) printResponse(status, endpoint, result)
def startHandler(self) -> None: def startHandler(self) -> None:
th_handler = Thread(target=self.handler, name="main_handler") for i in range(max(1, self._worker_count)):
th_handler.daemon = True th_handler = Thread(target=self.handler, name=f"main_handler_{i}")
th_handler.start() th_handler.daemon = True
self._threads.append(th_handler) th_handler.start()
self._threads.append(th_handler)
def start(self) -> None: def start(self) -> None:
"""Start receiver and handler threads.""" """Start receiver and handler threads."""

View File

@@ -118,7 +118,17 @@ class OSCHandler:
if service is not None: if service is not None:
osc_query_client = OSCQueryClient(service) osc_query_client = OSCQueryClient(service)
mute_self_node = osc_query_client.query_node(address) 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: except Exception:
errorLogging() errorLogging()
# エラー発生時にbrowserをリセットして次回再初期化 # エラー発生時にbrowserをリセットして次回再初期化

View File

@@ -37,23 +37,47 @@ class BaseRecorder:
class SelectedMicRecorder(BaseRecorder): class SelectedMicRecorder(BaseRecorder):
def __init__(self, device: dict, energy_threshold: int, dynamic_energy_threshold: bool, record_timeout: int) -> None: def __init__(self, device: dict, energy_threshold: int, dynamic_energy_threshold: bool, record_timeout: int) -> None:
source = Microphone( # Safely construct Microphone source. If device dict is missing expected keys
device_index=device['index'], # or index is out-of-range for the platform, fallback to default device (None)
sample_rate=int(device["defaultSampleRate"]), 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) super().__init__(source=source, energy_threshold=energy_threshold, dynamic_energy_threshold=dynamic_energy_threshold, record_timeout=record_timeout)
# self.adjustForNoise() # self.adjustForNoise()
class SelectedSpeakerRecorder(BaseRecorder): class SelectedSpeakerRecorder(BaseRecorder):
def __init__(self, device: dict, energy_threshold: int, dynamic_energy_threshold: bool, record_timeout: int) -> None: def __init__(self, device: dict, energy_threshold: int, dynamic_energy_threshold: bool, record_timeout: int) -> None:
try:
source = Microphone(speaker=True, device_index = int(device.get('index', -1))
device_index= device["index"], sample_rate = int(device.get("defaultSampleRate", 16000))
sample_rate=int(device["defaultSampleRate"]), channels = int(device.get("maxInputChannels", 1))
chunk_size=get_sample_size(paInt16), if device_index < 0:
channels=device["maxInputChannels"] 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) super().__init__(source=source, energy_threshold=energy_threshold, dynamic_energy_threshold=dynamic_energy_threshold, record_timeout=record_timeout)
# self.adjustForNoise() # self.adjustForNoise()
@@ -83,22 +107,42 @@ class BaseEnergyRecorder:
class SelectedMicEnergyRecorder(BaseEnergyRecorder): class SelectedMicEnergyRecorder(BaseEnergyRecorder):
def __init__(self, device: dict) -> None: def __init__(self, device: dict) -> None:
source = Microphone( try:
device_index=device['index'], device_index = int(device.get('index', -1))
sample_rate=int(device["defaultSampleRate"]), 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) super().__init__(source=source)
# self.adjustForNoise() # self.adjustForNoise()
class SelectedSpeakerEnergyRecorder(BaseEnergyRecorder): class SelectedSpeakerEnergyRecorder(BaseEnergyRecorder):
def __init__(self, device: dict) -> None: def __init__(self, device: dict) -> None:
try:
source = Microphone(speaker=True, device_index = int(device.get('index', -1))
device_index= device["index"], sample_rate = int(device.get("defaultSampleRate", 16000))
sample_rate=int(device["defaultSampleRate"]), channels = int(device.get("maxInputChannels", 1))
channels=device["maxInputChannels"] 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) super().__init__(source=source)
# self.adjustForNoise() # self.adjustForNoise()
@@ -156,10 +200,20 @@ class SelectedMicEnergyAndAudioRecorder(BaseEnergyAndAudioRecorder):
phrase_timeout: int = 1, phrase_timeout: int = 1,
record_timeout: int = 5, record_timeout: int = 5,
) -> None: ) -> None:
source = Microphone( try:
device_index=device['index'], device_index = int(device.get('index', -1))
sample_rate=int(device["defaultSampleRate"]), 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__( super().__init__(
source=source, source=source,
energy_threshold=energy_threshold, energy_threshold=energy_threshold,
@@ -182,12 +236,23 @@ class SelectedSpeakerEnergyAndAudioRecorder(BaseEnergyAndAudioRecorder):
record_timeout: int = 5, record_timeout: int = 5,
) -> None: ) -> None:
source = Microphone(speaker=True, try:
device_index= device["index"], device_index = int(device.get('index', -1))
sample_rate=int(device["defaultSampleRate"]), sample_rate = int(device.get("defaultSampleRate", 16000))
chunk_size=get_sample_size(paInt16), channels = int(device.get("maxInputChannels", 1))
channels=device["maxInputChannels"], 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__( super().__init__(
source=source, source=source,
energy_threshold=energy_threshold, energy_threshold=energy_threshold,