Uma história sobre dois relógios

Programação de áudio da Web com precisão

Chris Wilson
Chris Wilson

Introdução

Gerenciar o tempo é um dos maiores desafios na criação de um software de áudio e música de qualidade usando a plataforma da Web. Não como em “tempo de escrever código”, mas como na hora do relógio - um dos tópicos menos compreendidos sobre o Web Audio é como trabalhar corretamente com o relógio de áudio. O objeto Web AudioContext tem uma propriedade currentTime que expõe esse relógio de áudio.

Particularmente para aplicações musicais de áudio da Web, não apenas para escrever sequenciadores e sintetizadores, mas qualquer uso rítmico de eventos de áudio, como baterias, jogos e outros aplicativos, é muito importante ter um tempo consistente e preciso de eventos de áudio, não apenas iniciar e encerrar sons, mas também programar mudanças no som (como mudança de frequência ou volume). Às vezes, é desejável ter eventos levemente aleatórios em relação ao tempo, por exemplo, na demonstração de metralhadora em Desenvolvimento de áudio de jogos com a API Web Audio, mas normalmente queremos ter um tempo consistente e preciso para notas musicais.

Já mostramos como agendar notas usando o parâmetro de tempo dos métodos Web Audio noteOn e noteOff (agora renomeados start e stop) em Primeiros passos com o Web Audio e também em Como desenvolver áudio de jogos com a API Web Audio. No entanto, ainda não exploramos cenários mais complexos, como reprodução de longas sequências ou ritmos musicais. Para isso, precisamos primeiro saber um pouco sobre relógios.

O melhor dos tempos - o Relógio de áudio da web

A API Web Audio expõe o acesso ao relógio de hardware do subsistema de áudio. Esse relógio é exposto no objeto AudioContext pela propriedade .currentTime, como um número de ponto flutuante de segundos desde que o AudioContext foi criado. Isso permite que esse relógio (doravante chamado de "relógio de áudio") tenha uma precisão muito alta e foi projetado para especificar o alinhamento em um nível de amostra de som individual, mesmo com uma alta taxa de amostragem. Como há cerca de 15 dígitos decimais de precisão em um "duplo", mesmo que o relógio de áudio esteja em execução há dias, ele ainda tem muitos bits restantes para apontar para uma amostra específica, mesmo com uma alta taxa de amostragem.

O relógio de áudio é usado para programar parâmetros e eventos de áudio em toda a API Web Audio - para start() e stop(), é claro, mas também para métodos set*ValueAtTime() em AudioParams. Isso nos permite configurar eventos de áudio cronometrados com muita precisão com antecedência. Na verdade, é tentador configurar tudo no Web Audio como tempos de início/parada. No entanto, na prática, há um problema com isso.

Por exemplo, veja este snippet de código reduzido da nossa Introdução ao áudio da Web, que configura duas compassos de um padrão de colcheia:

