Estudo de caso: The Sounds of Racer

Introdução

Racer é um experimento do Chrome em vários dispositivos e multiplayer. Um jogo de caça-níqueis em estilo retrô jogado em diferentes telões. Em smartphones ou tablets, Android ou iOS. Qualquer pessoa pode participar. Nenhum app. Nenhum download. Apenas na Web para dispositivos móveis.

Plan8 com nossos amigos em 14islands criou a experiência dinâmica de música e som baseada em uma composição original de Giorgio Moroder. O modo de corrida tem sons responsivos de motores, efeitos sonoros de corrida e, mais importante, um mix dinâmico de música que se distribui por vários dispositivos à medida que os corredores entram. É uma instalação de vários alto-falantes composta de smartphones.

A conexão de vários dispositivos era algo que trabalhávamos há algum tempo. Fizemos experimentos musicais em que o som se dividia em diferentes dispositivos ou pulava entre dispositivos, por isso estávamos ansiosos para aplicar essas ideias ao Racer.

Mais especificamente, queríamos testar se poderíamos criar a faixa de música nos dispositivos à medida que cada vez mais pessoas entravam no jogo, começando com bateria e baixo, adicionando guitarra e sintetizadores e assim por diante. Fizemos algumas demonstrações musicais e aprendemos a 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.

Criação dos sons

O Google Creative Lab havia traçado 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 o alto-falante de saída seria, na maioria dos casos, um alto-falante minúsculo de smartphone ou tablet, de modo que o espectro de frequência dos sons precisaria ser limitado para evitar distorção. Isso provou ser um grande desafio. Quando recebemos os primeiros rascunhos de música de Giorgio, foi um alívio porque a composição dele combinava perfeitamente com os sons que tínhamos criado.

Som do motor

O maior desafio na programação dos sons foi encontrar o melhor som de motor e esculpir seu comportamento. A pista de corrida parecia uma pista de F1 ou Nascar, então os carros tinham que parecer rápidos e explosivos. Ao mesmo tempo, os carros eram muito pequenos, então um grande som de motor não conectava o som aos recursos visuais. Não podíamos ter um motor barulhento tocando no alto-falante móvel, então tivemos que descobrir outra coisa.

Para se inspirar, colocamos a coleção de sintetizadores modulares do nosso amigo Jon Ekstrand e começamos a brincar. Gostamos do que ouvimos. Era assim que parecia com dois osciladores, alguns filtros bonitos e LFO.

O equipamento analógico foi remodelado com grande sucesso usando a API de áudio da Web anteriormente. Por isso, tivemos grandes expectativas e começamos a criar um sintetizador simples para o Web Audio. Um som gerado seria o mais responsivo, mas sobrecarregaria o processamento do dispositivo. Precisávamos ser extremamente enxutos para economizar todos os recursos possíveis para que os recursos visuais funcionassem sem problemas. Por isso, mudamos a técnica para reproduzir amostras de áudio.

Sintetizador modular para inspiração de som de motor.

Várias técnicas podem ser usadas para fazer um motor tocar com base em amostras. A abordagem mais comum para jogos de console seria ter uma camada com vários sons (quanto mais, melhor) do mecanismo em diferentes RPMs (com carga) e depois usar crossfade e crosspitch entre eles. Em seguida, adicione uma camada com vários sons do motor apenas acelerando (sem carga) na mesma RPM e crossfade e crosspitch entre eles. O efeito cruzado entre essas camadas ao mudar de direção, se feito corretamente, soará muito realista, mas somente se você tiver uma grande quantidade de arquivos de som. O cruzamento não pode ser muito largo ou soará muito sintético. Como tivemos que evitar longos tempos de carregamento, essa opção não era boa para nós. Tentamos usar cinco ou seis arquivos de som para cada camada, mas o som era decepcionante. Tivemos que encontrar uma maneira com menos arquivos.

A solução mais eficaz provou ser esta:

  • Um arquivo de som com aceleração e mudança de marcha sincronizada com a aceleração visual do carro, terminando em um loop programado com a inclinação / RPM mais alta. A API de áudio da Web é muito boa em criar repetições precisas, de modo que pudéssemos fazer isso sem falhas ou estalos.
  • Um arquivo de som com desaceleração / mecanismo diminuindo.
  • E, por fim, um arquivo de som tocando o som estático / inativo em loop.

Parece com isto

Imagem de som do mecanismo

Para o primeiro evento de toque / aceleração, reproduziríamos o primeiro arquivo desde o início. Se o player liberasse o limite, calcularíamos o tempo em que estávamos no arquivo de som na liberação para que, quando o limite fosse ativado novamente, ele pularia para o lugar certo no arquivo de aceleração depois que o segundo arquivo (redução) fosse reproduzido.

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);
}

Tente

Ligue o motor e pressione o botão "Acelerador".

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

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

Como fazer a sincronização

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

syncOffset = localTime - serverTime - networkLatency

