Como desenvolver áudio de jogos com a API de áudio da Web

Introdução

O áudio é uma grande parte do que torna as experiências multimídia tão atraentes. Se você já tentou assistir a um filme com o som desligado, provavelmente deve ter notado isso.

Os jogos não são exceção! Minhas melhores lembranças são as músicas e os efeitos sonoros. Agora, em muitos casos, quase duas décadas depois de tocar meus favoritos, ainda não consigo tirar da cabeça as composições Zelda de Koji Kondo e a trilha sonora atmosférica Diablo de Matt Uelmen. O mesmo efeito se aplica aos efeitos sonoros, como as respostas de cliques em blocos instantaneamente reconhecíveis do Warcraft e amostras dos clássicos da Nintendo.

O áudio do jogo apresenta alguns desafios interessantes. Para criar uma música convincente, os designers precisam se ajustar ao estado de jogo potencialmente imprevisível em que o jogador se encontra. Na prática, partes do jogo podem continuar por uma duração desconhecida, os sons podem interagir com o ambiente e se misturar de maneiras complexas, como efeitos de ambiente e posicionamento relativo. Por fim, pode haver um grande número de sons tocando ao mesmo tempo, e todos eles precisam ter um som bom juntos e serem renderizados sem introduzir penalidades de performance.

Áudio de jogos na Web

Para jogos simples, usar a tag <audio> pode ser suficiente. No entanto, muitos navegadores oferecem implementações ruins, o que resulta em falhas de áudio e alta latência. Esperamos que esse seja um problema temporário, já que os fornecedores estão trabalhando duro para melhorar as respectivas implementações. Para ter uma visão do estado da tag <audio>, há um bom pacote de testes em areweplayingyet.org (link em inglês).

No entanto, ao analisar mais profundamente a especificação da tag <audio>, fica claro que há muitas coisas que simplesmente não podem ser feitas com ela, o que não é surpresa, já que foi projetado para reprodução de mídia. Algumas limitações incluem:

  • Não é possível aplicar filtros ao sinal de som
  • Não há como acessar os dados PCM brutos
  • Nenhum conceito de posição e direção de fontes e ouvintes
  • Não há um cronograma refinado.

No resto do artigo, vamos nos aprofundar em alguns desses tópicos no contexto do áudio de jogos escrito com a API Web Audio. Para uma breve introdução a essa API, confira o tutorial para iniciantes.

Músicas de fundo

Os jogos geralmente têm música de fundo tocando em repetição.

Pode ser muito irritante se seu loop for curto e previsível. Se um jogador estiver preso em uma área ou nível e a mesma amostra estiver sendo reproduzida continuamente em segundo plano, pode valer a pena esmaecer a faixa gradualmente para evitar mais frustrações. Outra estratégia é ter misturas de várias intensidades que gradualmente se alternam entre si, dependendo do contexto do jogo.

Por exemplo, se o jogador estiver em uma zona com uma batalha épica contra um chefe, você poderá ter várias combinações que variam de atencional, de previsão a intensa. O software de síntese de música geralmente permite exportar vários mixes (com a mesma duração) com base em uma peça. Basta escolher o conjunto de faixas a serem usadas na exportação. Dessa forma, você tem consistência interna e evita transições desagradáveis conforme você faz a transição de uma faixa para outra.

Banda de garagem

Em seguida, usando a API Web Audio, você pode importar todas essas amostras usando algo como a classe BufferLoader pelo XHR. Isso é abordado em mais detalhes no artigo introdutório sobre a API Web Audio (links em inglês). Carregar sons leva tempo. Por isso, os recursos usados no jogo precisam ser carregados no carregamento da página, no início da fase ou talvez de forma incremental enquanto o jogador está jogando.

Em seguida, crie uma origem para cada nó e um nó de ganho para cada fonte e conecte o grafo.

Depois de fazer isso, você pode reproduzir todas essas fontes simultaneamente em um loop e, como todas têm a mesma duração, a API Web Audio garante que elas permanecerão alinhadas. À medida que o personagem se aproxima ou mais longe da batalha final contra o chefe, o jogo pode variar os valores de ganho para cada um dos respectivos nós na cadeia, usando um algoritmo de ganho, como este:

