Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions laguna_devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
27 changes: 25 additions & 2 deletions laguna_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)


Expand Down
37 changes: 33 additions & 4 deletions static/app.js
Original file line number Diff line number Diff line change
@@ -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();
}),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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']) {
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions static/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ <h1>Laguna Translator</h1>
<span id="gpu-badge" class="badge neutral" data-tip-i18n="tip.gpu_badge">⚡ —</span>
<span id="conn-badge" class="badge neutral" data-tip-i18n="tip.conn_badge">🔌 <span data-role="conn-label" data-i18n="conn.reconnecting">reconectando…</span></span>
<button id="refresh-devices" class="icon-btn" data-tip-i18n="tip.refresh_devices" data-aria-i18n="aria.refresh_devices" aria-label="Atualizar dispositivos">🔄</button>
<span id="devices-hint" class="badge warn" aria-live="polite" hidden></span>
<button id="lang-toggle" class="icon-btn" data-tip-i18n="tip.lang_toggle" data-aria-i18n="aria.lang_toggle" aria-label="Alternar idioma">🌐</button>
<button id="theme-toggle" class="icon-btn" data-tip-i18n="tip.theme_toggle" data-aria-i18n="aria.theme_toggle" aria-label="Alternar tema">🌙</button>
</div>
Expand Down
138 changes: 138 additions & 0 deletions tests_unit/test_devices_refresh.py
Original file line number Diff line number Diff line change
@@ -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)
Loading