Back to the blog
C:\BLOG\BATALHA-DE-ASSOBIOS-PITC.TXT

Batalha de assobios: detectando pitch no navegador e pontuando em tempo real

Como o x1biu capta o assobio pelo microfone com a Web Audio API, encontra a frequência por autocorrelação e compara com a música em cents, via WebSocket.

Related project: x1biu

This article is only available in Portuguese.

O x1biu é um projeto declaradamente inútil: uma batalha 1 contra 1 de assobios. Dois jogadores entram na fila, são pareados, a mesma música toca para os dois e cada um assobia acompanhando a melodia. Quem assobiar mais afinado vence e sobe no ranking.

Inútil, mas cheio de problema bom de resolver: captar áudio no navegador, descobrir qual nota a pessoa está assobiando, comparar com a música enquanto ela toca e mostrar a pontuação dos dois ao vivo. Este artigo passa por cada parte.

O caminho de uma partida

  1. Os dois jogadores entram numa fila. Uma tarefa periódica do Celery Beat pareia os dois primeiros a cada poucos segundos.
  2. A partida é criada e os dois navegadores se conectam a um WebSocket do Django Channels.
  3. A música começa ao mesmo tempo para os dois.
  4. Cada navegador detecta o pitch do assobio e manda um frame por vez para o servidor.
  5. O servidor compara cada frame com a referência da música, soma pontos e devolve a pontuação para os dois.
  6. Quando a música termina, o vencedor é definido e o ranking é atualizado.

Ouvindo o microfone

A Web Audio API faz quase todo o trabalho pesado. O getUserMedia pede o microfone, um AnalyserNode expõe as amostras de áudio, e um loop com requestAnimationFrame lê um bloco de amostras a cada quadro de tela.

usePitchDetection.ts
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
const ctx = new AudioContext()
const analyser = ctx.createAnalyser()
analyser.fftSize = 2048
ctx.createMediaStreamSource(stream).connect(analyser)

const buffer = new Float32Array(analyser.fftSize)
function loop() {
  analyser.getFloatTimeDomainData(buffer)
  setHz(detectPitchHz(buffer, ctx.sampleRate))
  rafRef.current = requestAnimationFrame(loop)
}
loop()

Encontrando a nota por autocorrelação

Um som afinado é periódico: a onda se repete. A ideia da autocorrelação é comparar o sinal com uma cópia dele mesmo deslocada no tempo. Quando o deslocamento coincide com o período da onda, as duas cópias quase se sobrepõem. Achou esse deslocamento, achou a frequência: frequência = taxa de amostragem / deslocamento.

detectPitchHz
function detectPitchHz(buffer: Float32Array, sampleRate: number): number | null {
  // descarta frames silenciosos pelo volume médio (RMS)
  let rms = 0
  for (let i = 0; i < buffer.length; i++) rms += buffer[i] * buffer[i]
  if (Math.sqrt(rms / buffer.length) < 0.015) return null

  const half = Math.floor(buffer.length / 2)
  let bestOffset = -1, bestCorr = 0, lastCorr = 1, foundGood = false

  for (let offset = 1; offset < half; offset++) {
    let corr = 0
    for (let i = 0; i < half; i++) corr += Math.abs(buffer[i] - buffer[i + offset])
    corr = 1 - corr / half

    if (corr > 0.9 && corr > lastCorr) {
      foundGood = true
      if (corr > bestCorr) { bestCorr = corr; bestOffset = offset }
    } else if (foundGood) {
      break // passou do pico: o melhor deslocamento já foi encontrado
    }
    lastCorr = corr
  }

  if (bestOffset === -1) return null
  const hz = sampleRate / bestOffset
  return hz >= 500 && hz <= 4000 ? hz : null // faixa realista de um assobio
}

Três decisões fazem essa função funcionar com gente de verdade, num quarto de verdade:

  • Portão de silêncio: abaixo de um volume mínimo, o frame é ignorado. Sem isso, ruído de fundo vira nota.
  • Parar no primeiro pico: o sinal também se parece consigo mesmo em dois, três períodos. Parar depois do primeiro pico bom evita confundir a nota com uma oitava abaixo.
  • Faixa de assobio: assobio humano fica, em geral, entre 500 Hz e 4 kHz. Qualquer coisa fora disso é voz, ruído ou erro, e é descartada.

Pontuando em cents, não em hertz

Comparar frequências em hertz não funciona, porque a percepção de altura é logarítmica: a distância entre 500 e 550 Hz soa muito maior que entre 3000 e 3050 Hz. A medida certa é o cent, um centésimo de semitom; uma oitava tem 1200 cents.

E tem um detalhe musical: quase ninguém assobia na mesma oitava da melodia. Um assobio a 588 Hz para uma referência de 147 Hz é a mesma nota, duas oitavas acima, e precisa pontuar. Por isso o servidor normaliza a razão para dentro de uma oitava antes de medir a distância.

ScoringService · Python
def score_frame(self, player_hz: float, frame_index: int) -> int:
    ref_hz = self._ref.get(frame_index)
    if not ref_hz or player_hz <= 0:
        return 0

    # traz a razão para dentro de uma oitava: mesma nota em outra oitava pontua
    ratio = player_hz / ref_hz
    while ratio >= 2.0:
        ratio /= 2.0
    while ratio < 0.5:
        ratio *= 2.0

    cents = abs(1200 * math.log2(ratio))
    if cents >= 150:
        return 0
    return round(100 - cents * 100 / 150)  # 100 pontos afinado, 0 a um semitom e meio

Tempo real com Django Channels

Cada navegador envia um pitch_frame pelo WebSocket. O consumer do Channels guarda o frame num buffer, repassa o pitch para o grupo da partida (assim cada jogador vê a linha do adversário), calcula os pontos e transmite a pontuação nova para os dois.

MatchConsumer · Django Channels
async def _handle_pitch_frame(self, data: dict):
    hz = float(data.get("hz", 0))
    frame = int(data.get("frame", 0))
    if hz > 0:
        self._pitch_buffer.append({"frame": frame, "hz": round(hz, 1)})

    await self.channel_layer.group_send(
        f"match_{self.match_id}",
        {"type": "pitch.update", "player_id": self.user_id, "hz": hz},
    )

    new_score = await sync_to_async(self._score_and_update)(hz, frame)
    if new_score is not None:
        await self.channel_layer.group_send(
            f"match_{self.match_id}",
            {"type": "score.update", "player_id": self.user_id, "score": new_score},
        )

Dois cuidados aqui. A soma dos pontos usa F("score") + points no banco, para que frames concorrentes não sobrescrevam um ao outro. E o buffer de pitch só é gravado no fim da partida ou na desconexão, uma escrita só em vez de uma por frame; é esse histórico que desenha o gráfico da partida depois.

O que um projeto inútil ensina

  • Processamento de sinal no navegador é acessível: a Web Audio API entrega as amostras prontas.
  • Medir na unidade certa (cents) muda o resultado mais do que um algoritmo mais sofisticado.
  • Tempo real é sobre o que mandar a cada frame e o que gravar só no final.
  • Projeto divertido é o melhor lugar para testar uma stack nova sem pressão de prazo.