import matplotlib.pyplot as plt
import numpy as np
import soundfile as sf
from IPython.display import Audio
# Fonte do arquivo de som: https://freesound.org/people/CVLTIV8R/sounds/800700/
Compressão sem perdas em áudio
As redes de streaming dependem do uso eficiente de dados - se não pela velocidade de conexão, ao menos pela necessidade de economizar planos de dados. Por esse motivo, a compressão de áudio foi um grande tema de pesquisa até que ela culminou na invenção da MP3. Apesar da MP3 já ter algumas décadas de existência, não há hoje nenhuma tecnologia que supere a taxa de compressão e a qualidade obtida por ela.
Áudio analógico para digital
Um som, tocando ao vivo, vem de uma onda sonora. Uma onda sonora é uma pequena oscilação na pressão do ar, que se propaga como as ondas do mar. A onda sonora agita nossos tímpanos, que estimulam uma série de mecanismos internos do ouvido, e, depois, isso se converte em um sinal elétrico que vai para nosso cérebro. Por fim, nosso cérebro interpreta aquele estímulo como um "som".
Você provavelmente já percebeu que nós não transmitimos ondas sonoras através da Internet. Ao invés disso, transmitimos bits. Então, para que um "som" apareça no seu computador ou no seu celular, ele deve antes ser convertido em uma cadeia de bits.
Essa conversão acontece através de um processo chamado amostragem. No processo de amostragem, as pequenas oscilações de pressão do ar que se propagam antes de se tornar "som" são convertidas em sinais elétricos através de um sensor chamado microfone. Depois disso, os sinais elétricos são convertidos para bytes através de um periférico chamado conversor analógico-digital.
O conversor analógico-digital (ou: ADC, de analog-digital converter) opera tirando pequenas "fotos" do valor de tensão recebido em sua entrada. Daí, começamos a ter várias "fotos" que vamos chamar de \(x_1\), \(x_2\), etc., da seguinte forma:
Então, nosso sinal de áudio, que estava originalmente oscilando pelo ar, passou a ser representado como uma sequência de números. Bem, se é uma sequência de números, podemos representá-lo como um vetor \(x_t\):
Taxa de Amostragem
A frequência de "fotos" que tiramos de nosso sinal é chamada de Taxa de Amostragem, ou Sampling Frequency. Ela funciona de modo análogo ao frame rate em jogos. Cada uma das "fotos" é chamada de "amostra". Claro que gostaríamos de taxas de amostragem mais altas, mas isso também implicaria em ter muito mais amostras para armazenar, da mesma forma que gostamos de fotos com mais megapixels ou filmes com mais quadros por segundo. Por outro lado, se tivermos poucas amostras, vamos começar a ouvir artefatos, que são análogos a quando começamos ver fotos de baixa resolução (pixeladas) ou um filme a poucos quadros por segundo e passamos a ver os quadros isolados ao invés de uma sequência contínua.
Se minha taxa de amostragem é de 100 Hz e eu tenho 300 amostras, quantos segundos tem minha gravação?
São 100 amostras por segundo, e, portanto, 300 amostras correspondem a 3 segundos.
Se minha taxa de amostragem é de 48kHz, quantas amostras deve ter uma gravação de 1.5 segundos?
Em 1 segundo, tenho 48 mil amostras. Em 1.5 segundo, tenho 48+24=76 mil amostras.
Abrindo um arquivo de áudio
Um arquivo com dados de áudio tem tipicamente duas partes: um cabeçalho com informações gerais e um segmento de dados (o maior segmento) que tem as amostras \(x_1\), \(x_2\), etc.
Podemos ouvir nossa gravação:
fname = 'guitar.wav'
Audio(filename=fname)
Para abrir um arquivo de áudio, tipicamente usamos uma biblioteca como soundfile. A função sf.info encontra as informações do cabeçalho de um arquivo de áudio:
info = sf.info(fname)
print(info)
Taxas de amostragem e canais
Veja que, nesse caso, temos uma taxa de amostragem de 96kHz e 2 canais. Os canais correspondem ao som que deve ser colocado em cada um dos "canais de saída". Neste caso, temos 2 canais, isto é, trata-se de um som estéreo. Poderíamos ter, também, sons mono (1 canal) ou multi-canal, que são feitos para equipamentos como home theaters.
Vamos agora ler os conteúdos do arquivo de áudio. Para iso, usamos sf.read, que retorna duas variáveis:
x, que é o conteúdo das amostras gravadas, esr, que contém a taxa de amostragem da gravação:
x, sr = sf.read(fname)
print(sr)
Veja como sr corresponde à taxa de amostragem que foi informada em sf.info!
Vamos agora analisar a variável x:
print(type(x))
print(x.shape)
A variável x, portanto, é um np.array com muitas linhas, e duas colunas. Cada uma das colunas corresponde a um canal, e cada uma das linhas corresponde a uma amostra. Mas, por que temos esse número exato de amostras? Veja só:
print(info.samplerate * info.duration)
Isso se parece com uma conta que você já fez, não?
Plotando um arquivo de áudio
Até o momento, temos um sinal de áudio de dois canais. Eles correspondem ao canal da esquerda (\(x^(e)\)) e da direita (\(x^{(d)}\)) isto é:
Gostaríamos de trabalhar com um canal único, porque o processamento que vamos fazer é estritamente o mesmo para ambos os canais. Uma maneira de fazer isso é simplesmente escolher o primeiro canal:
xe = x[:,0]
E agora, podemos mostrar nossa onda sonora:
t = np.arange(len(xe)) / sr
plt.figure(figsize=(7,3))
plt.plot(t, xe)
plt.xlabel('Tempo (s)')
plt.ylabel('Magnitude')
plt.show()
Quantização
Lembre-se que nosso assunto principal aqui é a compressão. Vamos trabalhar com a compressão sem perdas em áudio. Para isso, vamos começar com a seguinte ideia: vamos quantizar nossas amostras, isto é, vamos transformá-las todas em números inteiros num intervalo. Ao quantizar as amostras, passamos a ter um vocabulário finito de valores possíveis para nossas amostras.
Se temos um vocabulário finito de valores sendo usados para representar cada amostra, isso significa que cada um desses valores pode corresponder a um símbolo (\(s_1\), \(s_2\), etc.) de um vocabulário. Mas, veja só na figura como os valores próximos de zero são muito mais comuns que os valores mais distantes de zero.
Se temos uma sequência de símbolos na qual alguns símbolos ocorrem muito mais vezes que outros, qual algoritmo podemos usar para comprimir a sequência?
Podemos usar o Código de Huffman!
Então, podemos começar nosso processo de compressão com a função:
def quantizar(x : np.ndarray, n : int):
x = x - x.min()
x = x / x.max()
x = x * n
x = x.astype(int)
return(x)
Após, aplique o Código de Huffman para comprimir sua cadeia. Qual taxa de compressão você conseguiu?
Perguntas
- O que acontece com a qualidade do som quando quantizamos a onda sonora em menos níveis?
- À partir de quantos níveis de quantização passa a ser mais difícil perceber a diferença de qualidade sonora?
- O que acontece com a taxa de compressão quando quantizamos a onda sonora em menos níveis? Por que isso acontece?
- Uma aplicação da compressão de áudio é transmitir sons de fala usando a menor largura de banda (ou: o menor número de bits) possível. Grave uma mensagem de fala (em segredo) e quantize em 2 níveis. O som deve ter ficado ininteligível. À partir de quantos níveis de quantização já é possível entender o que está sendo dito (mesmo que em baixíssima qualidade)? Qual é a taxa de compressão correspondente?