Skip to content

feat(devices): 🔄 re-inicializa o PortAudio e passa a ver hardware novo (/api/devices?refresh=1) - #43

Draft
caioross wants to merge 2 commits into
mainfrom
auto/issue-37-devices-refresh
Draft

feat(devices): 🔄 re-inicializa o PortAudio e passa a ver hardware novo (/api/devices?refresh=1)#43
caioross wants to merge 2 commits into
mainfrom
auto/issue-37-devices-refresh

Conversation

@caioross

Copy link
Copy Markdown
Owner

Contexto

O botão 🔄 prometia no tooltip re-detectar dispositivos "sem recarregar a página", mas o backend só reenumerava o cache estático do PortAudio — que é montado uma vez por processo, no Pa_Initialize. Fone plugado depois, VB-CABLE instalado depois ou "CABLE Output" renomeado para Laguna Translator Mic (o passo 5 do próprio passo a passo de setup) nunca apareciam; a única saída era reiniciar o servidor. O usuário concluía que o hardware dele é que estava errado.

O que mudou e por quê

  • laguna_devices.reinit_portaudio() — ciclo _terminate()/_initialize(). São API privada do sounddevice, então o acesso é defensivo (getattr + try/except): versão sem os símbolos, ou ciclo que falha, retorna False e o chamador segue com a enumeração normal. Se initialize() falhar, há uma segunda tentativa (melhor esforço) para não deixar o processo sem enumeração. Nunca levanta.
  • GET /api/devices?refresh=1 — só o clique no 🔄 pede o re-init; o boot da UI continua na versão barata (o ciclo custa centenas de ms no WASAPI e não faz sentido em todo carregamento de página).
  • Guarda obrigatória: com qualquer worker vivo o re-init é pulado — _terminate() derruba streams abertos e reatribui os índices dos devices, e o worker guarda capture_device/output_devices como int; ele passaria a apontar para outro hardware silenciosamente. A leitura de _workers usa o _lock já existente, como as demais rotas.
  • Contrato: a resposta ganha refresh: {requested, applied, reason} (reasonnull | "workers_running" | "unsupported"). Campo aditivo — cliente antigo ignora.
  • UI: aviso inline #devices-hint (aria-live="polite", reusa .badge warn, sem CSS novo) quando o refresh não pôde ser aplicado: "pare as direções" ou "reinicie o Laguna". Sem isso, "lista igual" continuaria sendo lida como defeito do hardware. Duas chaves i18n novas em PT e EN + tooltip do 🔄 atualizado (a promessa do tooltip agora se sustenta, inclusive na ressalva sobre direções rodando).

Gate (resultado real)

  • T1compileall -q .COMPILE_OK; import-smoke fase0_poc, laguna_core, laguna_serverIMPORTS_OK (Python C:\Python313\python.exe). ✅
  • pytest tests_unit/44 passed (38 anteriores + 6 novos em tests_unit/test_devices_refresh.py): sem worker → re-inicializa e vê o device novo (incluindo has_laguna_name para o badge 🌊); com worker registrado → não re-inicializa e a lista fica intacta; sd sem _terminateunsupported, sem exceção; _initialize que explode → unsupported, sem exceção; sem ?refresh → zero _terminate/_initialize (boot barato). ✅
  • T3node --check static/app.js + static/i18n.jsJS_OK; paridade PT/EN pelo teste versionado tests_unit/test_i18n_parity.py (dentro dos 44). ✅
  • T2 — não se aplica: o diff não toca laguna_core.py, fase0_poc.py, laguna_pipeline.py, VAD, defaults de modelo nem o caminho quente de áudio (o endpoint só é chamado fora do runtime de tradução).
  • Smoke real na máquina do dono (fora do gate, sem hardware novo para plugar): sounddevice 0.5.5 expõe os dois símbolos, reinit_portaudio()True e list_devices() depois do ciclo devolve a enumeração completa e coerente (36 inputs / 41 outputs, rótulos intactos) — ou seja, o ciclo não deixa o PortAudio num estado quebrado.

Riscos e limites

  • Não verificado ponta a ponta com hardware novo real (exige plugar/renomear um device fisicamente durante a sessão). O mecanismo está coberto por teste com stub e pelo smoke acima; o comportamento com hardware novo depende do PortAudio reenumerar no Pa_Initialize, que é o contrato documentado dele.
  • _terminate/_initialize são privados: podem sumir numa versão futura do sounddevice — daí o acesso defensivo e o caminho unsupported, que degrada em vez de quebrar /api/devices (a ui: falha do /api/devices no boot deixa a UI morta e muda (WebSocket nunca conecta, nenhum erro visível) #33 mostrou que esse endpoint quebrado mata o boot da UI).
  • Janela teórica: um POST /api/start que chegue exatamente durante o ciclo espera o _lock; ele valida os devices antes de pegar o lock, então uma config validada milissegundos antes poderia usar índice reatribuído. Fora isso os índices só mudam com nenhum worker vivo.
  • Promessa 100% local intacta: nenhuma chamada de rede, nenhuma dependência nova.

Solicito quórum (HANDBOOK §7)

Closes #37

#37)

O PortAudio enumera os devices uma unica vez por processo (no Pa_Initialize que
o sounddevice dispara no primeiro uso) e serve a lista de um cache estatico:
`sd.query_devices()` de novo devolve exatamente a mesma lista. Por isso o botao
🔄 girava o spinner, repopulava os selects com os mesmos dados e nunca mostrava
o fone plugado depois, o VB-CABLE instalado depois nem o CABLE renomeado para
"Laguna" — justamente o passo 5 do proprio passo a passo de setup.

- `laguna_devices.reinit_portaudio()`: ciclo `_terminate()/_initialize()` com
  acesso defensivo (API privada do sounddevice) — versao sem os simbolos, ou
  ciclo que falha, degrada para a enumeracao normal em vez de quebrar.
- `GET /api/devices?refresh=1`: so o clique no 🔄 pede o re-init (caro no
  WASAPI); o boot da UI continua na versao barata. Guarda obrigatoria: com
  qualquer worker vivo o re-init e PULADO — `_terminate()` derruba streams e
  reatribui os indices que o worker guarda como int. O campo `refresh`
  ({requested, applied, reason}) explica o resultado para a UI.
- UI: aviso inline (aria-live, PT+EN) quando o refresh nao pode ser aplicado —
  "pare as direcoes" ou "reinicie o Laguna" — em vez de deixar o usuario
  concluir que o hardware dele e que esta errado. Tooltip do 🔄 atualizado.
- `tests_unit/test_devices_refresh.py`: sem worker re-inicializa e ve o device
  novo; com worker registrado nao re-inicializa; sem `_terminate` degrada sem
  excecao; sem `?refresh` nao paga o ciclo.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@caioross
caioross marked this pull request as draft July 26, 2026 19:18
@caioross caioross added the decisao-dono Espera decisão do @caioross — agentes não resolvem/mergeiam label Jul 26, 2026
@caioross

Copy link
Copy Markdown
Owner Author

Parecer do PR Doctor — quórum adversarial (HANDBOOK §7.2)

Head analisado: 7a6332c. Pré-requisitos atendidos: CI verde (2/2), MERGEABLE/CLEAN, diff lido inteiro, gate T1 local verde (COMPILE_OK + IMPORTS_OK), pytest tests_unit/ → 44 passed, T3 verde (node --check + paridade i18n).

Veredito das 3 lentes: VETA / VETA / VETA. Não mergeio. A PR vai para DRAFT + decisao-dono, porque a correção não é reparo — depende de três decisões de contrato que não cabem a um agente.

Registro que o gate e o CI passam: nenhum destes defeitos é capturado pelo gate atual. Os quatro vetores abaixo eu confirmei pessoalmente no código/no fonte da lib, além do parecer das lentes.

🔴 Bloqueador 1 — /api/devices vira 500 permanente (regressão da classe #33)

laguna_devices.py:78-84 + laguna_server.py:99. Se terminate() passa e as duas tentativas de initialize() falham, o PortAudio fica terminado e reinit_portaudio() apenas retorna False. A linha seguinte chama list_devices() sem proteção.

Confirmado no fonte do sounddevice 0.5.5: query_devices() sem argumento faz DeviceList(query_devices(i) for i in range(_check(_lib.Pa_GetDeviceCount())))não re-inicializa lazy. Com o PortAudio terminado isso levanta PortAudioError: PortAudio not initialized [-10000].

Consequência: todo /api/devices passa a 500 — inclusive o refresh=0 do boot da UI — até reiniciar o processo. É exatamente o modo de falha que o corpo da PR cita como motivo do acesso defensivo. Pior: validate_direction_config engole a exceção (laguna_server.py:129-132[]), então POST /api/start passa na validação e o worker sobe para morrer no stream, sem nada na UI explicando.

🔴 Bloqueador 2 — a guarda _workers não sustenta a invariante que a PR precisa

A guarda protege o núcleo da feature ("nunca re-inicializar com worker vivo"), mas _workers não é fonte de verdade de vida de worker, nos dois sentidos:

  • Worker morto continua no dict. laguna_core.py não tem referência a _workers nem a laguna_server (só em comentários) — um worker que perde a captura após CAPTURE_MAX_RETRIES (laguna_core.py:460-467) simplesmente dá return e nunca se remove. A partir daí ?refresh=1 responde workers_running para sempre. Ou seja: o cenário que motiva a audio: botao 🔄 nunca ve hardware novo — /api/devices le a lista congelada do PortAudio (sem re-init) #37 (device sumiu → worker morreu → usuário pluga outro → clica 🔄) é justamente aquele em que o botão nunca mais funciona.
  • Worker vivo já saiu do dict. api_stop (laguna_server.py:286-289) faz o pop dentro do _lock e chama w.stop() fora dele; e stop() é best-effort (join(timeout=2), laguna_core.py:175-178). Sequência trivial de usuário — clicar Stop e em seguida 🔄 — cai numa janela com _workers vazio e streams ainda vivos: Pa_Terminate() com stream aberto, exatamente o que o docstring de reinit_portaudio proíbe.

🟡 Vetor 3 — GET com efeito colateral destrutivo, sem rate limit

laguna_server.py:75-95. /api/devices era leitura pura; passa a derrubar/reconstruir o PortAudio. Qualquer página aberta no browser pode disparar fetch('http://127.0.0.1:7531/api/devices?refresh=1', {mode:'no-cors'}) em loop — o browser envia a requisição, só bloqueia a leitura da resposta. O debounce existe só na UI (btn.disabled), não no servidor. Estado destrutivo pertence a POST.

🟡 Vetor 4 — a UI dá diagnóstico factualmente errado

Falha real de driver/hardware cai no mesmo reason: "unsupported" de "símbolo ausente" (laguna_devices.py:72-73,77), e a UI exibe "Re-detecção indisponível nesta versão do sounddevice" (static/i18n.js:19,166). Isso manda o usuário caçar o problema no lugar errado — o oposto do objetivo da issue. Falta um reason distinto (reinit_failed) e log.

🟡 Vetor 5 — bloqueio do event loop

laguna_server.py:76,95: async def executando chamada C bloqueante (centenas de ms no WASAPI) segurando um threading.Lock dentro do event loop do uvicorn — congela _broadcast_async (:61) e as demais rotas durante o refresh.

🟠 Vetor 6 — o aviso novo aparece SEMPRE (verificado no browser)

static/index.html:24 usa o atributo hidden, mas .badge define display: inline-flex (static/style.css:103-104) — regra de autor vence a folha do user-agent por origem no cascade, e não existe nenhuma regra [hidden] no CSS do projeto (só a classe .hidden, static/style.css:230).

Medido com o servidor rodando da worktree, DOM intocado, sem nenhum clique no 🔄:

#devices-hint → hiddenAttr:true, display:"flex", offsetWidth:26, offsetHeight:78, textContent:""

O usuário vê uma pílula laranja vazia encravada entre o 🔄 e o 🌐, permanente, em todo carregamento. Um badge de aviso vazio ao lado do botão de dispositivos lê como "tem algo errado no seu áudio" — o oposto da intenção. Correção mínima: [hidden] { display: none !important; } em static/style.css.

🟠 Vetor 7 — falso verde no teste (padrão da #34)

tests_unit/test_devices_refresh.py:121-128: o teste "não quebra o endpoint" passa porque _FakeSd.query_devices (:45-46) devolve a lista independentemente de terminate/initialize — o fake não modela paNotInitialized. Ele assere uma propriedade que a produção não tem (Bloqueador 1). O retry de initialize() (laguna_devices.py:80-83) não tem nenhum assert.


O que preciso que o @caioross decida

Não mergeio e não reparo por conta própria porque as correções dependem de três decisões de contrato — e a forma dos vetores 1/4/6 muda conforme a resposta:

  1. Verbo do refresh: manter GET ?refresh=1 ou migrar para POST /api/devices/refresh? (muda o contrato REST e o cliente)
  2. O que é "worker vivo": o dict _workers não serve. Aceita introduzir um contrato de liveness em laguna_core.py (ex.: DirectionWorker.is_alive() sobre as threads de áudio, com a guarda virando "nenhuma thread viva" em vez de "dict vazio")? Isso é diff no core — área de quórum por si só.
  3. Recuperação de PortAudio terminado: o endpoint deve degradar (200 com lista vazia + reason: reinit_failed) ou tentar re-inicializar sob demanda? Define a taxonomia de erro e as chaves i18n.

Definidas essas três, o resto (CSS, async, taxonomia de erro, teste que modele paNotInitialized) é reparo mecânico e a PR volta ao quórum.

O diagnóstico da issue #37 continua correto e o caminho escolhido (re-init explícito, fora do boot) é o certo — o problema é a invariante da guarda, não a ideia.

O merge de #69 moveu os imports pesados de laguna_server.py para dentro do
guarda de import; o `reinit_portaudio` desta PR foi para junto deles. Sem
mudanca de comportamento dos dois lados.

Gate na uniao: compileall OK, ruff (E9,F63,F7,F82) OK, imports OK,
pytest tests_unit/ 111 passed, node --check em static/app.js e i18n.js.
@caioross

caioross commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Reparo de conflito, sem tocar no mérito: o merge da #69 moveu os imports pesados de laguna_server.py para dentro de um guarda de import (falha de dependência passou a deixar rastro no laguna.log), e isso conflitava com o reinit_portaudio desta PR. Uni origin/main na branch (51dec1d, merge por união — sem rebase, sem --force); o reinit_portaudio foi junto para dentro do guarda.

Gate na união: compileall OK · ruff E9,F63,F7,F82 OK · imports OK · pytest tests_unit/ -q 111 passed · node --check em static/app.js e static/i18n.js OK.

Segue DRAFT + decisao-dono — nada aqui muda a decisão pendente do @caioross, só evita que ela apodreça atrás de um conflito.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

decisao-dono Espera decisão do @caioross — agentes não resolvem/mergeiam

Projects

None yet

Development

Successfully merging this pull request may close these issues.

audio: botao 🔄 nunca ve hardware novo — /api/devices le a lista congelada do PortAudio (sem re-init)

1 participant