// Assume gains is an array of AudioGainNode, normVal is the intensity
// between 0 and 1.
var value = normVal - (gains.length - 1);
// First reset gains on all nodes.
for (var i = 0; i < gains.length; i++) {
    gains[i].gain.value = 0;
}
// Decide which two nodes we are currently between, and do an equal
// power crossfade between them.
var leftNode = Math.floor(value);
// Normalize the value between 0 and 1.
var x = value - leftNode;
var gain1 = Math.cos(x - 0.5*Math.PI);
var gain2 = Math.cos((1.0 - x) - 0.5*Math.PI);
// Set the two gains accordingly.
gains[leftNode].gain.value = gain1;
// Check to make sure that there's a right node.
if (leftNode < gains.length - 1) {
    // If there is, adjust its gain.
    gains[leftNode + 1].gain.value = gain2;
}

Na abordagem acima, duas fontes são reproduzidas ao mesmo tempo, e fazemos o fading cruzado entre elas usando curvas de potências iguais (conforme descrito na introdução).

Atualmente, muitos desenvolvedores de jogos usam a tag <audio> para a música de fundo, porque ela é adequada para streaming de conteúdo. Agora é possível trazer o conteúdo da tag <audio> para um contexto do Web Audio.

Essa técnica pode ser útil, já que a tag <audio> pode funcionar com conteúdo de streaming, o que permite tocar imediatamente a música de fundo, em vez de ter que esperar o download de tudo. Ao trazer o stream para a API Web Audio, você pode manipular ou analisar o stream. O exemplo a seguir aplica um filtro de passagem de baixa frequência às músicas tocadas pela tag <audio>:

var audioElement = document.querySelector('audio');
var mediaSourceNode = context.createMediaElementSource(audioElement);
// Create the filter
var filter = context.createBiquadFilter();
// Create the audio graph.
mediaSourceNode.connect(filter);
filter.connect(context.destination);

Para conferir uma discussão mais completa sobre como integrar a tag <audio> com a API Web Audio, consulte este breve artigo.

Efeitos sonoros

Os jogos geralmente reproduzem efeitos sonoros em resposta a entradas do usuário ou mudanças no estado do jogo. No entanto, assim como a música de fundo, os efeitos sonoros podem ser irritantes rapidamente. Para evitar isso, muitas vezes é útil ter um conjunto de sons parecidos, mas diferentes. Isso pode variar de pequenas variações de amostras de passos a variações drásticas, como vistas na série Warcraft em resposta aos cliques em unidades.

Outra característica importante dos efeitos sonoros em jogos é que pode haver muitos deles simultaneamente. Imagine que você está no meio de uma briga com vários atores atirando em metralhadoras. Cada metralhadora é disparada muitas vezes por segundo, e dezenas de efeitos sonoros são tocados ao mesmo tempo. Reproduzir sons de várias fontes com marcação de tempo precisa ao mesmo tempo é um dos lugares em que a API Web Audio se destaca.

O exemplo a seguir cria um arredondamento de metralhadora a partir de várias amostras de bala individuais, criando várias fontes de som cuja reprodução é escalonada no tempo.

var time = context.currentTime;
for (var i = 0; i < rounds; i++) {
    var source = this.makeSource(this.buffers[M4A1]);
    source.noteOn(time + i - interval);
}

Se todas as metralhadoras do seu jogo soam exatamente assim, seria bem chato. É claro que eles variam por som com base na distância do alvo e na posição relativa (falaremos mais sobre isso mais tarde), mas até mesmo isso pode não ser suficiente. Felizmente, a API Web Audio fornece uma maneira fácil de ajustar o exemplo acima de duas maneiras:

  1. Com uma mudança sutil no tempo entre o disparo dos marcadores
  2. Mudando a taxa de reprodução de cada amostra (também mudando o tom) para simular melhor a aleatoriedade do mundo real.

Para ver um exemplo mais real dessas técnicas em ação, confira a demonstração da tabela de sinuca, que usa amostragem aleatória e varia a taxa de reprodução para um som de colisão mais interessante.

Som posicional 3D

Os jogos geralmente são ambientados em um mundo com algumas propriedades geométricas, seja em 2D ou 3D. Se esse for o caso, o áudio em estéreo poderá aumentar muito a imersão da experiência. Felizmente, a API Web Audio vem com recursos de áudio posicional acelerados por hardware que são muito simples de usar. Aliás, verifique se você tem alto-falantes estéreo (de preferência fones de ouvido) para que o exemplo a seguir faça sentido.

No exemplo acima, há um listener (ícone de pessoa) no meio da tela, e o mouse afeta a posição da origem (ícone de alto-falante). Confira acima um exemplo simples de uso do AudioPannerNode para conseguir esse tipo de efeito. A ideia básica do exemplo acima é responder ao movimento do mouse definindo a posição da fonte de áudio, da seguinte maneira:

