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 @@