Steven
Steven10 min de leitura

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.

Engenharia
Audio
Desktop
Lançamentos da GeekBye
Os três verbos que mantêm o Web Audio vivo

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 um new AudioContext(...) se ainda não houver nenhum ou o existente estiver closed. Se existir mas estiver suspended, fazer await audioContext.resume() em vez de criar um novo.
  • Em cleanup, substituir audioContext.close() por audioContext.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 AudioWorklet carregado sobrevive com ele — o código não reinicia mais seu flag de «worklet carregado» na limpeza, então audioWorklet.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 ficou suspended por 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 de close(). 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

  1. Um AudioContext tem uma máquina de estados — trate state como estrutural. running, suspended e closed nã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 (um ScriptProcessorNode na thread principal continua; o destino governado pelo relógio do hardware, não). Se você não vigia onstatechange, 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.
  2. 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.
  3. 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.

Artigos Relacionados

O silêncio era estrutural
Steven
Steven8 min de leitura

O silêncio era estrutural

As duas últimas releases da GeekBye v1 giram em torno da mesma verdade incômoda: a transcrição em tempo real sobre uma rede real não é sem perdas, e o gesto honesto é parar de fingir que é. A v1.8.20 guardava em disco uma cópia de cada chunk de áudio antes de descartá-lo durante uma reconexão, e começou a marcar em voz alta os buracos na transcrição. A v1.9.0 parou de enviar silêncio para economizar largura de banda — e descobriu que o silêncio era justamente o sinal que o transcritor usava para saber que uma frase havia terminado. Duas releases sobre o custo de jogar coisas fora.

Engenharia
Audio
Confiabilidade
Distinguir uma chamada de um app aberto
Steven
Steven9 min de leitura

Distinguir uma chamada de um app aberto

A GeekBye pode notar que você entrou em uma reunião por vídeo e oferecer gravá-la. A detecção acaba sendo a metade fácil — um binário Swift que lê títulos de janela a cada dez segundos. A metade difícil é a precisão: não disparar quando o Zoom está meramente aberto, não oferecer gravar uma reunião que você já está gravando, e não silenciar o microfone na chamada em que você realmente está. Três releases, e cada uma é uma salvaguarda que teve que aprender a não se sabotar.

Engenharia
macOS
Desktop
Tirando o backend do caminho de upload
Steven
Steven9 min de leitura

Tirando o backend do caminho de upload

A GeekBye grava sua tela e salva o vídeo no seu Google Drive. A primeira versão enviava cada gravação pelos próprios servidores da GeekBye no caminho; uma release depois, o arquivo ia direto da sua máquina para o Drive, e o backend foi rebaixado a segurar um único ponteiro. A parte interessante é o quão pouco código a versão «direta e retomável» realmente contém — porque a retomabilidade veio de apagar um proxy, não de escrever um.

Engenharia
Arquitetura
Desktop