Uma história sobre dois relógios

Programar áudio da Web com precisão

Chris Wilson
Chris Wilson

Introdução

Um dos maiores desafios na criação de softwares de áudio e música usando a plataforma da Web é gerenciar o tempo. Não como "tempo para escrever código", mas como "tempo de relógio". Um dos tópicos menos compreendidos sobre o Web Audio é como trabalhar corretamente com o relógio de áudio. O objeto AudioContext do Web Audio 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 para qualquer uso rítmico de eventos de áudio, como baterias de bateria, jogos e outros aplicativos, é muito importante ter eventos de áudio consistentes e precisos, não apenas ao iniciar e parar sons, mas também programar mudanças no som (como mudanças de frequência ou volume). Às vezes, é desejável ter eventos ligeiramente randomizados no tempo, por exemplo, na demonstração de metralhadora em Como desenvolver áudio de jogos com a API Web Audio. No entanto, geralmente, queremos ter um tempo consistente e preciso para as notas musicais.

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

The Best of Times: 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 (chamado de "relógio de áudio") tenha uma precisão muito alta. Ele foi projetado para especificar o alinhamento em um nível individual de amostragem de som, mesmo com uma taxa de amostragem alta. 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 deve ter 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 com precisão com antecedência. Na verdade, é tentador configurar tudo no Web Audio como horários de início/parada. No entanto, na prática, há um problema com isso.

Por exemplo, confira este snippet de código reduzido da nossa introdução ao áudio da Web, que configura duas barras de um padrão de bumbo 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 vai funcionar muito bem. No entanto, se você quiser mudar o andamento no meio dessas duas barras ou parar de tocar antes que as duas barras terminem, não vai dar certo. Já vi desenvolvedores fazerem coisas como inserir um nó de ganho entre os AudioBufferSourceNodes pré-programados e a saída, apenas para silenciar os próprios sons.

Em resumo, como você vai precisar da flexibilidade para mudar o tempo ou parâmetros como frequência ou ganho (ou parar a programação completamente), não é recomendável enviar muitos eventos de áudio para a fila. Ou, mais precisamente, não é recomendável olhar muito para o futuro, porque você pode querer mudar essa programação completamente.

The Worst of Times: o relógio JavaScript

Também temos nosso relógio JavaScript muito amado e muito criticado, representado por Date.now() e setTimeout(). O lado bom do relógio JavaScript é que ele tem alguns métodos muito úteis de "me ligue depois" window.setTimeout() e window.setInterval(), que permitem que o sistema chame nosso código em momentos específicos.

O lado ruim do relógio JavaScript é que ele não é muito preciso. Para começar, 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.Se a nota começar um milissegundo antes ou depois, você nem vai notar.No entanto, mesmo com uma taxa de hardware de áudio relativamente baixa de 44,1 kHz, o tempo é cerca de 44,1 vezes mais lento para ser usado como um relógio de programação de áudio. Lembre-se de que colocar amostras pode causar falhas de áudio. Portanto, se estivermos encadeando amostras, talvez elas sejam necessárias em sequência.

A especificação de tempo de alta resolução (link em inglês) que está por vir oferece uma precisão muito maior do tempo atual com window.performance.now(); ela é implementada (embora prefixada) em muitos navegadores atuais. Isso pode ajudar em algumas situações, mas não é realmente relevante para a pior parte das APIs de temporização 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 ruim, o callback real de eventos de timer em JavaScript (por window.setTimeout() ou window.setInterval()) pode ser facilmente distorcido por dezenas de milissegundos ou mais por layout, renderização, coleta de lixo, XMLHTTPRequest e outros callbacks, ou seja, por qualquer número de coisas que acontecem na linha de execução principal. Lembra quando mencionei os "eventos de áudio" que podemos programar usando a API Web Audio? Bem, todos eles são processados em uma linha de execução separada. Portanto, mesmo que a linha de execução principal seja temporariamente interrompida para fazer um layout complexo ou outra tarefa demorada, o áudio ainda vai acontecer exatamente nos horários programados. Na verdade, mesmo que você pare em um ponto de interrupção no depurador, a linha de execução de áudio vai continuar reproduzindo os eventos programados.

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

Como a linha de execução principal pode ser facilmente interrompida por vários milissegundos de cada vez, não é recomendável usar o setTimeout do JavaScript para iniciar diretamente a reprodução de eventos de áudio. Na melhor das hipóteses, suas notas serão disparadas em um milissegundo ou mais do que deveriam, e na pior, elas serão atrasadas por ainda mais tempo. Pior ainda, para o que deveria ser sequências rítmicas, elas não são acionadas em intervalos precisos, já que o tempo é sensível a outras coisas que acontecem na linha de execução principal do JavaScript.

