diff --git a/laguna_devices.py b/laguna_devices.py index 754f558..d413096 100644 --- a/laguna_devices.py +++ b/laguna_devices.py @@ -48,6 +48,42 @@ def list_devices() -> dict: return {"inputs": inputs, "outputs": outputs, "loopbacks": loopbacks} +def reinit_portaudio() -> bool: + """Re-inicializa o PortAudio para que a proxima enumeracao veja hardware novo. + + O PortAudio enumera os devices uma unica vez (no `Pa_Initialize` que o + sounddevice dispara no primeiro uso do processo) e serve a lista de um cache + estatico: sem este ciclo, `list_devices()` nunca ve um fone plugado depois, + um VB-CABLE instalado depois nem um device renomeado (issue #37). + + `_terminate`/`_initialize` sao API PRIVADA do sounddevice: acesso defensivo + (`getattr` + `try/except`). Versao sem esses simbolos, ou ciclo que falha, + degrada para a enumeracao normal (retorna False) — nunca quebra quem chama. + + CUIDADO (responsabilidade de quem chama): `_terminate()` derruba streams + abertos e REATRIBUI os indices dos devices. So chame com nenhum worker vivo. + """ + terminate = getattr(sd, "_terminate", None) + initialize = getattr(sd, "_initialize", None) + if not callable(terminate) or not callable(initialize): + return False + try: + terminate() + except Exception: + return False + try: + initialize() + return True + except Exception: + # PortAudio ficou terminado: uma segunda tentativa e o melhor esforco + # para nao deixar o processo sem enumeracao ate reiniciar o servidor. + try: + initialize() + except Exception: + pass + return False + + def _device_label(d: dict, api: str) -> str: name = d["name"] marks = [] diff --git a/laguna_server.py b/laguna_server.py index ee21d0f..9885f92 100644 --- a/laguna_server.py +++ b/laguna_server.py @@ -147,7 +147,7 @@ def _notify_error(msg: str) -> None: from fastapi.staticfiles import StaticFiles from laguna_core import DirectionConfig, DirectionWorker - from laguna_devices import detect_laguna_devices, list_devices + from laguna_devices import detect_laguna_devices, list_devices, reinit_portaudio from laguna_pipeline import SAMPLE_RATE except BaseException as _import_exc: # Sem isto, `pip install` incompleto = duplo-clique que nao faz nada. @@ -202,9 +202,32 @@ async def _broadcast_async(payload: str) -> None: @app.get("/api/devices") -async def api_devices() -> JSONResponse: +async def api_devices(refresh: int = 0) -> JSONResponse: + """Lista os dispositivos. Com `?refresh=1`, re-detecta hardware novo. + + Sem o parametro a resposta e a barata de sempre (usada no boot da UI): so + enumera o cache do PortAudio. Com `refresh=1` (o botao 🔄) o PortAudio e + re-inicializado antes de enumerar — caro (centenas de ms no WASAPI) e por + isso nao entra em todo carregamento de pagina. + + Guarda: com QUALQUER worker vivo o re-init e pulado. `_terminate()` derruba + os streams abertos e reatribui os indices dos devices, e um worker guarda + `capture_device`/`output_devices` como int — ele passaria a apontar para + outro hardware silenciosamente. Nesse caso a lista volta igual e o campo + `refresh` explica o porque para a UI. + """ + refresh_info = {"requested": bool(refresh), "applied": False, "reason": None} + if refresh: + with _lock: + if _workers: + refresh_info["reason"] = "workers_running" + elif reinit_portaudio(): + refresh_info["applied"] = True + else: + refresh_info["reason"] = "unsupported" data = list_devices() data["laguna"] = detect_laguna_devices() + data["refresh"] = refresh_info return JSONResponse(data) diff --git a/static/app.js b/static/app.js index 5fff710..48993b6 100644 --- a/static/app.js +++ b/static/app.js @@ -1,6 +1,8 @@ // Laguna Translator — frontend const API = { - devices: () => fetch('/api/devices').then(r => { + // refresh=true só no clique do 🔄: pede ao backend re-inicializar o PortAudio + // (caro) para enxergar hardware plugado/renomeado depois do boot. + devices: (refresh = false) => fetch(refresh ? '/api/devices?refresh=1' : '/api/devices').then(r => { if (!r.ok) throw new Error(`/api/devices HTTP ${r.status}`); return r.json(); }), @@ -211,9 +213,9 @@ function selectByPreference(sel, preferredTag) { // Carrega os dispositivos. NUNCA propaga exceção: falha de /api/devices vira // feedback visível no badge (e devolve false) para não abortar o resto do boot. -async function loadDevices() { +async function loadDevices(refresh = false) { try { - state.devices = await API.devices(); + state.devices = await API.devices(refresh); } catch (err) { console.error('[laguna] falha ao carregar dispositivos:', err); state.devicesError = true; @@ -258,11 +260,15 @@ async function refreshDevices() { } if (btn) { btn.disabled = true; btn.classList.add('is-refreshing'); } + setDevicesHint(null); try { - const ok = await loadDevices(); + const ok = await loadDevices(true); // falhou: o badge já sinalizou o erro e os selects seguem como estavam — // nada a re-aplicar. O finally reabilita o botão para nova tentativa. if (!ok) return; + // o backend diz se conseguiu mesmo re-detectar; sem isso o usuário + // interpretaria "lista igual" como hardware dele com problema. + showRefreshOutcome(state.devices.refresh); // re-aplica a seleção capturada por cima da preferência automática, // mas só quando o device continua existindo entre as opções for (const dir of ['falar', 'escutar']) { @@ -297,6 +303,29 @@ async function refreshDevices() { } } +// Aviso inline (aria-live) ao lado do 🔄. `key` null esconde o aviso. +function setDevicesHint(key) { + const el = document.getElementById('devices-hint'); + if (!el) return; + if (!key) { + el.hidden = true; + el.textContent = ''; + el.removeAttribute('data-i18n'); + return; + } + const T = window.LAGUNA_T || ((k) => k); + el.setAttribute('data-i18n', key); // re-traduzido ao trocar de idioma + el.textContent = T(key); + el.hidden = false; +} + +// Traduz o campo `refresh` de /api/devices em feedback para o usuário. +function showRefreshOutcome(info) { + if (!info || !info.requested || info.applied) { setDevicesHint(null); return; } + if (info.reason === 'workers_running') setDevicesHint('devices.refresh_blocked_running'); + else setDevicesHint('devices.refresh_unsupported'); +} + function refreshVolumeLabels(dir) { const panel = document.querySelector(`.panel[data-dir="${dir}"]`); if (!panel) return; diff --git a/static/i18n.js b/static/i18n.js index d581067..d1df1df 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -14,7 +14,9 @@ window.LAGUNA_I18N = { "conn.offline": "offline", "tip.lang_toggle": "Alternar idioma do painel (PT ↔ EN).", "tip.theme_toggle": "Alternar tema claro/escuro (atalho: Shift+T).", - "tip.refresh_devices": "Re-detecta os dispositivos de áudio sem recarregar a página. Use ao conectar um fone ou renomear o CABLE depois de abrir o painel.", + "tip.refresh_devices": "Re-detecta os dispositivos de áudio sem recarregar a página. Use ao conectar um fone ou renomear o CABLE depois de abrir o painel. Com alguma direção rodando, pare-a antes: a re-detecção mexe nos dispositivos em uso.", + "devices.refresh_blocked_running": "⚠ Pare as direções para re-detectar dispositivos (a lista acima não mudou)", + "devices.refresh_unsupported": "⚠ Re-detecção indisponível nesta versão do sounddevice — reinicie o Laguna para ver hardware novo", // FALAR "falar.title": "FALAR", @@ -166,7 +168,9 @@ window.LAGUNA_I18N = { "conn.offline": "offline", "tip.lang_toggle": "Toggle UI language (PT ↔ EN).", "tip.theme_toggle": "Toggle light/dark theme (shortcut: Shift+T).", - "tip.refresh_devices": "Re-detect audio devices without reloading the page. Use it after plugging in headphones or renaming the CABLE once the panel is already open.", + "tip.refresh_devices": "Re-detect audio devices without reloading the page. Use it after plugging in headphones or renaming the CABLE once the panel is already open. Stop any running direction first: re-detection touches the devices in use.", + "devices.refresh_blocked_running": "⚠ Stop the running directions to re-detect devices (the list above did not change)", + "devices.refresh_unsupported": "⚠ Re-detection unavailable in this sounddevice version — restart Laguna to see new hardware", // FALAR "falar.title": "SPEAK", diff --git a/static/index.html b/static/index.html index 4379feb..dd8a07c 100644 --- a/static/index.html +++ b/static/index.html @@ -21,6 +21,7 @@