for (var bar = 0; bar < 2; bar++) {
  var time = startTime + bar * 8 * eighthNoteTime;

  // Play the hi-hat every eighth note.
  for (var i = 0; i < 8; ++i) {
    playSound(hihat, time + i * eighthNoteTime);
  }

Esse código funcionará muito bem. Porém, se quiser mudar o ritmo no meio desses dois compassos ou parar de tocar antes dos dois compassos acabarem, não vai conseguir. Alguns desenvolvedores já fizeram coisas como inserir um nó de ganho entre os AudioBufferSourceNodes pré-programados e a saída, só para que possam silenciar os próprios sons.

Resumindo, como você vai precisar de flexibilidade para mudar o ritmo ou parâmetros como frequência ou ganho (ou parar totalmente a programação), você não deve colocar muitos eventos de áudio na fila. Ou, mais precisamente, não convém pensar muito no tempo à frente, porque você pode querer mudar totalmente essa programação.

Os piores tempos: o Relógio JavaScript

Temos também nosso amado e muito amado relógio JavaScript, representado por Date.now() e setTimeout(). O lado bom desse relógio é que ele tem alguns métodos "call-me-back-mais tarde" window.setTimeout() e window.setInterval() muito úteis, que permitem que o sistema chame nosso código de volta em momentos específicos.

O lado ruim do relógio JavaScript é que ele não é muito preciso. Para iniciantes, Date.now() retorna um valor em milissegundos, um número inteiro de milissegundos, então a melhor precisão que você pode esperar é de um milissegundo. Isso não é tão ruim em alguns contextos musicais, mas, mesmo com uma taxa de hardware de áudio relativamente baixa de 44,1 kHz, ele é cerca de 44,1 vezes lento demais para ser usado como um relógio de programação de áudio. Lembre-se de que descartar amostras pode causar falhas de áudio. Portanto, se estamos encadeando amostras, elas podem precisar ser sequenciais com precisão.

A promissor especificação do tempo de alta resolução, na verdade, oferece uma precisão muito melhor do horário atual por meio de window.performance.now(). Ela até é implementada (embora prefixada) em muitos navegadores atuais. Isso pode ajudar em algumas situações, embora não seja realmente relevante para a pior parte das APIs de tempo do JavaScript.

A pior parte das APIs de tempo do JavaScript é que, embora a precisão de milissegundos de Date.now() não pareça muito ruim de se atender, o callback real dos eventos de tempo em JavaScript (através de window.setTimeout() ou window.setInterval) pode facilmente ser distorcido em dezenas de milissegundos ou mais por layout, renderização, coleta de lixo, XMLHTTPRequest e outros callbacks, em resumo, por qualquer número de execuções principais. Lembra-se de como mencionei “eventos de áudio” que poderíamos agendar usando a API de áudio da web? Bem, tudo isso está sendo processado em uma linha de execução separada. Então, mesmo que a linha de execução principal fique temporariamente paralisada com um layout complexo ou outra tarefa longa, o áudio ainda vai acontecer exatamente no horário em que foram instruídos. Na verdade, mesmo que você esteja parado em um ponto de interrupção no depurador, a linha de execução de áudio vai continuar reproduzindo eventos programados.

Usar a função setTimeout() do JavaScript em apps de áudio

Como a linha de execução principal pode ser facilmente parada por vários milésimos de segundo de uma vez, não é uma boa ideia usar o setTimeout do JavaScript para começar a reproduzir eventos de áudio diretamente, pois, na melhor das hipóteses, suas notas serão disparadas em cerca de um milissegundo do horário que realmente deveriam e, na pior das hipóteses, serão adiadas por mais tempo. O pior de tudo é que, para o que devem ser sequências rítmicas, elas não são disparadas em intervalos precisos, porque o tempo é sensível a outros acontecimentos que acontecem na linha de execução principal do JavaScript.

Para demonstrar isso, criei um exemplo de aplicativo de metrônomo “ruim”, ou seja, que usa setTimeout diretamente para agendar anotações, e também cria muito layout. Abra este aplicativo, clique em “reproduzir” e redimensione a janela rapidamente durante a reprodução. Você perceberá que o tempo está visivelmente instável (você pode ouvir que o ritmo não permanece consistente). "Mas isso é artificial!", você diz? Bem, é claro, mas isso não significa que isso também não acontece no mundo real. Até mesmo a interface do usuário relativamente estática terá problemas de tempo em setTimeout devido a reformulações do layout. Por exemplo, notamos que o redimensionamento rápido da janela faz com que o tempo do excelente WebkitSynth trava visivelmente. Agora imagine o que vai acontecer quando você estiver tentando rolar suavemente uma partitura completa junto com o áudio. Então, é fácil imaginar como isso afetaria apps de música complexos no mundo real.

Uma das perguntas mais frequentes que ouço é "Por que não consigo receber callbacks de eventos de áudio?". Embora possa haver usos para esses tipos de callbacks, eles não resolveriam o problema específico em questão. É importante entender que esses eventos seriam disparados na linha de execução principal do JavaScript, portanto, estariam sujeitos a todos os possíveis atrasos que o setTimeout, ou seja, podem ser atrasados por alguns milissegundos desconhecidos e variáveis de tempo exato antes de serem processados de fato.

Então, o que podemos fazer? A melhor forma de lidar com o tempo é configurar uma colaboração entre os timers JavaScript (setTimeout(), setInterval() ou requestAnimationFrame() – vamos falar mais sobre isso depois) e a programação do hardware de áudio.

Obtendo um ritmo sólido considerando o futuro

Vamos voltar à demonstração do metrônomo. Na verdade, escrevi a primeira versão dessa demonstração simples do metrônomo corretamente para demonstrar essa técnica de agendamento colaborativo. O código também está disponível no GitHub (link em inglês). Essa demonstração toca sons sonoros (gerados por um oscilador) com alta precisão a cada seis, colcheia ou semínima, alterando o tom dependendo da batida. Ele também permite que você mude o ritmo e o intervalo de notas enquanto o toca, ou pare a reprodução a qualquer momento, o que é um recurso importante para qualquer sequenciador rítmico do mundo real. Seria muito fácil adicionar código para alterar de forma imediata os sons que esse metrônomo usa.

A maneira como ele permite o controle de temperatura e mantém um tempo sólido é uma colaboração: um timer setTimeout que é acionado uma vez com frequência e configura o agendamento do Web Audio para notas individuais no futuro. O timer setTimeout basicamente verifica se alguma nota precisa ser agendada "em breve" com base no andamento atual e depois as programa, da seguinte maneira:

setTimeout() e interação do evento de áudio.
setTimeout() e interação de evento de áudio.

Na prática, as chamadas setTimeout() podem ser atrasadas, então o tempo das chamadas de agendamento pode ficar instável (e distorcer, dependendo de como você usa setTimeout) ao longo do tempo. Embora os eventos neste exemplo sejam disparados com aproximadamente 50 ms de intervalo, eles frequentemente aparecem um pouco mais do que isso (e às vezes muito mais). No entanto, durante cada chamada, agendamos eventos de áudio da web não apenas para as notas que precisem ser tocadas agora (por exemplo, a primeira nota), mas também para todas as notas que precisem ser tocadas entre agora e o próximo intervalo.

Na verdade, não queremos apenas prever precisamente o intervalo entre chamadas de setTimeout(). Também precisamos de alguma sobreposição de programação entre essa chamada de timer e a próxima para acomodar o pior comportamento da linha de execução principal, ou seja, o pior caso de coleta de lixo, layout, renderização ou outro código que aconteça na linha de execução principal, atrasando nossa próxima chamada de timer. Também precisamos considerar o tempo de programação de blocos de áudio, ou seja, quanto áudio o sistema operacional mantém no buffer de processamento, que varia entre sistemas operacionais e hardware, de poucos dígitos de milissegundos a cerca de 50 ms. Cada chamada setTimeout() mostrada acima tem um intervalo azul que mostra todo o intervalo de vezes em que tentará programar eventos. Por exemplo, o quarto evento de áudio da Web programado no diagrama acima poderia ter sido reproduzido “atrasado” se esperávamos para reproduzi-lo até a próxima chamada setTimeout acontecer, se essa chamada setTimeout tivesse ocorrido apenas alguns milissegundos depois. Na vida real, a instabilidade nesses momentos pode ser ainda mais extrema, e essa sobreposição se torna ainda mais importante à medida que o aplicativo se torna mais complexo.

A latência geral da pré-visualização afeta o nível de rigidez do controle de ritmo (e de outros controles em tempo real). O intervalo entre as chamadas de programação é uma troca entre a latência mínima e a frequência com que seu código afeta o processador. O quanto a previsão se sobrepõe ao horário de início do próximo intervalo determina a resiliência do app em diferentes máquinas e à medida que ele se torna mais complexo (e o layout e a coleta de lixo podem demorar mais). Em geral, para ser resiliente a máquinas e sistemas operacionais mais lentos, é melhor ter uma grande visão geral e um intervalo razoavelmente curto. Você pode ter sobreposições mais curtas e intervalos mais longos para processar menos callbacks. No entanto, em algum momento, talvez você comece a ouvir que uma grande latência faz com que mudanças de ritmo e outros efeitos não entrem em vigor imediatamente. Por outro lado, se você diminuiu muito a instabilidade (já que uma chamada de agendamento poderia ter que "compensar" eventos que deveriam ter acontecido anteriormente).

O diagrama de tempo abaixo mostra o que o código de demonstração do metrônomo realmente faz: ele tem um intervalo setTimeout de 25 ms, mas uma sobreposição muito mais resiliente: cada chamada será agendada para os próximos 100 ms. A desvantagem disso é que mudanças de ritmo e outros levam um décimo de segundo para entrar em vigor; no entanto, somos muito mais resilientes a interrupções:

Programação com sobreposições longas.
programação com longas sobreposições

Nesse exemplo, tivemos uma interrupção de setTimeout no meio. Deveríamos ter tido um callback setTimeout em aproximadamente 270 ms, mas ele foi adiado por algum motivo até aproximadamente 320 ms, 50 ms mais tarde do que deveria ter sido. No entanto, a grande latência antecipada continuou o ritmo sem problemas, e não perdemos a batida, apesar de aumentarmos o ritmo pouco antes disso para tocar as semicolcheias a 240 bpm (além dos tempos de tambor e baixo pesados!)

Também é possível que cada chamada do programador acabe agendando várias notas. Vamos dar uma olhada no que acontece se usarmos um intervalo de agendamento mais longo (250 ms de antecedência, 200 ms de intervalo) e um aumento de ritmo no meio:

setTimeout() com espera longa e intervalos longos.
setTimeout() com antecedência longa e intervalos longos

Esse caso demonstra que cada chamada setTimeout() pode acabar agendando vários eventos de áudio. Na verdade, esse metrônomo é um aplicativo simples de uma nota por vez, mas é possível ver facilmente como essa abordagem funciona para uma bateria (em que há frequentemente várias notas simultâneas) ou um sequenciador (que frequentemente pode ter intervalos não regulares entre as notas).

Na prática, convém ajustar o intervalo de programação e a antecedência para ver como eles são afetados pelo layout, pela coleta de lixo e por outros aspectos da linha de execução principal do JavaScript, além de ajustar a granularidade do controle sobre o andamento etc. Se você tem um layout muito complexo que acontece com frequência, por exemplo, talvez seja melhor aumentar a visualização. O ponto principal é que queremos que a quantidade de “programação antecipada” que estamos fazendo seja grande o suficiente para evitar atrasos, mas não tão grande a ponto de criar um atraso perceptível ao ajustar o controle de ritmo. Mesmo o caso acima tem uma sobreposição muito pequena e, por isso, não será muito resiliente em uma máquina lenta com um aplicativo da Web complexo. Um bom ponto de partida são, provavelmente, 100 ms de "aspectiva", com intervalos definidos como 25 ms. Isso ainda pode ter problemas em aplicativos complexos em máquinas com muita latência do sistema de áudio. Nesse caso, você deve aumentar o tempo de espera ou, se precisar de um controle mais restrito com a perda de alguma resiliência, use um olhar mais curto à frente.

O código principal do processo de agendamento está na função agendador() -

while (nextNoteTime < audioContext.currentTime + scheduleAheadTime ) {
  scheduleNote( current16thNote, nextNoteTime );
  nextNote();
}

Essa função apenas identifica o tempo atual do hardware de áudio e o compara com o tempo da próxima nota na sequência. Na maior parte do tempo* nesse cenário preciso, isso não fará nada (já que não há "notas" de metrônomo aguardando para serem programadas, mas, quando acertar, ela agendará essa nota usando a API Web Audio e avançará para a próxima nota.

A função scheduleNote() é responsável por programar a próxima “nota” de áudio da Web a ser reproduzida. Nesse caso, usei os osciladores para emitir sons de bipes em diferentes frequências. Você poderia criar nós AudioBufferSource com a mesma facilidade e definir os buffers deles para sons de bateria ou qualquer outro som que quiser.

currentNoteStartTime = time;

// create an oscillator
var osc = audioContext.createOscillator();
osc.connect( audioContext.destination );

if (! (beatNumber % 16) )         // beat 0 == low pitch
  osc.frequency.value = 220.0;
else if (beatNumber % 4)          // quarter notes = medium pitch
  osc.frequency.value = 440.0;
else                              // other 16th notes = high pitch
  osc.frequency.value = 880.0;
osc.start( time );
osc.stop( time + noteLength );

Depois que os osciladores são programados e conectados, o código pode esquecê-los completamente. Eles são iniciados, interrompidos e, em seguida, a coleta de lixo é automática.

O método nextNote() é responsável por avançar para a próxima sexta nota, ou seja, definir as variáveis nextNoteTime e current16thNote para a próxima nota:

function nextNote() {
  // Advance current note and time by a 16th note...
  var secondsPerBeat = 60.0 / tempo;    // picks up the CURRENT tempo value!
  nextNoteTime += 0.25 * secondsPerBeat;    // Add 1/4 of quarter-note beat length to time

  current16thNote++;    // Advance the beat number, wrap to zero
  if (current16thNote == 16) {
    current16thNote = 0;
  }
}

Isso é bastante simples, mas é importante entender que, neste exemplo de agendamento, não estou acompanhando o "tempo da sequência", ou seja, o tempo desde o início do metrônomo. Tudo o que precisamos fazer é lembrar quando tocamos a última nota e descobrir quando a próxima nota está programada para tocar. Dessa forma, podemos mudar o ritmo (ou parar de tocar) com muita facilidade.

Essa técnica de agendamento é usada por vários outros aplicativos de áudio na Web, por exemplo, a Web Audio Drum Machine, o divertido jogo Acid Defender e exemplos de áudio ainda mais aprofundados, como a demonstração do Granular Effects.

Outro sistema de programação

Como todo bom músico sabe, o que todo aplicativo de áudio precisa é de mais campainhas... ou mais timers. Vale a pena mencionar que a maneira certa de apresentar o visual é usar um TERCEIRO sistema de marcação de tempo.

Por que precisamos de outro sistema de tempo? Ele é sincronizado com a exibição visual, ou seja, a taxa de atualização de gráficos, pela API requestAnimationFrame. Para caixas de desenho em nosso exemplo de metrônomo, isso pode não parecer grande coisa, mas à medida que seus gráficos ficam cada vez mais complexos, é cada vez mais importante usar o requestAnimationFrame() para sincronizar com a taxa de atualização visual. Na verdade, ele é tão fácil de usar desde o início quanto o uso de setTimeout()! Com gráficos sincronizados muito complexos (por exemplo, a exibição precisa das notas de áudio mais densas e a sincronização gráfica mais precisa(), você fornecerá a solicitação Animation() mais precisa e a mais precisa do pacote de notação musical()) para sincronizar com a taxa de atualização visual.

Mantemos o controle das batidas na fila no programador:

notesInQueue.push( { note: beatNumber, time: time } );

A interação com o tempo atual do nosso metrônomo pode ser encontrada no método draw(), que é chamado (usando requestAnimationFrame) sempre que o sistema gráfico está pronto para uma atualização:

var currentTime = audioContext.currentTime;

while (notesInQueue.length && notesInQueue[0].time < currentTime) {
  currentNote = notesInQueue[0].note;
  notesInQueue.splice(0,1);   // remove note from queue
}

Mais uma vez, você vai notar que estamos verificando o relógio do sistema de áudio - porque é realmente ele com que queremos sincronizar, já que ele vai tocar as notas - para ver se devemos desenhar uma nova caixa ou não. Na verdade, não estamos usando os carimbos de data/hora requestAnimationFrame, porque usamos o relógio do sistema de áudio para descobrir em que ponto estamos no tempo.

É claro que eu poderia ter simplesmente pulado o uso de um callback setTimeout() e colocado meu agendador de notas no callback requestAnimationFrame, assim voltaríamos a ter dois timers novamente. Isso também não tem problema, mas é importante entender que requestAnimationFrame é apenas uma substituição para setTimeout() nesse caso. Você ainda vai querer a precisão da programação do tempo do áudio da Web para as notas reais.

Conclusão

Espero que este tutorial tenha sido útil para explicar relógios, temporizadores e como criar ótimos tempos em aplicativos de áudio da web. Essas mesmas técnicas podem ser facilmente extrapoladas para criar sequências de jogadores, baterias e muito mais. Até a próxima...