O que o Spectro é, e por que ele existe
O Spectro é uma biblioteca TypeScript, publicada no npm como @sophialabs/spectro, que recebe um vetor de áudio mono (um Float32Array, o que o navegador devolve ao decodificar um arquivo) e devolve um <canvas> com o espectrograma desenhado. FFT própria, quatro funções janela, escala linear ou Mel, 83 colormaps do Matplotlib com suas versões invertidas, filtros FIR de pré-processamento, eixos de frequência e de tempo com marcas calculadas, ganho e faixa em decibéis, detecção de pitch e exportação em PNG. Sem nenhuma dependência em tempo de execução.
Ela nasceu de uma necessidade da SophiaLabs: mostrar a imagem de um arquivo de áudio inteiro numa aplicação web, com a mesma aparência toda vez, com controle sobre cada parâmetro e sem puxar uma biblioteca de visualização de áudio que faz vinte coisas e deixa essa uma mal configurável. Publiquei doze versões entre março e setembro de 2025, a maioria nos primeiros dez dias, que é o ritmo de uma biblioteca sendo usada enquanto é escrita. Esse texto é sobre as contas que cada parâmetro esconde e sobre o que cada decisão compra e cobra.
Por que não o AnalyserNode do navegador
A Web Audio API já tem FFT. O AnalyserNode devolve o espectro do que está tocando agora, e é o caminho certo para um visualizador ao vivo. Não é o caminho certo para a imagem de um arquivo inteiro, por três motivos. Ele é amarrado ao relógio do áudio: para desenhar um arquivo de três minutos, é preciso tocá-lo por três minutos, ou acelerar e aceitar que os quadros caem onde o navegador quiser. O tamanho do salto entre quadros não é seu: é a frequência com que o seu laço de animação consegue ler o nó. E a janela é fixa, Blackman, sem escolha. O resultado é uma imagem que depende da máquina, da aba estar em primeiro plano e do que mais estava rodando. Eu queria o oposto: o mesmo vetor de entrada, com os mesmos parâmetros, produz a mesma imagem, pixel a pixel, em qualquer máquina. Isso exige fazer a análise offline, quadro a quadro, com a FFT no meu código.
A pipeline, do vetor ao pixel
- Filtro opcional. Se configurado, um FIR passa-baixa, passa-alta, passa-banda ou rejeita-banda é aplicado ao sinal inteiro antes de qualquer análise.
- Quadros. O sinal é fatiado em janelas de
fftSizeamostras, avançandohopSizeamostras por quadro (padrão: metade da FFT). O número de quadros define a largura nativa da imagem. - Janela. Cada quadro é multiplicado por uma função janela: nenhuma, cosseno, Hanning ou Blackman-Harris de sete termos.
- FFT. Uma FFT radix-2, escrita na própria biblioteca. A magnitude de cada bin vira decibéis.
- Recorte em frequência. Só os bins entre
fMinefMaxentram na imagem. A altura nativa é esse número de bins. - Pico global e normalização. Um passe inteiro pela matriz acha o maior valor em dB. Cada pixel vira um número entre 0 e 1 relativo a esse pico, dentro de uma faixa de
rangeDb. - Colormap. O número entre 0 e 1 vira RGB por uma tabela de 256 cores.
- Canvas fora da tela. A imagem é escrita na resolução nativa (quadros por bins) e depois desenhada, com escala, num canvas do tamanho pedido, com margens para os eixos quando eles estão ligados.
Cada passo tem uma decisão dentro, e algumas custam mais do que parecem.
A janela padrão é BH7, e isso é uma aposta
Multiplicar o quadro por uma janela existe para suavizar as bordas do recorte, porque um recorte abrupto espalha energia por todo o espectro. A escolha da janela é uma troca entre duas coisas: a largura do lóbulo principal, que define quão borrada em frequência a imagem fica, e a altura dos lóbulos laterais, que define o quanto um som forte "vaza" sobre os fracos ao redor.
| Janela | Lóbulo principal | Lóbulos laterais | Na imagem |
|---|---|---|---|
| Nenhuma | O mais estreito | Altos, caem devagar | Linhas finas, mas um tom forte mancha a vizinhança inteira |
| Cosseno | Estreito | Moderados | Compromisso leve, pouco usado |
| Hanning | Médio | Primeiro a cerca de −31 dB | O padrão da maioria das ferramentas; bom para música e fala |
| BH7 | O mais largo | Muito abaixo dos da Hanning | Faixa dinâmica enorme, linhas mais grossas |
Escolhi a Blackman-Harris de sete termos como padrão porque o caso de uso da SophiaLabs tinha sons fracos perto de sons fortes, e o vazamento da Hanning escondia os fracos. O preço é que cada componente de frequência fica mais grossa na imagem, e para quem quer resolução fina, por exemplo para separar dois harmônicos próximos, a Hanning é melhor. É por isso que a janela é parâmetro e não constante: o padrão serve ao meu caso, e o seu pode ser outro.
Uma FFT própria, e o que ela custa
A FFT é radix-2 recursiva: divide o sinal em pares e ímpares, chama a si mesma nas duas metades e combina com os fatores de rotação. Escolhi escrevê-la em vez de depender de uma biblioteca por dois motivos. O primeiro é o requisito de zero dependências: quem instala o Spectro instala o Spectro, e nada muda embaixo dele. O segundo é o determinismo: a mesma entrada produz exatamente a mesma saída, em qualquer navegador, porque não há caminho acelerado que mude a precisão de uma máquina para outra.
O que isso cobra é desempenho. A análise roda de forma síncrona na thread principal, e para um arquivo de alguns segundos ou de um minuto isso é imperceptível, que era o caso de uso. Para arquivos longos, o tempo cresce de forma linear com o número de quadros, e a interface fica congelada enquanto a imagem é calculada. Esse é o limite real da biblioteca, mais do que qualquer parâmetro, e o salto configurável existe em parte por ele: aumentar hopSize reduz o número de quadros na mesma proporção, ao custo de resolução temporal.
A largura nativa da imagem é o número de quadros, e o canvas fora da tela é criado com essa largura. Navegadores limitam o tamanho de um canvas, e o limite em largura fica na casa de algumas dezenas de milhares de pixels. Um arquivo de 30 minutos com salto de 1.024 amostras tem cerca de 77 mil quadros, e isso não cabe. Para arquivos assim, o salto precisa crescer até a imagem caber, e a regra prática é que a largura nativa não deve passar de algumas vezes a largura de destino, porque acima disso o navegador vai descartar colunas ao reduzir de qualquer jeito.
A escala de decibéis e o pico global
A magnitude de cada bin vira decibéis com um piso de 10⁻¹², para o logaritmo de zero não virar menos infinito. Depois, um passe inteiro pela matriz acha o pico global, o maior valor em dB de toda a imagem. Cada pixel é então normalizado assim: tudo que está acima do pico menos rangeDb é mapeado linearmente para 0 a 1, e tudo abaixo vira 0. O padrão de 80 dB cobre a faixa útil de quase todo áudio gravado. Reduzir a faixa aumenta o contraste e esconde o que é fraco; aumentar mostra mais e achata.
Ancorar no pico global é a decisão que torna a imagem estável: dois trechos do mesmo arquivo são comparáveis, porque a referência é a mesma. A alternativa, normalizar cada coluna pelo próprio pico, faz todo quadro parecer cheio e destrói exatamente a informação de intensidade relativa que o espectrograma existe para mostrar. Mas a decisão tem um custo conhecido: um único estalo define a referência do arquivo inteiro. Um clique de 0 dB numa gravação em que o resto fica a −50 dB joga tudo de interessante para a parte escura da faixa. Hoje o jeito é aumentar rangeDb ou aplicar um filtro antes, e nenhum dos dois é a resposta certa. A resposta certa é uma referência configurável, por percentil ou fixa, e é o que entra na próxima versão. É também o que vai dar sentido ao gainDb: com referência no pico, um ganho uniforme entra nos dois lados da conta e se cancela; com referência fixa, ele desloca a imagem como um volume.
Mel é um remapeamento de exibição, não um banco de filtros
Quando scaleType é Mel, a análise é exatamente a mesma: FFT linear, os mesmos bins. O que muda é como as linhas da imagem escolhem de qual bin ler. Para cada linha, a biblioteca calcula o valor Mel correspondente à posição vertical, converte de volta para Hz e pega o bin mais próximo. Isso dá a aparência perceptual, com as frequências baixas ocupando mais espaço, e dá de graça, sem segunda análise. Mas tem duas consequências que vale saber. Nas linhas de baixo, várias linhas consecutivas leem o mesmo bin, e a imagem fica em blocos; a resolução não aumentou, só foi esticada. Nas linhas de cima, linhas consecutivas pulam bins, e componentes que caem nos bins pulados somem da imagem. Um banco de filtros Mel de verdade somaria os bins pulados em vez de ignorá-los. Para olhar, o remapeamento serve, e é o que a maioria das ferramentas de visualização faz. Para extrair features para um modelo, não use essa imagem; calcule o banco de filtros.
Colormaps do Matplotlib, e um acoplamento que vem da primeira versão
Os colormaps são as tabelas do Matplotlib, 83 nomes com 256 cores cada, mais as versões invertidas com sufixo _r. A função partial('viridis') devolve uma função que recebe um número entre 0 e 1 e devolve RGB. Com 256 entradas, a busca é por degrau, sem interpolação, e a diferença é invisível, porque 256 degraus é mais do que a imagem consegue mostrar. Ter as tabelas todas na biblioteca foi uma escolha: quem gera um espectrograma quer o mesmo viridis ou jet que vê no Python, e não uma aproximação. O custo é tamanho: as tabelas são quase todo o pacote.
O parâmetro de colormap é um nome, uma string, e a biblioteca o procura no escopo global. O jeito de usar é expor as funções antes de gerar, com window.hot = partial('hot'). Isso veio da primeira versão, pensada para ser carregada direto num <script> numa página sem módulos, onde é a forma mais simples que existe. Em Angular ou React, é um passo a mais que surpreende quem esperava passar uma função. E impede o empacotador de descartar os colormaps que você não usa, porque todos estão num objeto só. Aceitar uma função como colormap, mantendo a string como atalho, é a mudança de interface que entra na próxima versão maior.
Eixos e exportação
Os eixos só existem se pedidos, e quando existem a biblioteca adiciona margens: 60 pixels à esquerda para os rótulos de frequência, 22 na base para os de tempo. As marcas de frequência seguem a escala em uso, lineares ou em Mel, com a quantidade calculada pela altura se você não fixar. As marcas de tempo usam uma lista de passos "agradáveis", de 50 milissegundos a uma hora, e escolhem o menor que respeita um espaçamento mínimo em pixels, para os rótulos nunca se sobreporem em nenhuma largura. A duração total é quadros vezes salto dividido pela taxa de amostragem.
A exportação em PNG, exportHighResPNG, gera o espectrograma e o desenha num canvas maior, pelo fator pedido. Serve para o que foi feita: um arquivo para relatório ou impressão, em que a imagem da tela ficaria pequena. O que ela não faz é aumentar o detalhe, porque os pixels são os mesmos, interpolados. Para ter mais detalhe de verdade, o caminho é aumentar canvasHeight e targetWidth e exportar em fator 1.
Os filtros FIR, e a conta que define a transição
Os quatro filtros são construídos a partir de um só: um passa-baixa por sinc janelado, com 101 coeficientes e janela de Hamming. O passa-alta é a inversão espectral do passa-baixa (um impulso menos o kernel). O passa-banda é a diferença entre dois passa-baixas. O rejeita-banda é a inversão do passa-banda. É elegante e é padrão de livro. A conta que importa é a largura da transição: com 101 coeficientes e Hamming, a faixa em que o filtro passa de "deixa passar" para "corta" é cerca de 4% da taxa de amostragem, uns 1,7 kHz a 44,1 kHz. Para separar voz de ruído de baixa frequência, serve. Para um notch estreito em 60 Hz, não serve: a transição é trinta vezes maior que a banda que você quer rejeitar. Um kernel maior resolve, ao custo de uma convolução direta que cresce com ele, e tornar o tamanho do kernel um parâmetro é uma das mudanças pequenas que valem a pena.
Pitch e harmônicos: uma medida do arquivo inteiro
A detecção de pitch usa autocorrelação: para cada atraso possível entre o período da frequência máxima e o da mínima, soma o produto do sinal com ele mesmo deslocado, e o atraso de maior soma é o período da fundamental. A extração de harmônicos pega essa fundamental e devolve os múltiplos inteiros até o décimo, parando em fMax. É simples e funciona bem para o que foi feito: uma nota sustentada, um tom de teste, um sinal de um instrumento só.
O trade-off está no que a medida significa. Ela é calculada sobre o vetor inteiro, então devolve um pitch para o arquivo. Para uma frase cantada ou para fala, isso é a média de coisas diferentes e não quer dizer muito. E o custo acompanha: cada atraso é uma passagem pelo sinal, e com fMin em 1 Hz a 44,1 kHz são 44 mil atrasos, o que sobre um arquivo longo custa mais do que o espectrograma inteiro. Apertar fMin e fMax para a faixa do instrumento é o que torna a conta barata e a resposta confiável. A versão por quadro, devolvendo uma curva no tempo, é o que faz sentido para pitch tracking de verdade, e é o próximo passo dessa parte da biblioteca.
O que entra na próxima versão maior
- Análise separada do desenho. Uma função pura que recebe o vetor e devolve a matriz em dB, sem tocar no DOM, e uma função de desenho que recebe a matriz. A primeira passa a rodar num Web Worker, e a interface deixa de congelar; de quebra, passa a rodar em Node para gerar imagens no servidor.
- Referência de normalização configurável, por percentil ou fixa, para o estalo deixar de mandar na imagem e o ganho voltar a ter efeito.
- Colormap como função, com a string como atalho e as tabelas separadas, para o pacote só levar o que você usa.
- Pitch por quadro, devolvendo uma curva no tempo em vez de um número para o arquivo.
Zero dependências, a mesma imagem para a mesma entrada em qualquer máquina, e um canvas na saída. É o contrato que fez a biblioteca valer a pena, e tudo acima é para cumpri-lo em arquivos maiores e em mais ambientes, não para trocá-lo.
Resumo da conversa
- O Spectro transforma um vetor de áudio num espectrograma determinístico no navegador, sem dependências, com janela, salto, escala, faixa dinâmica e colormap sob controle. É o que o
AnalyserNodenão dá. - A BH7 como padrão compra faixa dinâmica e paga resolução. O pico global como referência compra estabilidade e paga vulnerabilidade a um estalo. Mel é remapeamento de exibição, não banco de filtros.
- A FFT própria compra zero dependências e determinismo, e paga em tempo de análise na thread principal; o salto configurável é a alavanca para arquivos longos.
- Colormaps do Matplotlib de verdade, ao custo de tamanho e de um acoplamento com o escopo global que sai na próxima versão maior, junto com a análise em worker e o pitch por quadro.
Igor Nascimento