Para demonstrar isso, escrevi um exemplo de aplicativo de metrônomo "ruim", ou seja, um que usa setTimeout diretamente para programar notas e também faz muito layout. Abra o aplicativo, clique em "reproduzir" e redimensione a janela rapidamente enquanto ela está sendo reproduzida. Você vai notar que o tempo está visivelmente instável (é possível ouvir que o ritmo não permanece consistente). "Mas isso é artificial!", você diz? Claro, mas isso não significa que não acontece no mundo real. Mesmo uma interface do usuário relativamente estática terá problemas de tempo em setTimeout devido a novos layouts. Por exemplo, notei que redimensionar a janela rapidamente faz com que o tempo do excelente WebkitSynth oscile de forma perceptível. Agora imagine o que vai acontecer quando você tentar rolar suavemente uma partitura musical completa com seu áudio. É 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 de JavaScript, portanto, eles estariam sujeitos aos mesmos possíveis atrasos que setTimeout, ou seja, eles poderiam ser atrasados por algum tempo desconhecido e variável de milissegundos.

O que podemos fazer? A melhor maneira de lidar com o tempo é configurar uma colaboração entre os timers do JavaScript (setTimeout(), setInterval() ou requestAnimationFrame() – mais informações a seguir) e a programação de hardware de áudio.

Como ter um tempo perfeito olhando para o futuro

Vamos voltar à demonstração do metrônomo. Na verdade, eu escrevi a primeira versão dessa demonstração simples de metrônomo corretamente para demonstrar essa técnica de programação colaborativa. O código também está disponível no Github. Esta demonstração toca sons de bipe (gerados por um oscilador) com alta precisão em cada nota de 16, 8 ou 4, alterando o tom dependendo do ritmo. Ele também permite mudar o andamento e o intervalo de notas enquanto está tocando ou parar a reprodução a qualquer momento, o que é um recurso fundamental para qualquer sequenciador rítmico do mundo real. Seria muito fácil adicionar código para mudar os sons que o metrônomo usa em tempo real.

Ela permite o controle de temperatura e mantém um tempo sólido: um timer setTimeout que é acionado uma vez de vez em quando e configura a programação do Web Audio no futuro para notas individuais. O timer setTimeout basicamente verifica se é necessário programar alguma nota "em breve" com base no tempo atual e, em seguida, as programa, como esta:

setTimeout() e interação com eventos de áudio.
Timeout() e interação com eventos de áudio.