Laguna Translator

⚡ — 🔌 reconectando… + diff --git a/tests_unit/test_devices_refresh.py b/tests_unit/test_devices_refresh.py new file mode 100644 index 0000000..34aa627 --- /dev/null +++ b/tests_unit/test_devices_refresh.py @@ -0,0 +1,138 @@ +"""Alvo #37 — `/api/devices?refresh=1` re-inicializa o PortAudio (com guarda). + +O PortAudio enumera os devices uma vez por processo: sem re-init, o botao 🔄 +mostra eternamente a mesma lista congelada. O endpoint passa a fazer o ciclo +`_terminate()/_initialize()` — mas NUNCA com worker vivo, porque o ciclo derruba +streams e reatribui os indices que o worker guarda como int. + +Nada aqui toca audio real: o `conftest.py` ja substitui `sounddevice` por um +stub vazio e aqui injetamos um fake mais rico em `laguna_devices.sd` (mesmo +padrao de `test_validate_config.py`). `api_devices` e async e as rotas viram +funcoes normais sob o stub de FastAPI do conftest — chamamos via `asyncio.run`. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +import laguna_devices +import laguna_server + + +class _FakeSd: + """Fake do sounddevice: conta o ciclo de re-init e troca a lista ao aplica-lo.""" + + def __init__(self, devices, devices_after_reinit=None, has_private_api=True): + self._devices = devices + self._after = devices_after_reinit + self.terminate_calls = 0 + self.initialize_calls = 0 + if has_private_api: + self._terminate = self._do_terminate + self._initialize = self._do_initialize + + def _do_terminate(self): + self.terminate_calls += 1 + + def _do_initialize(self): + self.initialize_calls += 1 + # o hardware novo so aparece depois do ciclo completo + if self._after is not None: + self._devices = self._after + + def query_devices(self, idx=None): + return self._devices if idx is None else self._devices[idx] + + def query_hostapis(self): + return [{"name": "Windows WASAPI"}] + + +_MIC = {"name": "Mic", "hostapi": 0, "max_input_channels": 2, "max_output_channels": 0} +_SPK = {"name": "Speakers", "hostapi": 0, "max_input_channels": 0, "max_output_channels": 2} +# device que so existe depois do re-init (ex.: CABLE Output renomeado) +_NEW = {"name": "Laguna Translator Mic", "hostapi": 0, "max_input_channels": 2, "max_output_channels": 0} + + +def _call_devices(refresh): + return asyncio.run(laguna_server.api_devices(refresh=refresh)).content + + +@pytest.fixture +def no_workers(): + """Garante estado limpo de `_workers` (global do modulo) antes e depois.""" + saved = dict(laguna_server._workers) + laguna_server._workers.clear() + yield laguna_server._workers + laguna_server._workers.clear() + laguna_server._workers.update(saved) + + +def _install(monkeypatch, fake): + monkeypatch.setattr(laguna_devices, "sd", fake) + return fake + + +def test_refresh_sem_workers_reinicializa_e_ve_device_novo(monkeypatch, no_workers): + fake = _install(monkeypatch, _FakeSd([_MIC, _SPK], devices_after_reinit=[_MIC, _SPK, _NEW])) + + data = _call_devices(1) + + assert (fake.terminate_calls, fake.initialize_calls) == (1, 1) + assert data["refresh"] == {"requested": True, "applied": True, "reason": None} + assert [d["name"] for d in data["inputs"]] == ["Mic", "Laguna Translator Mic"] + # o badge 🌊 depende dessa deteccao — e o motivo pratico do refresh + assert data["laguna"]["has_laguna_name"] is True + + +def test_sem_parametro_nao_reinicializa(monkeypatch, no_workers): + fake = _install(monkeypatch, _FakeSd([_MIC, _SPK], devices_after_reinit=[_MIC, _SPK, _NEW])) + + data = _call_devices(0) + + # boot da UI continua barato: nada de Pa_Terminate/Pa_Initialize + assert (fake.terminate_calls, fake.initialize_calls) == (0, 0) + assert data["refresh"] == {"requested": False, "applied": False, "reason": None} + assert [d["name"] for d in data["inputs"]] == ["Mic"] + + +def test_refresh_com_worker_rodando_nao_reinicializa(monkeypatch, no_workers): + fake = _install(monkeypatch, _FakeSd([_MIC, _SPK], devices_after_reinit=[_MIC, _SPK, _NEW])) + no_workers["falar"] = object() # worker vivo: indices dele nao podem mudar + + data = _call_devices(1) + + assert (fake.terminate_calls, fake.initialize_calls) == (0, 0) + assert data["refresh"] == {"requested": True, "applied": False, "reason": "workers_running"} + # lista intacta -> a UI explica o porque em vez de mentir que atualizou + assert [d["name"] for d in data["inputs"]] == ["Mic"] + + +def test_sounddevice_sem_api_privada_degrada_sem_excecao(monkeypatch, no_workers): + fake = _install(monkeypatch, _FakeSd([_MIC, _SPK], has_private_api=False)) + + data = _call_devices(1) + + assert data["refresh"] == {"requested": True, "applied": False, "reason": "unsupported"} + assert [d["name"] for d in data["inputs"]] == ["Mic"] + + +def test_falha_no_ciclo_de_reinit_nao_quebra_o_endpoint(monkeypatch, no_workers): + fake = _install(monkeypatch, _FakeSd([_MIC, _SPK])) + monkeypatch.setattr(fake, "_initialize", _raise, raising=False) + + data = _call_devices(1) + + assert data["refresh"] == {"requested": True, "applied": False, "reason": "unsupported"} + assert [d["name"] for d in data["inputs"]] == ["Mic"] + + +def _raise(): + raise RuntimeError("PortAudio nao inicializou") + + +def test_reinit_portaudio_isolado_reporta_sucesso(monkeypatch): + fake = _install(monkeypatch, _FakeSd([_MIC])) + assert laguna_devices.reinit_portaudio() is True + assert (fake.terminate_calls, fake.initialize_calls) == (1, 1)