Estudo de caso: The Sounds of Racer

Introdução

O Racer é um experimento do Chrome para vários jogadores e dispositivos. Um jogo de corrida de carros retrô em várias telas. Em smartphones ou tablets Android ou iOS. Qualquer pessoa pode participar. Nenhum app. Nenhum download. Somente a Web para dispositivos móveis.

A Plan8 e nossos amigos da 14islands criaram a experiência dinâmica de música e som com base em uma composição original de Giorgio Moroder. O Racer tem sons de motor responsivos, efeitos sonoros de corrida, mas, mais importante, um mix de músicas dinâmico que é distribuído em vários dispositivos à medida que os pilotos se juntam. É uma instalação de vários alto-falantes composta por smartphones.

Conectar vários dispositivos era algo que estávamos testando há algum tempo. Já havíamos feito experimentos musicais em que o som se dividia em diferentes dispositivos ou pulava entre eles. Por isso, queríamos aplicar essas ideias ao Racer.

Mais especificamente, queríamos testar se poderíamos criar a faixa de música em todos os dispositivos à medida que mais e mais pessoas entravam no jogo, começando com bateria e baixo, depois adicionando guitarra e sintetizadores, e assim por diante. Fizemos algumas demonstrações de música e mergulhamos na programação. O efeito de vários alto-falantes foi muito gratificante. Não tínhamos toda a sincronização naquele momento, mas quando ouvimos as camadas de som espalhadas pelos dispositivos, sabíamos que estávamos no caminho certo.

Como criar os sons

O Google Creative Lab havia definido uma direção criativa para o som e a música. Queríamos usar sintetizadores analógicos para criar os efeitos sonoros em vez de gravar os sons reais ou recorrer a bibliotecas de som. Também sabíamos que, na maioria dos casos, o alto-falante de saída seria um alto-falante de smartphone ou tablet pequeno. Por isso, os sons precisavam ser limitados no espectro de frequência para evitar distorções. Isso foi um desafio. Quando recebemos os primeiros rascunhos de música de Giorgio, foi um alívio porque a composição dele funcionava perfeitamente com os sons que criamos.

Som do motor

O maior desafio na programação dos sons foi encontrar o melhor som do motor e esculpir o comportamento dele. A pista de corrida se assemelhava a uma de F1 ou Nascar, então os carros precisavam ser rápidos e explosivos. Ao mesmo tempo, os carros eram muito pequenos, então um som de motor grande não conectava o som aos recursos visuais. Não podíamos ter um motor potente e barulhento tocando no alto-falante do smartphone. Por isso, tivemos que pensar em outra coisa.

Para ter inspiração, conectamos alguns sintetizadores modulares do nosso amigo Jon Ekstrand e começamos a brincar. Gostamos do que ouvimos. É assim que soa com dois osciladores, alguns filtros e LFO.

Já havíamos remodelado equipamentos analógicos com muito sucesso usando a API Web Audio, então tínhamos grandes esperanças e começamos a criar um sintetizador simples na Web Audio. Um som gerado seria o mais responsivo, mas sobrecarregaria a capacidade de processamento do dispositivo. Precisávamos ser extremamente enxutos para economizar todos os recursos possíveis para que os recursos visuais funcionassem sem problemas. Então, mudamos a técnica para reproduzir amostras de áudio.

Sintetizador modular para inspiração no som do motor

Há várias técnicas que podem ser usadas para fazer um motor soar com amostras. A abordagem mais comum para jogos de console é ter uma camada de vários sons (quanto mais, melhor) do motor em diferentes RPMs (com carga) e, em seguida, fazer crossfade e crosspitch entre eles. Em seguida, adicione uma camada de vários sons do motor apenas acelerando (sem carga) na mesma RPM e faça um crossfade e crosspitch entre os dois. Se feito corretamente, o crossfade entre essas camadas ao mudar de marcha vai soar muito realista, mas apenas se você tiver uma grande quantidade de arquivos de som. O crosspitching não pode ser muito amplo, ou vai soar muito sintético. Como precisávamos evitar tempos de carregamento longos, essa opção não era boa para nós. Tentamos usar cinco ou seis arquivos de som para cada camada, mas o som não ficou bom. Tivemos que encontrar uma maneira de usar menos arquivos.

A solução mais eficaz foi esta:

  • Um arquivo de som com aceleração e troca de marcha sincronizados com a aceleração visual do carro, terminando em um loop programado no tom / RPM mais alto. A API Web Audio é muito boa em fazer loops com precisão, então conseguimos fazer isso sem falhas ou ruídos.
  • Um arquivo de som com desaceleração / motor em marcha lenta.
  • E, por fim, um arquivo de som que reproduz o som de inatividade / imagem em loop.