Na prática, as chamadas de setTimeout() podem ser atrasadas, então o tempo das chamadas de programação pode variar (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 geralmente são um pouco mais longos (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 precisam ser tocadas a partir de agora até o intervalo seguinte.

Na verdade, não queremos apenas olhar para a frente precisamente o intervalo entre chamadas setTimeout(). Também precisamos de algumas sobreposições 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 acontece na linha de execução principal, atrasando nossa próxima chamada de timer. Também precisamos considerar o tempo de programação de bloco de áudio, ou seja, quanto áudio o sistema operacional mantém no buffer de processamento, que varia de acordo com os sistemas operacionais e o hardware, de dígitos baixos 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 ele vai tentar programar eventos. Por exemplo, o quarto evento de áudio da Web programado no diagrama acima pode ter sido reproduzido "atrasado" se tivéssemos esperado para reproduzi-lo até a próxima chamada setTimeout ocorrer alguns milissegundos depois. Na vida real, o jitter nesses momentos pode ser ainda mais extremo, e essa sobreposição se torna ainda mais importante à medida que o app se torna mais complexo.

A latência de previsão geral afeta a precisão do controle de tempo (e outros controles em tempo real). O intervalo entre as chamadas de programação é uma compensação entre a latência mínima e a frequência com que o código afeta o processador. O quanto o lookahead se sobrepõe ao horário de início do próximo intervalo determina a resiliência do seu app em diferentes máquinas e conforme se torna mais complexo (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 um panorama geral amplo e um intervalo razoavelmente curto. É possível ajustar para ter intervalos mais curtos e intervalos mais longos, a fim de processar menos callbacks, mas em algum momento, você pode começar a ouvir que uma latência grande causa mudanças de tempo etc., para não entrar em vigor imediatamente. Por outro lado, se você reduziu muito o tempo de espera, pode começar a ouvir alguns ruídos, já que uma chamada de programação pode precisar "compensar" eventos que deveriam ter acontecido no passado.

O diagrama de temporização a seguir mostra o que o código de demonstração do metrônomo faz: ele tem um intervalo setTimeout de 25ms, mas uma sobreposição muito mais resiliente: cada chamada é programada para os próximos 100ms. A desvantagem dessa abordagem longa é que mudanças de ritmo levam um décimo de segundo para entrar em vigor. No entanto, somos muito mais resistentes a interrupções:

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

Na verdade, é possível notar neste exemplo que houve uma interrupção de setTimeout no meio. Deveria ter um callback setTimeout em aproximadamente 270 ms, mas ele foi atrasado por algum motivo até aproximadamente 320 ms, 50 ms depois do que deveria. No entanto, a grande latência de antecipação manteve o tempo sem problemas, e não perdemos nenhum compasso, mesmo aumentando o tempo pouco antes disso para tocar notas de dezesseis em 240 bpm (além de tempos de bateria e baixo hardcore!)

Também é possível que cada chamada do programador acabe agendando várias notas. Vamos conferir o que acontece se usarmos um intervalo de programação mais longo (250 ms de antecedência, espaçados 200 ms) e um aumento de tempo no meio:

setTimeout() com lookahead longo e intervalos longos.
setTimeout() com lookahead longo e intervalos longos

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

Na prática, você vai querer ajustar o intervalo de programação e o cálculo de antecedência para saber como ele é afetado pelo layout, pela coleta de lixo e por outras coisas que acontecem na linha de execução principal do JavaScript, além de ajustar a granularidade do controle sobre o tempo etc. Se você tiver um layout muito complexo que acontece com frequência, por exemplo, provavelmente vai querer aumentar o cálculo de antecedência. O ponto principal é que queremos que a quantidade de "programação antecipada" 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 tempo. Até o caso acima tem uma sobreposição muito pequena. Portanto, ele não será muito resiliente em uma máquina lenta com um aplicativo da Web complexo. Um bom ponto de partida é provavelmente 100 ms de tempo de "antecipação", com intervalos definidos em 25 ms. Isso ainda pode causar problemas em aplicativos complexos em máquinas com muita latência do sistema de áudio. Nesse caso, aumente o tempo de visão futura. Se você precisar de um controle mais rígido com a perda de alguma resiliência, use um tempo de visão futura mais curto.

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

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

Essa função apenas recebe o tempo atual do hardware de áudio e o compara com o tempo da próxima nota na sequência. Na maioria das vezes*, nesse cenário específico, isso não faz nada, já que não há "notas" de metrônomo aguardando programação. No entanto, quando isso acontece, a nota é programada usando a API Web Audio e avança para a próxima.

A função scheduleNote() é responsável por programar a próxima "nota" do Web Audio a ser tocada. Nesse caso, usei osciladores para emitir sons de bipes em diferentes frequências. Você poderia criar facilmente nós AudioBufferSource e definir os buffers 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 esses osciladores são programados e conectados, o código pode esquecê-los completamente. Eles são iniciados, interrompidos e coletados automaticamente.

O método nextNote() é responsável por avançar para a próxima nota de dezesseis, 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. No entanto, é importante entender que, neste exemplo de programação, não estou acompanhando a "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 ser tocada. Assim, podemos mudar o andamento (ou parar a reprodução) com muita facilidade.

Essa técnica de programação é usada por vários outros aplicativos de áudio na Web, como a Web Audio Drum Machine, o divertido jogo Acid Defender e até exemplos de áudio mais detalhados, como a demonstração de efeitos granulares.

Yet Another Timing System

Como todo bom músico sabe, o que todo aplicativo de áudio precisa é de mais choque, mais timers. Vale a pena mencionar que a maneira certa de fazer a exibição visual é usar um sistema de TEMPORIZAÇÃO TERCEIRO.

Por que, por que, por que, por que precisamos de outro sistema de cronometragem? Ela está sincronizada com a exibição visual, ou seja, a taxa de atualização gráfica, pela API requestAnimationFrame. Para desenhar caixas em nosso exemplo do metrônomo, isso pode não parecer um grande problema, mas à medida que seus gráficos se tornam cada vez mais complexos, torna-se cada vez mais importante usar requestAnimationFrame() para sincronizar com a taxa de atualização visual. Na verdade, é tão fácil de usar desde o início quanto usar setTimeout()! Com gráficos sincronizados muito complexos (por exemplo, a exibição precisa de notas musicais densas à medida que são tocadas em uma notação musical suave), você recebe uma solicitação de notas musicais mais densas com precisão e mais suave.

Acompanhamos os beats na fila no programador:

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

A interação com o tempo atual do 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
}

Novamente, você vai notar que estamos verificando o relógio do sistema de áudio, porque é com ele que queremos sincronizar, já que ele vai tocar as notas, para saber se precisamos desenhar uma nova caixa ou não. Na verdade, não estamos usando os carimbos de data/hora do requestAnimationFrame, já que estamos usando o relógio do sistema de áudio para descobrir onde estamos no tempo.

Claro, eu poderia ter pulado o uso de um callback setTimeout() e colocado meu programador de notas no callback requestAnimationFrame. Assim, voltaríamos a ter dois timers. Isso também é permitido, mas é importante entender que o requestAnimationFrame é apenas um substituto de setTimeout() nesse caso. Você ainda vai precisar da precisão de programação do tempo do Web Audio para as notas reais.

Conclusão

Esperamos que este tutorial tenha ajudado a explicar relógios, cronômetros e como criar um ótimo tempo em aplicativos de áudio da Web. Essas mesmas técnicas podem ser extrapoladas facilmente para criar players de sequência, drum machines e muito mais. Até a próxima…