
Os três verbos que mantêm o Web Audio vivo
Duas releases pontuais da GeekBye, com dois meses de diferença e em dois arquivos distintos, ensinaram ao nosso código de áudio a mesma lição a partir de extremos opostos: parar de tratar o AudioContext do navegador como descartável. Uma release aprendeu a fazer resume() de um contexto que o macOS havia suspendido silenciosamente no meio da gravação; a outra aprendeu a fazer suspend() em vez de close() para que sessões consecutivas parem de se espatifar contra o teto de aproximadamente seis contextos do Chromium. Resume, suspend, close — é esse o enredo inteiro.
Na maior parte do tempo, um AudioContext de Web Audio se comporta como um objeto comum: você constrói um, conecta alguns nós, e se esquece dele. Duas releases da GeekBye são o que aconteceu quando essa suposição quebrou — duas vezes, em duas partes diferentes do app, com dois meses de diferença. Nenhuma foi planejada como continuação da outra. Mas coloque-as lado a lado e elas contam uma única história: o AudioContext do navegador não é um objeto descartável. É um recurso escasso, de propriedade do sistema operacional, com uma máquina de estados real, e os três verbos dessa máquina de estados — resume, suspend, close — são o enredo inteiro de ambas as releases.
A GeekBye tem dois pipelines de áudio separados, e é por isso que a mesma lição teve que ser aprendida duas vezes. Um é o caminho de gravação de vídeo (ScreenRecordingService), que constrói um grafo de Web Audio para mixar o microfone e o áudio do sistema em um arquivo de vídeo gravado. O outro é o caminho de transcrição em tempo real (RealtimeAudioCaptureService), que constrói seu próprio grafo para alimentar o reconhecimento de voz. Cada um tem seu próprio AudioContext. Cada release abaixo corrige um deles.
v1.8.16: o vídeo ficou em silêncio, mas a transcrição não
O primeiro bug é um cérebro dividido. Você está gravando uma reunião, troca por um instante para outro app, e algo sutil acontece: o arquivo de vídeo perde seu microfone enquanto a transcrição ao vivo continua rolando como se nada tivesse mudado. A transcrição jura que você estava falando. O vídeo está em silêncio. É um tipo de bug enlouquecedor porque as duas coisas que deveriam concordar — «o microfone te ouviu?» — discordam categoricamente.
O commit que o corrige nomeia a causa com precisão:
AudioContext can be suspended by macOS when the app loses focus during screen recording. This silences the mic in the video file while the ScriptProcessorNode (STT path) keeps running on the JS main thread, causing a split where transcripts continue but the video has no mic.
Aqui está a mecânica. ScreenRecordingService.setupMonoMixingPipeline() constrói um AudioContext, um MediaStreamDestination que vira a trilha de áudio do vídeo, um GainNode para o silenciamento, e um par de ScriptProcessorNode (via createScriptProcessor) que bombeiam frames de áudio para a lógica de VAD/STT. Quando o macOS suspende o contexto — o que ele pode fazer quando o app perde o foco durante uma gravação de tela — as partes do grafo governadas pelo relógio do hardware de áudio param: o MediaStreamDestination para de receber áudio do microfone, então o vídeo gravado fica mudo. Mas o callback onaudioprocess de um ScriptProcessorNode roda na thread principal do JavaScript, e essa thread continua batendo. Então o lado de STT continua produzindo entradas de transcrição a partir do que tinha por último, e os dois consumidores do mesmo microfone divergem.
A correção é notar a suspensão e desfazê-la:
audioContext.onstatechange = () => {
if (audioContext.state === 'suspended' && this.status === 'recording') {
logger.info('AudioContext suspended during recording, resuming')
audioContext.resume()
}
}
Essa é a mudança de correção inteira: vigiar o state do contexto, e se ele cair para suspended enquanto ainda deveríamos estar gravando, chamar resume(). A release também conecta listeners onmute, onunmute e onended no MediaStreamTrack do microfone — não para mudar o comportamento, mas para que um silenciamento em nível de OS ou uma desconexão de dispositivo apareça nos logs em vez de ser uma causa invisível de silêncio. Quando toda a dificuldade de um bug é «por que o áudio parou», tornar a parada do áudio observável é metade da correção.
Um commit de acompanhamento chegou alguns minutos depois para limpar após esses novos listeners: cleanup() agora zera micTrack.onmute/onunmute/onended e define audioContext.onstatechange = null antes de desmontar o grafo. A nota do commit diz que ele «casa com o padrão de limpeza de RealtimeAudioCaptureService» — o outro serviço de áudio do app. Guarde esse detalhe, porque dois meses depois esse exato padrão é o que a próxima release teve que reescrever.
v2.0.2: o áudio e as transcrições morrem depois de cerca de seis sessões
O segundo bug vive no outro pipeline, RealtimeAudioCaptureService, e tem uma forma maravilhosa: tudo funciona perfeitamente, e então depois de você ter iniciado e parado a gravação um punhado de vezes numa mesma sessão do app, o áudio simplesmente... morre. Primeiro o áudio do sistema fica em silêncio, depois a transcrição para. Reinicie o app e está tudo bem de novo — até outro punhado de sessões.
A nota de causa raiz vale a pena ler por inteiro, porque é um bug-atrás-do-bug de manual:
Root cause: setupMonoMixingPipeline created a new AudioContext on every start() and cleanup() closed it. Chromium hard-caps hardware AudioContexts at ~6 per page and closed-but-not-yet-GC'd contexts keep their slot, so ~6 back-to-back sessions exhaust the cap. The next context is dead → the silent-monitor passthrough can't play (no system audio) AND the mixer worklet can't capture (no transcript) — the reported 'after 6+ sessions audio dies, then transcript dies'.
A armadilha é que o código antigo estava fazendo a coisa «responsável». Cada start() criava um AudioContext novo; cada cleanup() chamava close() nele. Fechar um recurso quando você termina com ele é exatamente o que te ensinam a fazer. Havia até um comentário inline racionalizando o vaivém — que recarregar o módulo AudioWorklet por contexto novo era aceitável porque «um contexto novo por start() significa que sempre pagamos esse custo uma vez».
Mas o Chromium limita quantos AudioContext apoiados por hardware uma única página pode manter — aproximadamente seis — e, crucialmente, close() não devolve o slot no instante em que você o chama. Um contexto fechado continua ocupando seu slot até ser recolhido pelo coletor de lixo, o que acontece quando o motor de JS resolve fazê-lo. Então uma política escrupulosa de fechar-toda-vez é precisamente o que te leva direto ao teto: seis ciclos de criar/fechar depois, os seis slots estão todos ocupados por cadáveres ainda não recolhidos, o sétimo new AudioContext() volta morto, e cada nó que você anexa a ele silenciosamente não faz nada.
A correção é parar de tratar o contexto como descartável. Criá-lo uma vez e reutilizá-lo por toda a vida do serviço:
- Em
setupMonoMixingPipeline, só construir umnew AudioContext(...)se ainda não houver nenhum ou o existente estiverclosed. Se existir mas estiversuspended, fazerawait audioContext.resume()em vez de criar um novo. - Em
cleanup, substituiraudioContext.close()poraudioContext.suspend()(com uma guarda para que nunca seja chamado num contexto já fechado). O contexto sobrevive à sessão. - Como o contexto sobrevive, o módulo
AudioWorkletcarregado sobrevive com ele — o código não reinicia mais seu flag de «worklet carregado» na limpeza, entãoaudioWorklet.addModule(...)é pago uma vez por toda a vida do serviço em vez de a cada sessão.
As peças por sessão — os nós createMediaStreamSource para o microfone e o áudio do sistema, o worklet do mixer, e o passthrough silencioso que mantém vivo o ponto de captura do loopback de áudio do sistema do Chromium — continuam sendo construídas e desmontadas a cada ciclo. Só o próprio AudioContext persiste, então existe no máximo um por vez. O teto é inatingível porque você nunca empilha contextos contra ele.
Uma nota honesta que o commit faz sobre si mesmo: isto foi verificado à mão — «verificado manualmente: 8+ sessões consecutivas mantêm o áudio do sistema audível e as transcrições fluindo (estava morto na sessão 7)» — e o autor é franco em que o ciclo de vida de Web Audio «não é testável por testes unitários em jsdom sem um grande novo aparato de mocks». Esse é o tipo certo de honestidade. A falha só aparece diante de um dispositivo de áudio real ao longo de sessões reais; uma suíte unitária no verde não teria provado nada. O «aproximadamente seis» e o «morto na sessão 7» são observações do time de onde o limite do Chromium mordeu, não uma constante documentada — o próprio código se resguarda com um til, e quem o repetir também deveria.
Resume, suspend, close
Alinhe as duas releases e a simetria é quase boa demais. Ambas tratam dos mesmos três métodos no mesmo tipo de objeto, abordados de direções opostas:
- A v1.8.16 aprendeu a fazer
resume(). O contexto ficoususpendedpor baixo dos panos pelo OS, e a correção é pegar isso e retomar — manter rodando um contexto que deveria estar rodando. - A v2.0.2 aprendeu a fazer
suspend()em vez declose(). O código antigo tinha pressa demais para destruir o contexto, e a correção é suspendê-lo e reutilizá-lo — manter vivo um contexto que é caro de recriar.
São as duas metades de um único princípio: parar de tratar o AudioContext como algo descartável. Uma metade diz: não deixe ele morrer quando não deveria; a outra diz: não o mate quando não precisa. E há uma pequena ironia na linha do tempo — a limpeza da v1.8.16 foi escrita para «casar com o padrão de limpeza de RealtimeAudioCaptureService», e então a v2.0.2 reescreveu exatamente esse padrão em RealtimeAudioCaptureService, porque o hábito de close()-toda-vez do padrão acabou sendo o bug. Os dois serviços continuam aprendendo um com o outro sobre o manejo do ciclo de vida do áudio, uma correção por vez.
Três coisas que estas releases nos ensinaram
- Um
AudioContexttem uma máquina de estados — tratestatecomo estrutural.running,suspendedeclosednão são trivialidades. O OS pode mover um contexto entre eles sem te perguntar, e partes diferentes do seu grafo reagem de forma diferente (umScriptProcessorNodena thread principal continua; o destino governado pelo relógio do hardware, não). Se você não vigiaonstatechange, está confiando que o contexto fica onde você o deixou — e num desktop real, sob mudanças de foco e gravação de tela, ele não vai ficar. close()não éfree(). A intuição de que fechar um recurso prontamente é sempre correto é exatamente o que causou o segundo bug. Quando o recurso é um slot num pool duro, imposto pelo navegador, que só é recuperado na hora do GC, fechar de forma agressiva é como você esgota o pool. Às vezes o ciclo de vida certo é criar-uma-vez-suspender-com-frequência, não criar-e-destruir-por-uso.- Alguns bugs só são alcançáveis pela porta do hardware real e do tempo real. «Morre depois de cerca de seis sessões» e «fica em silêncio quando você troca de aba» não podem ser pegos por um teste unitário que simula o dispositivo de áudio — e o time disse isso com clareza em vez de encobrir com um teste que passa falsamente. Nomeie os limites do seu aparato de testes; um bug que precisa de oito sessões reais para aparecer merece um honesto «verificado manualmente», não um tique verde que não significa nada.
Para o capítulo anterior, distinguir uma chamada de um app aberto (v1.8.15–v1.8.19); e para todo o arco, a anatomia de entregar software até a perfeição.