Fica assim

Gráfico do som do motor

Para o primeiro evento de toque / aceleração, tocamos o primeiro arquivo desde o início. Se o jogador soltasse o acelerador, calcularíamos o tempo a partir do ponto em que estávamos no arquivo de som na liberação. Assim, quando o acelerador voltasse a ser acionado, ele saltaria para o lugar certo no arquivo de aceleração depois que o segundo arquivo (de redução de velocidade) fosse tocado.

function throttleOn(throttle) {
    //Calculate the start position depending 
    //on the current amount of throttle.
    //By multiplying throttle we get a start position 
    //between 0 and 3 seconds.
    var startPosition = throttle * 3;

    var audio = context.createBufferSource();
    audio.buffer = loadedBuffers["accelerate_and_loop"];

    //Sets the loop positions for the buffer source.
    audio.loopStart = 5;
    audio.loopEnd = 9;

    //Starts the buffer source at the current time
    //with the calculated offset.
    audio.start(context.currentTime, startPosition);
}

Faça um teste

Ligue o motor e pressione o botão "Aceleração".

<input type="button" id="playstop" value = "Start/Stop Engine" onclick='playStop()'>
<input type="button" id="throttle" value = "Throttle" onmousedown='throttleOn()' onmouseup='throttleOff()'>

Então, com apenas três arquivos de som pequenos e um bom mecanismo de som, decidimos passar para o próximo desafio.

Como fazer a sincronização

Em parceria com David Lindkvist, da 14islands, começamos a analisar melhor como fazer com que os dispositivos tocassem em perfeita sincronia. A teoria básica é simples. O dispositivo solicita o horário ao servidor, considera a latência da rede e calcula o deslocamento do relógio local.

syncOffset = localTime - serverTime - networkLatency

Com esse ajuste, cada dispositivo conectado compartilha o mesmo conceito de tempo. Fácil, não é? (Novamente, na teoria.)

Como calcular a latência da rede

Podemos presumir que a latência é metade do tempo que leva para solicitar e receber uma resposta do servidor:

networkLatency = (receivedTime - sentTime) × 0.5

O problema com essa suposição é que a viagem de ida e volta ao servidor não é sempre simétrica, ou seja, a solicitação pode levar mais tempo do que a resposta ou vice-versa. Quanto maior a latência da rede, maior será o impacto dessa assimetria, causando atrasos e reprodução de sons fora de sincronia com outros dispositivos.

Felizmente, nosso cérebro não percebe se os sons estão um pouco atrasados. Estudos mostram que leva de 20 a 30 milissegundos (ms) para que o cérebro perceba os sons como separados. No entanto, em cerca de 12 a 15 ms, você vai começar a "sentir" os efeitos de um sinal atrasado, mesmo que não consiga "perceber" totalmente. Investigamos alguns protocolos de sincronização de tempo estabelecidos, alternativas mais simples e tentamos implementar alguns deles na prática. No final, graças à infraestrutura de baixa latência do Google, conseguimos simplesmente amostrar uma explosão de solicitações e usar a amostra com a menor latência como referência.

Como evitar o desvio do relógio

Funcionou! Tivemos mais de cinco dispositivos executando um pulso em perfeita sincronização, mas apenas por um tempo. Depois de alguns minutos, os dispositivos se afastavam, mesmo que o som fosse programado usando o tempo de contexto altamente preciso da API Web Audio. O atraso se acumulou lentamente, apenas alguns milissegundos por vez, e não foi detectável no início, mas resultou em camadas musicais totalmente fora de sincronia após a reprodução por períodos mais longos. Olá, deslocamento de relógio.

A solução foi sincronizar novamente a cada poucos segundos, calcular um novo deslocamento de relógio e alimentá-lo perfeitamente no programador de áudio. Para reduzir o risco de mudanças perceptíveis na música devido ao atraso da rede, decidimos suavizar a mudança mantendo um histórico dos últimos deslocamentos de sincronização e calculando uma média.

Como programar músicas e alternar entre elas