PositionSample.prototype.changePosition = function(position) {
    // Position coordinates are in normalized canvas coordinates
    // with -0.5 < x, y < 0.5
    if (position) {
    if (!this.isPlaying) {
        this.play();
    }
    var mul = 2;
    var x = position.x / this.size.width;
    var y = -position.y / this.size.height;
    this.panner.setPosition(x - mul, y - mul, -0.5);
    } else {
    this.stop();
    }
};

Informações importantes sobre o tratamento de espacialização do Web Audio:

  • O listener está na origem (0, 0, 0) por padrão.
  • As APIs posicionais do Web Audio não têm unidades, então introduzi um multiplicador para melhorar o som da demonstração.
  • O Web Audio usa as coordenadas cartesianas y-is-up (o oposto da maioria dos sistemas gráficos de computador). É por isso que estou trocando o eixo y no snippet acima

Avançado: cones de som

O modelo posicional é muito avançado e muito avançado, amplamente baseado no OpenAL. Para mais detalhes, consulte as seções 3 e 4 da especificação vinculada acima.

Modelo de posição

Há um único AudioListener anexado ao contexto da API Web Audio, que pode ser configurado no espaço pela posição e orientação. Cada fonte pode ser transmitida por um AudioPannerNode, que espacializa o áudio de entrada. O nó panorâmico tem posição e orientação, além de um modelo direcional e de distância.

O modelo de distância especifica a quantidade de ganho, dependendo da proximidade à origem, enquanto o modelo direcional pode ser configurado especificando um cone interno e externo, que determinam a quantidade de ganho (geralmente negativo) se o listener estiver dentro do cone interno, entre o cone interno e externo ou fora do cone externo.

var panner = context.createPanner();
panner.coneOuterGain = 0.5;
panner.coneOuterAngle = 180;
panner.coneInnerAngle = 0;

Embora meu exemplo seja em 2D, este modelo é facilmente generalizado para a terceira dimensão. Para conferir um exemplo de som espacializado em 3D, consulte esta amostra posicional. Além da posição, o modelo de som do Web Audio também inclui opcionalmente a velocidade para mudanças de doppler. Este exemplo mostra o efeito doppler em mais detalhes.

Para mais informações sobre esse tópico, leia este tutorial detalhado sobre [como misturar áudio posicional e WebGL][webgl].

Efeitos e filtros para os cômodos

Na realidade, a maneira como o som é percebido depende muito do ambiente em que ele é ouvido. Um barulho de porta aberta soa muito diferente em um porão de um grande salão aberto. Jogos com alto valor de produção vão querer imitar esses efeitos, já que criar um conjunto separado de amostras para cada ambiente é extremamente caro e levaria a ainda mais recursos e uma quantidade maior de dados do jogo.

Em termos simples, o termo de áudio para a diferença entre o som bruto e o que ele soa na realidade é a resposta ao impulso. Essas respostas ao impulso podem ser gravadas com muito cuidado. Na verdade, há sites que hospedam muitos desses arquivos pré-gravados de resposta a impulsos (armazenados como áudio) para sua conveniência.

Para mais informações sobre como as respostas a impulsos podem ser criadas usando um determinado ambiente, leia a seção "Configuração de gravação" na parte Convolução das especificações da API Web Audio.

Mais importante para nossos objetivos, a API Web Audio oferece uma maneira fácil de aplicar essas respostas de impulsos aos sons usando o ConvolverNode.

// Make a source node for the sample.
var source = context.createBufferSource();
source.buffer = this.buffer;
// Make a convolver node for the impulse response.
var convolver = context.createConvolver();
convolver.buffer = this.impulseResponseBuffer;
// Connect the graph.
source.connect(convolver);
convolver.connect(context.destination);

Confira também esta demonstração de efeitos de ambiente na página de especificações da API Web Audio, bem como este exemplo que fornece controle sobre a mistura seca (bruta) e molhada (processada com conversor) de um ótimo padrão do jazz.

Contagem regressiva final

Você criou um jogo, configurou o áudio posicional e agora tem um grande número de AudioNodes no gráfico, todos reproduzindo simultaneamente. Ótimo, mas ainda há mais uma coisa a considerar:

Como vários sons ficam empilhados uns sobre os outros sem normalização, é possível que você esteja excedendo o limite da capacidade do alto-falante. Assim como imagens que ultrapassam os limites da tela, os sons também podem ser cortados se a forma de onda exceder o limite máximo, produzindo uma distorção distinta. A forma de onda é mais ou menos assim:

Clipe

Aqui está um exemplo real de recorte na prática. A forma de onda está ruim:

Clipe

É importante ouvir distorções duras, como a acima, ou mixes excessivamente suaves que forçam os ouvintes a aumentar o volume. Se você está nessa situação, é realmente necessário resolver o problema.