Com esse deslocamento, cada dispositivo conectado compartilha o mesmo conceito de tempo. Fácil, não é? (Repetindo, 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 ida e volta ao servidor nem sempre é simétrica, ou seja, a solicitação pode demorar mais do que a resposta ou vice-versa. Quanto maior a latência da rede, maior será o impacto dessa assimetria, causando atraso e reprodução dos sons fora de sincronia com outros dispositivos.

Felizmente, nosso cérebro está programado para não perceber se os sons estão um pouco atrasados. Estudos demonstraram que leva um atraso de 20 a 30 milissegundos (ms) antes que nosso cérebro entenda os sons como separados. No entanto, por volta de 12 a 15 ms, você começa a "sentir" os efeitos de um sinal atrasado, mesmo que não consiga "notá-lo". Investigamos alguns protocolos de sincronização de tempo estabelecidos, alternativas mais simples e tentamos implementar alguns deles na prática. No fim, graças à infraestrutura de baixa latência do Google, conseguimos simplesmente criar uma amostra de um burst de solicitações e usar aquela com a latência mais baixa como referência.

Combate ao deslocamento do relógio

Deu certo! Tínhamos mais de cinco dispositivos tocando uma pulsação em perfeita sincronização, mas só por um tempo. Depois de tocar por alguns minutos, os dispositivos se deslocavam, apesar de termos programado o som usando o tempo de contexto altamente preciso da API de áudio da Web. O atraso se acumulou lentamente, somente alguns milissegundos de cada vez, e era indetectável no começo, mas resultou em camadas musicais totalmente fora de sincronia após tocar por mais tempo. Olá, deslocamento do relógio.

A solução era sincronizar novamente a cada poucos segundos, calcular um novo deslocamento do relógio e inseri-lo no programador de áudio. Para reduzir o risco de alterações notáveis na música devido ao atraso da rede, decidimos suavizar a mudança mantendo um histórico dos deslocamentos de sincronização mais recentes e calculando uma média.

Programar a música e trocar de arranjo

Criar uma experiência sonora interativa significa que você não está mais no controle de quando partes da música serão 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 significava que nosso programador precisava calcular quanto resta da barra em reprodução antes de mudar 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 o momento em que a música foi iniciada usando o contexto de Web Audio, considerando syncOffset e o tempo decorrido desde a criação do contexto de áudio.
  • playDelta = Date.now() - syncOffset - songStartTime - context.currentTime
  • Client(n) calcula por quanto tempo a música está em execução usando o playDelta. O programador de músicas usa isso para saber qual barra da organização atual deve ser tocada em seguida.
  • playTime = playDelta + context.currentTime nextBar = Math.ceil((playTime % loopDuration) ÷ barDuration) % numberOfBars

Por uma questão de sanidade, limitamos nossos arranjos para sempre ter oito compassos e ter o mesmo ritmo (batidas por minuto).

Olhe para frente

É sempre importante programar com antecedência ao usar setTimeout ou setInterval em JavaScript. Isso ocorre porque o relógio 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 consideramos o tempo que todos os clientes levam para receber o mesmo evento pela rede.

Sprites de áudio

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

No Android, os elementos de áudio continuam tocando mesmo que você coloque o dispositivo no modo de espera. No modo de espera, a execução do JavaScript é limitada para economizar bateria, e não é possível depender de requestAnimationFrame, setInterval ou setTimeout para disparar callbacks. Esse é um problema, já que os sprites de áudio dependem do JavaScript para verificar se a reprodução deve ser interrompida. Para piorar, em alguns casos, o currentTime do elemento "Áudio" não é atualizado enquanto o áudio ainda está tocando.

Confira a implementação do AudioSprite que usamos no Chrome Racer como substituto de áudio sem Web.

Elemento de áudio

Quando começamos a trabalhar no Racer, o Google Chrome para Android ainda não era compatível com a API Web Audio. A lógica de usar áudio HTML para alguns dispositivos, a API de áudio da Web para outros, combinada com a saída de áudio avançada que queríamos alcançar criou alguns desafios interessantes. Felizmente, isso é tudo agora. A API Web Audio está implementada no Android M28 Beta.

  • Atrasos/problemas de programação. O elemento "Áudio" nem sempre toca exatamente quando você pede. Como o JavaScript tem uma única linha de execução, o navegador pode estar ocupado, causando atrasos de até dois segundos na reprodução.
  • Os atrasos na reprodução significam que um loop contínuo nem sempre é possível. No computador, é possível usar buffer duplo para ter loops um pouco sem lacunas, mas isso não é possível em dispositivos móveis porque:
    • A maioria dos dispositivos móveis não toca mais de um elemento de áudio por vez.
    • Volume fixo. O Android e o iOS não permitem alterar o volume de um objeto de áudio.
  • Sem pré-carregamento. Em dispositivos móveis, o elemento de áudio não começa a carregar a própria origem, a menos que a reprodução seja iniciada em um gerenciador touchStart.
  • Procurar problemas. A recebimento de duration ou a configuração de currentTime falhará, a menos que o servidor ofereça suporte a Byte-Range HTTP. Cuidado com esse caso se você estiver criando um sprite de áudio como fizemos.
  • A autenticação básica em MP3 falha. Alguns dispositivos não carregam arquivos MP3 protegidos por autenticação básica, seja qual for o navegador usado.

Conclusões

Já avançamos muito desde o uso do botão de desativar microfone como 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 ser muito pesado. Abordamos apenas um pouco o que pode ser feito quando se trata de sincronização de vários dispositivos. Não tínhamos o poder de processamento nos celulares e tablets para mergulhar no processamento de sinais e nos efeitos (como reverberação), mas, à medida que o desempenho do dispositivo aumenta, os jogos baseados na Web também aproveitam esses recursos. Estamos empolgando você para continuar ampliando as possibilidades do som.