Criar uma experiência de som interativa significa que você não tem mais controle sobre quando as partes da música vão ser tocadas, já que depende das ações do usuário para mudar o estado atual. Tivemos que garantir que pudéssemos alternar entre os arranjos da música em tempo hábil, o que significa que nosso programador precisava calcular quanto tempo restava da barra que estava sendo reproduzida antes de alternar para o próximo arranjo. Nosso algoritmo ficou mais ou menos assim:

  • Client(1) inicia a música.
  • Client(n) pergunta ao primeiro cliente quando a música foi iniciada.
  • Client(n) calcula um ponto de referência para quando a música foi iniciada usando o contexto do Web Audio, considerando o syncOffset e o tempo decorrido desde a criação do contexto de áudio.
  • playDelta = Date.now() - syncOffset - songStartTime - context.currentTime
  • Client(n) calcula quanto tempo a música está tocando usando o playDelta. O agendador de músicas usa isso para saber qual barra no arranjo atual deve ser tocada em seguida.
  • playTime = playDelta + context.currentTime nextBar = Math.ceil((playTime % loopDuration) ÷ barDuration) % numberOfBars

Para facilitar, limitamos nossos arranjos a oito compassos e o mesmo andamento (batidas por minuto).

Olhe para frente

É sempre importante programar com antecedência ao usar setTimeout ou setInterval no JavaScript. Isso ocorre porque o relógio do JavaScript não é muito preciso, e os callbacks programados podem ser facilmente distorcidos por dezenas de milissegundos ou mais por layout, renderização, coleta de lixo e XMLHTTPRequests. No nosso caso, também tivemos que considerar o tempo que todos os clientes levam para receber o mesmo evento pela rede.

Sprites de áudio

Combinar sons em um arquivo é uma ótima maneira de reduzir as solicitações HTTP, tanto para o HTML Audio quanto para a API Web Audio. Também é a melhor maneira de tocar sons de forma responsiva usando o objeto Audio, já que não é necessário carregar um novo objeto de áudio antes da reprodução. Já existem algumas implementações boas que usamos como ponto de partida. Ampliamos nosso sprite para funcionar de maneira confiável no iOS e no Android, além de lidar com alguns casos estranhos em que os dispositivos entram em suspensão.

No Android, os elementos de áudio continuam tocando mesmo se você colocar o dispositivo no modo de suspensão. No modo de suspensão, a execução do JavaScript é limitada para preservar a bateria, e você não pode usar requestAnimationFrame, setInterval ou setTimeout para disparar callbacks. Isso é um problema, já que os sprites de áudio dependem do JavaScript para continuar verificando se a reprodução precisa ser interrompida. Para piorar a situação, em alguns casos, o currentTime do elemento de áudio não é atualizado, embora o áudio ainda esteja sendo reproduzido.

Confira a implementação do AudioSprite que usamos no Chrome Racer como uma alternativa para áudios que não são da Web.

Elemento de áudio

Quando começamos a trabalhar no Racer, o Chrome para Android ainda não oferecia suporte à API Web Audio. A lógica de usar o HTML Audio para alguns dispositivos, a API Web Audio para outros, combinada com a saída de áudio avançada que queríamos alcançar, criou alguns desafios interessantes. Felizmente, isso já é passado. A API Web Audio foi implementada na versão Beta do Android M28.

  • Atrasos/problemas de tempo. O elemento de áudio nem sempre é reproduzido exatamente quando você diz para ele ser reproduzido. Como o JavaScript usa um único encadeamento, o navegador pode estar ocupado, causando atrasos de reprodução de até dois segundos.
  • Os atrasos na reprodução significam que nem sempre é possível fazer loops suaves. No computador, é possível usar o buffer duplo para conseguir loops sem lacunas, mas isso não é uma opção em dispositivos móveis porque:
    • A maioria dos dispositivos móveis não reproduz mais de um elemento de áudio por vez.
    • Volume fixo. Nem o Android nem o iOS permitem que você mude o volume de um objeto de áudio.
  • Sem pré-carregamento. Em dispositivos móveis, o elemento de áudio não vai começar a carregar a fonte, a menos que a reprodução seja iniciada em um gerenciador touchStart.
  • Procurar problemas. A obtenção de duration ou a configuração de currentTime falhará, a menos que o servidor ofereça suporte a HTTP Byte-Range. Preste atenção se você estiver criando um sprite de áudio como fizemos.
  • A autenticação básica no MP3 falha. Alguns dispositivos não carregam arquivos MP3 protegidos por autenticação básica, não importa qual navegador você está usando.

Conclusões

Já percorremos um longo caminho desde que o botão de silenciar se tornou a melhor opção para lidar com o som na Web, mas isso é só o começo, e o áudio da Web está prestes a bombar. Isso é só a ponta do iceberg do que pode ser feito em relação à sincronização de vários dispositivos. Não tínhamos a capacidade de processamento nos smartphones e tablets para mergulhar no processamento de sinais e efeitos (como reverberação), mas, à medida que o desempenho do dispositivo aumenta, os jogos baseados na Web também aproveitam esses recursos. Estes são tempos empolgantes para continuar avançando nas possibilidades do som.