Repository navigation
fix(audio): traducao toca em paralelo nas saidas — um stream por device em vez de sd.play serial - #46
Conversation
…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
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
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.
Parecer do PR Doctor — quórum vetou, PR reparada, re-quórum 3× APROVA ✅Área de quórum (HANDBOOK §7.2: diff em Rodada 1 (head
|
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 montaoutput_devicescom dois alvos no fluxo recomendado (virtual → Discord + fone de monitoração), isso causava:O que mudou
laguna_core.py: um_OutputSinkpor device de saída —sd.OutputStreampróprio, aberto uma vez no start do worker e reusado, alimentado por uma thread própria._playfazsubmit()(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.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; ofinallyde_runfaz 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ódulosounddevice(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 poropen_streamcobre início simultâneo, bloqueio que não cresce com N, isolamento de falha de device, reabertura após erro de escrita eclose()idempotente sem thread pendurada.Gate (HANDBOOK §6) — resultado real
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.pytest tests_unit/ -q→ 44 passed (38 antes + 6 novos); os 6 novos rodados 3× seguidas sem flake.test_offline.py dry_pt2en.wav --direction pt2en --model small --device auto→ verde,out_gate.wavaudível. Passada quente: STT 438 ms | MT 298 ms | TTS 289 ms (áudio de saída 4.04 s). Ressalva honesta:test_offline.pynã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.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_playe o instante de início de cada saída:sd.playserial)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
sd.playdeixava 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.9e10aqui, ambos VB-Audio Virtual Cable) não abrem simultaneamente — o segundo emiteerror.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.sd.OutputStreamquanto comsd.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
0f9beb7O que o quórum vetou
close()chamastream.abort()justamente para desbloquear owrite()em andamento — e o PortAudio faz esse write falhar. Oexceptda thread do sink não checavaself._stop, tratava a exceção do abort como falha de device e emitiaerror.play. Comostatic/app.js:496-500trata qualquerkind:"error"como terminal (toggleButtons(panel,false)+state.running.delete(dir)) ecase '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, elaguna_server.py:250-257para o worker antigo a cada troca de config, então mudar device também disparava.Agravante:
FakeStream.abort()era no-op ewrite()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 quandoself._stopestá setado. Parar não é erro. O guarda é escopado no_stopdo sink (setado só porclose()), 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 (owrite()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 porqueue.Fullagora loga uma vez, em vez de sumir calada (segue a convenção vizinha do_on_drop). Contabilizar noDropCounter/overloadexige um terceirokinde 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(): fixaself._sinksnuma 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 tocamlaguna_core.pyem regiões vizinhas, fizgit 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,
0f9beb7compileall→COMPILE_OK;import fase0_poc, laguna_core, laguna_server→IMPORTS_OK.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.pycobsoleto na worktree produzia falso-vermelho emtest_vad_segmenter.py.test_offline.py dry_pt2en.wav --direction pt2en --model small --device auto→ exit 0,out_gate.wavaudível (179 KB), CUDA: STT 411 ms | MT 269 ms | TTS 280 ms.static/).Fatias próprias que o quórum levantou e este PR NÃO resolve
static/app.js:496-500—error.playreal 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.laguna_core.py,close()— não faz_done.set()ao final: numa janela estreita de shutdown,_playpode consumir o deadline inteiro (~2,2 s) numa thread daemon. Sem vazamento de device, sem evento para a UI._report()silencia sem logar — durante o teardown, uma falha genuína de device some sem rastro. Umlog()(não_emit) resolveria sem tocar o contrato do WS.DropCounter— o log one-shot não reseta; a contabilização própria (terceirokind+ payload dooverload) é fatia separada._passthrough_runtem a mesma classe de defeito que foi vetada aqui (exceção no stop viraerror.passthrough_genericsem checar_stop).