Detectar recorte

Do ponto de vista técnico, o recorte acontece quando o valor do sinal em qualquer canal excede o intervalo válido, ou seja, entre -1 e 1. Depois que isso for detectado, é útil dar um feedback visual de que isso está acontecendo. Para fazer isso de maneira confiável, coloque um JavaScriptAudioNode no gráfico. O gráfico de áudio seria configurado da seguinte maneira:

// Assume entire sound output is being piped through the mix node.
var meter = context.createJavaScriptNode(2048, 1, 1);
meter.onaudioprocess = processAudio;
mix.connect(meter);
meter.connect(context.destination);

E o recorte pode ser detectado no seguinte gerenciador processAudio:

function processAudio(e) {
    var buffer = e.inputBuffer.getChannelData(0);

    var isClipping = false;
    // Iterate through buffer to check if any of the |values| exceeds 1.
    for (var i = 0; i < buffer.length; i++) {
    var absValue = Math.abs(buffer[i]);
    if (absValue >= 1) {
        isClipping = true;
        break;
    }
    }
}

Em geral, tenha cuidado para não usar JavaScriptAudioNode em excesso por motivos de desempenho. Nesse caso, uma implementação alternativa de limitação poderia pesquisar um RealtimeAnalyserNode no gráfico de áudio para getByteFrequencyData, no momento da renderização, conforme determinado por requestAnimationFrame. Essa abordagem é mais eficiente, mas perde a maior parte do sinal (incluindo lugares em que possivelmente se recorta), já que a renderização acontece no máximo 60 vezes por segundo, enquanto o sinal de áudio muda muito mais rapidamente.

Como a detecção de clipes é muito importante, é provável que haja um nó integrado da API Web Audio MeterNode no futuro.

Evitar recorte

Ao ajustar o ganho no AudioGainNode mestre, você pode reduzir o mix a um nível que evite o recorte. No entanto, na prática, como os sons que são exibidos no seu jogo podem depender de uma grande variedade de fatores, pode ser difícil decidir o valor do ganho mestre que impede o recorte de todos os estados. Em geral, você precisa ajustar os ganhos para prever o pior caso, mas isso é mais uma arte do que uma ciência.

Adicione um pouco de açúcar

Os compressores costumam ser usados na produção de músicas e jogos para suavizar os picos de sinal e controlar o sinal geral. Essa funcionalidade está disponível no mundo do Web Audio pelo DynamicsCompressorNode, que pode ser inserido no gráfico de áudio para dar um som mais alto, mais rico e completo, além de ajudar no recorte. Citando diretamente a especificação, esse nó

Usar a compactação dinâmica geralmente é uma boa ideia, especialmente em um ambiente de jogo, em que, como discutido anteriormente, você não sabe exatamente quais sons serão reproduzidos e quando. O Plink, dos laboratórios DinahMoe, é um ótimo exemplo disso, já que os sons reproduzidos dependem completamente de você e dos outros participantes. Um compressor é útil na maioria dos casos, exceto em alguns casos raros, em que você está lidando com faixas masterizadas meticulosamente que já foram ajustadas para soar "ideal".

Para implementar isso, basta incluir um DynamicsCompressorNode no gráfico de áudio, geralmente como o último nó antes do destino:

// Assume the output is all going through the mix node.
var compressor = context.createDynamicsCompressor();
mix.connect(compressor);
compressor.connect(context.destination);

Para mais detalhes sobre compactação dinâmica, este artigo da Wikipédia é bastante informativo.

Para resumir, ouça com atenção os recortes e evite-os inserindo um nó de ganho mestre. Depois, aperte toda a mistura usando um nó compressor dinâmico. Seu gráfico de áudio pode ficar mais ou menos assim:

Resultado final

Conclusão

Esses são os aspectos mais importantes do desenvolvimento de áudio em jogos com a API Web Audio. Com essas técnicas, você pode criar experiências de áudio realmente envolventes diretamente no navegador. Antes de sair, vou deixar uma dica específica para o navegador: pause o som se a guia for para o segundo plano usando a API de visibilidade da página. Caso contrário, você criará uma experiência potencialmente frustrante para o usuário.

Para mais informações sobre o Web Audio, consulte o artigo de introdução mais introdutório e, se tiver uma dúvida, confira se ela já foi respondida nas Perguntas frequentes sobre áudio na Web. Por fim, se você tiver outras dúvidas, faça perguntas no Stack Overflow usando a tag web-audio.

Antes de finalizar, vou deixar alguns usos incríveis da API Web Audio em jogos reais hoje: