Skip to content

fix(audio): traducao toca em paralelo nas saidas — um stream por device em vez de sd.play serial - #46

Merged
caioross merged 3 commits into
mainfrom
auto/issue-38-parallel-output-sinks
Jul 28, 2026
Merged

caioross merged 3 commits into
mainfrom
auto/issue-38-parallel-output-sinks

Conversation

@caioross

@caioross caioross commented Jul 28, 2026 •

Copy link
Copy Markdown
Owner

Contexto

DirectionWorker._play() tocava a tradução em cada device de saída em série e bloqueando (sd.play(..., blocking=True)). Como a UI monta output_devices com dois alvos no fluxo recomendado (virtual → Discord + fone de monitoração), isso causava:

  1. o fone só começar quando o CABLE terminava — eco de uma frase inteira, monitoração inútil;
  2. o laço de tradução ficar bloqueado N × duração-da-frase, empilhando atraso ponta a ponta que não aparece na métrica de latência (ela para na síntese);
  3. abertura de stream a cada frase (dezenas de ms no WASAPI), em cima do alvo de p50 ~450 ms.

O que mudou

laguna_core.py: um _OutputSink por device de saída — sd.OutputStream próprio, aberto uma vez no start do worker e reusado, alimentado por uma thread própria.

  • _play faz submit() (não bloqueante) em todos os sinks e espera uma vez só, com deadline único — o bloqueio passa a ser ~1 frase, não N, e um device travado não multiplica a espera.
  • Falha de device é local: a exceção fecha só aquele stream (a próxima frase reabre) e continua sendo emitida por error.play — os outros sinks seguem tocando. Contrato do evento inalterado (mesma chave, mesmos args).
  • stop() aborta os streams, junta as threads e zera os sinks; o finally de _run faz o mesmo. Parar/reiniciar a direção não vaza device.
  • _update_level("out", ...) continua sendo alimentado 1× por frase, não 1× por device.

sd.play() não daria para paralelizar com threads: opera sobre um stream global do módulo sounddevice (duas chamadas concorrentes se cancelam) e reabre o stream a cada frase.

tests_unit/test_output_sink.py (novo, roda no CI — sem áudio, sem PortAudio): stream falso injetado por open_stream cobre início simultâneo, bloqueio que não cresce com N, isolamento de falha de device, reabertura após erro de escrita e close() idempotente sem thread pendurada.

Gate (HANDBOOK §6) — resultado real

  • T1 — python -m compileall -q . → COMPILE_OK; import fase0_poc, laguna_core, laguna_server → IMPORTS_OK. ruff check --select E9,F63,F7,F82 . → All checks passed.
  • T1+ — pytest tests_unit/ -q → 44 passed (38 antes + 6 novos); os 6 novos rodados 3× seguidas sem flake.
  • T2 — test_offline.py dry_pt2en.wav --direction pt2en --model small --device auto → verde, out_gate.wav audível. Passada quente: STT 438 ms | MT 298 ms | TTS 289 ms (áudio de saída 4.04 s). Ressalva honesta: test_offline.py não exercita _play (escreve WAV, não toca em device) — serve como não-regressão do pipeline; o número do que mudou está abaixo.
  • T3 — não aplicável (não toca static/).

Números antes/depois — reprodução (devices reais)

Frase real de 4,04 s (saída do T2), dois devices de saída independentes desta máquina (9 = Laguna Translator Output/VB-Audio, 14 = Oculus Virtual Audio — ambos inaudíveis), regime permanente (streams já abertos), medindo o tempo em que o laço fica bloqueado em _play e o instante de início de cada saída:

cenário 1 saída 2 saídas início da 2ª saída
antes (sd.play serial) 4123 ms 10136 ms +4114 ms
depois (sinks paralelos) 4041 ms 4050 ms +0 ms (< 1 ms)

Ou seja: bloqueio do laço deixa de crescer com o número de saídas, e as duas saídas passam a começar juntas. Nenhuma constante de VAD/latência ou default de modelo foi alterada.

Riscos

  • Os streams de saída ficam abertos enquanto a direção roda (antes, sd.play deixava aberto apenas o stream global do último device). Em modo compartilhado do WASAPI/MME isso é o mesmo padrão já usado pelo passthrough; para um app externo em modo exclusivo, um device a mais fica ocupado. stop() libera na hora.
  • Devices que são o mesmo endpoint com dois nomes (ex.: 9 e 10 aqui, ambos VB-Audio Virtual Cable) não abrem simultaneamente — o segundo emite error.play (Device unavailable) em vez de tocar em série. Configuração sem sentido prático (a mesma saída duas vezes), mas o erro fica visível na UI.
  • Paridade verificada: um device WASAPI cuja taxa nativa não bate com a do Piper (22050 Hz) falha na abertura tanto com sd.OutputStream quanto com sd.play — comportamento pré-existente, não introduzido aqui.

Diff em laguna_core.py → área de quórum.

Solicito quórum (HANDBOOK §7)

Closes #38


Reparo pós-quórum (PR Doctor) — head 0f9beb7

As seções acima são do Resolvedor e descrevem o estado original (df768ea). O quórum adversarial vetou essa versão; o que segue é o reparo e o estado final. Os números de gate acima ("44 passed", 6 testes novos) são os de antes do reparo.

O que o quórum vetou

close() chama stream.abort() justamente para desbloquear o write() em andamento — e o PortAudio faz esse write falhar. O except da thread do sink não checava self._stop, tratava a exceção do abort como falha de device e emitia error.play. Como static/app.js:496-500 trata qualquer kind:"error" como terminal (toggleButtons(panel,false) + state.running.delete(dir)) e case 'ready' nunca reabilita os botões, o resultado era: todo Stop com uma frase tocando deixava o painel travado (verde "rodando", ■ Parar desabilitado, slider de volume morto) até recarregar a página. E não era caso raro — a tradução sai ~1 frase atrasada, e laguna_server.py:250-257 para o worker antigo a cada troca de config, então mudar device também disparava.

Agravante: FakeStream.abort() era no-op e write() nunca levantava. A suíte era estruturalmente cega para o defeito — os 44 testes verdes não diziam nada sobre o caminho quebrado.

O reparo (commit b82e6e1)

  • _report() substitui a chamada direta a _on_error: não emite quando self._stop está setado. Parar não é erro. O guarda é escopado no _stop do sink (setado só por close()), não no do worker — falha real de device continua indo para a UI.
  • test_parar_com_frase_tocando_nao_vira_erro_de_device + _AbortaLevantando: fake que modela o PortAudio de verdade (o write() só retorna quando abortado, e aí levanta). Falha sem o fix — é regressão de verdade, não teste decorativo.
  • test_falha_de_device_fora_do_stop_continua_reportada: contraprova. Existe para impedir que o guarda vire mordaça.
  • submit(): frase descartada por queue.Full agora loga uma vez, em vez de sumir calada (segue a convenção vizinha do _on_drop). Contabilizar no DropCounter/overload exige um terceiro kind e mudaria o payload do evento — fica como fatia própria.
  • _open_sinks(): não abre device se o stop já chegou durante o warmup (fechava a janela em que o worker VELHO abria o device depois de o NOVO ter assumido).
  • _play(): fixa self._sinks numa local, para o laço de espera não enxergar um conjunto diferente do que submeteu se um stop concorrer.

União com a main (commit 0f9beb7)

A main recebeu a #49 (delegação do _segmenter à VADSegmenter) enquanto esta PR estava em quórum. Como as duas tocam laguna_core.py em regiões vizinhas, fiz git merge origin/main (união, nunca rebase/force) para o gate rodar sobre o código combinado, não sobre cada PR isolada. Merge automático, sem conflito.

Gate final — código combinado, 0f9beb7

  • T1 — compileall → COMPILE_OK; import fase0_poc, laguna_core, laguna_server → IMPORTS_OK.
  • Testes — pytest tests_unit/ -q → 48 passed (44 originais + 2 do reparo + 2 vindos da refactor(vad): DirectionWorker delega a segmentação à VADSegmenter testada #49). Rodado com __pycache__ limpo: um .pyc obsoleto na worktree produzia falso-vermelho em test_vad_segmenter.py.
  • T2 — test_offline.py dry_pt2en.wav --direction pt2en --model small --device auto → exit 0, out_gate.wav audível (179 KB), CUDA: STT 411 ms | MT 269 ms | TTS 280 ms.
  • T3 — não se aplica (não toca static/).

Fatias próprias que o quórum levantou e este PR NÃO resolve

  1. static/app.js:496-500 — error.play real continua sendo tratado como terminal pela UI, enquanto o worker segue rodando e o sink reabre o stream na frase seguinte. Estado UI≠backend até recarregar. É a raiz pré-existente; o reparo só parou de disparar o gatilho indevidamente.
  2. laguna_core.py, close() — não faz _done.set() ao final: numa janela estreita de shutdown, _play pode consumir o deadline inteiro (~2,2 s) numa thread daemon. Sem vazamento de device, sem evento para a UI.
  3. _report() silencia sem logar — durante o teardown, uma falha genuína de device some sem rastro. Um log() (não _emit) resolveria sem tocar o contrato do WS.
  4. Descarte de frase no DropCounter — o log one-shot não reseta; a contabilização própria (terceiro kind + payload do overload) é fatia separada.
  5. _passthrough_run tem a mesma classe de defeito que foi vetada aqui (exceção no stop vira error.passthrough_generic sem checar _stop).

…ice) (#38)

Com fone + CABLE configurados, `_play` chamava `sd.play(blocking=True)` device a
device: a 2a saida so comecava quando a 1a terminava (eco de uma frase inteira,
monitoracao inutil) e o laco de traducao ficava bloqueado N x duracao-da-frase —
atraso que nao aparece na metrica de latencia, que para na sintese.

Agora cada device de saida tem um `_OutputSink`: `sd.OutputStream` proprio,
aberto uma vez no start do worker e reusado (some tambem o custo de abertura por
frase), alimentado por uma thread propria. `_play` submete a mesma frase a todos
e espera uma vez so, com deadline unico — bloqueia ~1 frase, nao N.

- falha de um device nao impede os outros: excecao fecha so aquele stream (a
  proxima frase reabre) e continua sendo emitida por `error.play`;
- `stop()` aborta os streams, junta as threads e zera os sinks, para que
  reiniciar a direcao varias vezes nao vaze device;
- o medidor `_update_level("out", ...)` segue sendo alimentado 1x por frase.

`sd.play()` nao daria para paralelizar com threads: opera sobre um stream global
do modulo sounddevice e duas chamadas concorrentes se cancelam.

Refs #38
@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 added 2 commits July 28, 2026 16:25
O quorum adversarial (HANDBOOK 7.2) vetou: close() chama stream.abort() para
desbloquear o write() em andamento, e o PortAudio faz esse write falhar. O
except da thread do sink tratava isso como falha de device e emitia error.play
— que static/app.js trata como terminal (toggleButtons(panel,false) +
state.running.delete). Resultado: todo Stop com uma frase tocando, e toda troca
de config (laguna_server para o worker antigo), deixava o painel travado ate
recarregar a pagina. Parar nao e erro.

- _report() so emite se nao estamos em shutdown; erro real de device segue indo
  para a UI (test_falha_de_device_fora_do_stop_continua_reportada e a contraprova).
- submit(): frase descartada por queue.Full loga uma vez em vez de sumir calada.
  Contabilizar no DropCounter/`overload` exige um 3o kind e muda o payload do
  evento — fica como fatia propria.
- _open_sinks(): nao abre device se o stop ja chegou durante o warmup.
- _play(): fixa self._sinks numa local, para o laco de espera nao enxergar um
  conjunto diferente do que submeteu se um stop concorrer.
- Teste do vetor vetado: o FakeStream tinha abort() no-op e write() que nunca
  levantava — a suite era cega justamente para o defeito. _AbortaLevantando
  reproduz o PortAudio (write so retorna quando abortado, e ai levanta).

Gate: compileall OK, imports OK, pytest tests_unit/ -> 46 passed (44 + 2).

Refs #38
…uto/issue-38-parallel-output-sinks

Uniao para o gate rodar sobre o codigo combinado: a #49 mexeu em _segmenter e
esta PR mexe nos vizinhos (_open_sinks/_play). Merge limpo, sem conflito.
@caioross

Copy link
Copy Markdown
Owner Author

Parecer do PR Doctor — quórum vetou, PR reparada, re-quórum 3× APROVA ✅

Área de quórum (HANDBOOK §7.2: diff em laguna_core.py). Duas rodadas de 3 lentes adversariais, com default VETAR e obrigação de vetor arquivo:linha.

Rodada 1 (head df768ea) — 2 APROVA, 1 VETA ❌

Lente Produto/UX vetou, e o vetor era concreto e comum, não patológico:

close() chama stream.abort() justamente para desbloquear o write() em andamento — e o PortAudio faz esse write falhar. O except da thread do sink (laguna_core.py:169-176) não checava self._stop, tratava a exceção do próprio shutdown como falha de device e emitia error.play. Como static/app.js:496-500 trata qualquer kind:"error" como terminal (toggleButtons(panel,false) + state.running.delete(dir)) e case 'ready' (app.js:459-461) nunca reabilita os botões, todo Stop com uma frase tocando deixava o painel travado: verde "rodando", ■ Parar desabilitado, slider de volume morto em silêncio (app.js:277-283 faz early-return quando dir saiu de state.running). Só F5 recuperava.

Dois agravantes que sustentaram o veto:

  • Não era caso raro. A tradução sai ~1 frase atrasada, então parar logo depois de falar é o comportamento normal. Pior: /api/start numa direção já rodando chama existing.stop() (laguna_server.py:250-257), então trocar de device fazia o erro do worker VELHO chegar com o mesmo dir enquanto o NOVO subia.
  • A suíte era estruturalmente cega. FakeStream.abort() era no-op e write() nunca levantava; test_close_encerra_thread_e_fecha_stream fechava um sink ocioso. Os 44 testes verdes não tocavam o caminho quebrado.

A Lente Pipeline/Latência, que havia aprovado, tinha registrado o mesmo vetor (laguna_core.py:172-174) como achado relevante com a mesma correção sugerida. Veto corroborado — o quórum vale pelo argumento, não pelo voto.

Reparo (commit b82e6e1)

_report() no lugar da chamada direta a _on_error: não emite quando self._stop está setado. Escopado no _stop do sink (que só close() seta), não no do worker — falha real de device continua chegando à UI, e test_falha_de_device_fora_do_stop_continua_reportada existe justamente para impedir que o guarda vire mordaça. O teste do vetor vetado usa _AbortaLevantando, fake que modela o PortAudio de verdade (o write() só retorna quando abortado, e aí levanta) — falha sem o fix. Junto: log one-shot no descarte por queue.Full (frase deixou de sumir calada), _open_sinks checando stop, e _play fixando self._sinks numa local.

Rodada 2 (head b82e6e1) — 3× APROVA ✅

Produto/UX (a que vetou) — APROVA. "O vetor vetado está fechado na raiz certa": close() seta _stop antes de stream.abort(), e o write() só levanta por causa desse abort — a ordem set→abort→raise→check é sequencialmente forçada, então não há interleaving que deixe erro espúrio escapar. Stop e troca de config não emitem mais nada no WS.

Pipeline/Latência — APROVA. Custo zero no caminho quente: sinks = self._sinks troca 3 LOAD_ATTR por 1 LOAD_ATTR + 2 LOAD_FAST (estritamente mais barato), e o if not self._logou_descarte vive dentro do except queue.Full — caminho feliz não paga nada. Frase intacta (ordem, dtype, contiguidade, samplerate). Verificou que _logou_descarte não é sequer corrida: escritor e leitor são a mesma thread única de tradução. Provou empiricamente que os testes novos não flakeiam (700 iterações, inclusive com setswitchinterval forçado).

Privacidade/Robustez — APROVA. Zero rede, telemetria, I/O de arquivo ou chave no diff; o log() novo imprime só um índice inteiro de device, e o PCM nunca é formatado — conteúdo traduzido não tem como entrar. Não existe caminho em que os sinks fiquem abertos e nunca fechados: o finally de _run é incondicional. Parar/reiniciar N vezes não vaza device nem thread.

Higiene exigida pelo quórum e cumprida

A Lente Produto/UX condicionou o merge a uma correção de processo: a branch estava 1 commit atrás da main, que recebeu a #49 (delegação do _segmenter à VADSegmenter) durante o quórum. Como as duas PRs tocam laguna_core.py em regiões vizinhas, uni com git merge origin/main (união — nunca rebase, nunca force) para o gate rodar sobre o código combinado, não sobre cada PR isolada. Merge automático, sem conflito.

Ela também diagnosticou um falso-vermelho: um __pycache__ obsoleto na worktree fazia test_vad_segmenter.py falhar apontando linha 177 num arquivo de 149 linhas. Rodei o gate com o cache limpo.

Gate final no combinado (0f9beb7), Python canônico:

Fatias próprias (registradas para o Curador, não bloqueiam)

  1. static/app.js:496-500 — error.play real continua terminal para a UI enquanto o worker segue rodando e o sink reabre o stream na frase seguinte: estado UI≠backend até recarregar. É a raiz pré-existente; este PR só parou de puxar o gatilho indevidamente. Fatia: fatal:false no evento, ou reabilitar em ready/status.listening.
  2. close() não faz _done.set() — janela estreita de shutdown em que _play consome o deadline inteiro (~2,2 s) numa thread daemon. Sem vazamento, sem evento. Correção de 1 linha.
  3. _report() silencia sem logar — no teardown, falha genuína de device some sem rastro. Um log() (não _emit) resolve sem tocar o contrato do WS.
  4. Descarte de frase no DropCounter — o log one-shot não reseta e não tem prefixo [{direction}] (com duas direções, "saida dev=N" é ambíguo). A contabilização própria exige um terceiro kind e muda o payload do overload.
  5. _passthrough_run tem a mesma classe de defeito que foi vetada aqui: exceção no stop vira error.passthrough_generic sem checar _stop.

Os critérios de aceite da #38 foram conferidos um a um contra o código e os testes; Closes #38 procede. Ressalva honesta: o AC de "reiniciar a direção várias vezes não vaza device" está garantido por inspeção do finally, não por asserção em teste de laço start/stop.

Squash-merge. O fone e o CABLE passam a começar juntos (+0 ms em vez de +4114 ms), o laço deixa de ficar bloqueado N × duração-da-frase — e parar a direção deixou de mentir que o device falhou.

@caioross
caioross merged commit b9bbeb7 into main Jul 28, 2026
2 checks passed
@caioross
caioross deleted the auto/issue-38-parallel-output-sinks branch July 28, 2026 19:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

audio: com fone + CABLE a traducao toca em serie — sd.play(blocking=True) por device bloqueia o worker

1 participant