Tocando o Som de Uma Onda Quadrada
Preenchendo o Som
Queremos preencher um Buffer circular para tocar 1 segundo de áudio.
Isso significa que quando preenchermos uma região e ela atingir o final do buffer, iremos retornar ao início e começar a preencher até o limite determinado que seria o cursor de ‘play’ corrente.
Esse procedimento pode ser feito com o método IDirectSoundBuffer::Lock
.
Se estivermos próximo do fim do buffer, O método Lock poderá nos fornecer 2 regiões, 1 para o final e outro para o início do Buffer. Agora, se for possível travar o Buffer em uma única região por haver espaço suficiente ele o fará.
Declarando propriedades
Primeiro vamos definir as propriedades importantes para termos um som tocável.
1
2
3
4
5
6
7
8
9
10
s32 hz = 256;
s16 volume = 2000;
s32 samples_per_second = 48000; // sample rate
s32 square_wave_period = samples_per_second / hz; // sample in one period
s32 bytes_per_sample = sizeof(s16) * 2; // 16 bit depth * 2 channels
size_t buffer_size = bytes_per_sample * samples_per_second; // 192k bytes por segundo
u32 sample_index = 0;
win32_load_dsound(window, samples_per_second, buffer_size);
bool sound_playing = false;
hz:
A frequência de uma única nota musical como DÓ (C) dentro de um único ciclo (hz). Ou seja, o som de dó matemáticamente é uma frequência de 256hz = 1/256;
HERTZ: Hertz é unidade de medida de ciclos por segundo. E um ciclo é contado quando uma onde se repete de um pico para ou outro como onda senoide ou quadrada. A nota de DÓ corresponde à 261hz. Ou seja, 261 ciclos em um segundo irá soar um DÓ.
volume:
A altura da onda, isto é, o volume.
sample_per_second:
Sample Rate é a taxa de amostragem universal, medida em ciclos por segundo. 48Khz é uma valor padrão assim como em CDs é 44,1khz.
Essa taxa de amostragem é a chave para a qualidade da conversão de uma fonte de áudio analógica para digital por meio de cálculos dos computadores.
Em outras palavras, a taxa de amostragem (sample_rate) é o número de vezes (por segundo) que o nível do som é amostrado no formato digital, isto é, quantas “fatias” é necessário para representar uma onda dentro de 1 ciclo. Neste exemplo, temos 48mil partes dentro de 1 segundo.
Ler Artigo: https://skytracks.io/blog/understanding-sample-rates-and-what-they-mean e https://routenote.com/blog/what-is-sample-rate-in-audio/
square_wave_period
Calculamos um único ciclo de amostra para reproduzir um som.
No exemplo citado com 48khz (sample-rate) dividido por 256hz (nota C), teremos 187 samples.
Referência: https://pages.mtu.edu/~suits/notefreqs.html
buffer_size
O buffer_size é valor que corresponde a taxa de bits (Mpbs - megabytes por segundo) que determina o tamanho do arquivo digital.
Ele é composto por:
- sample-rate (taxa de amostragem)
- bit depth (profundidade de bits)
- número de canais (mono/stereo)
No nosso exemplo, o tamanho do buffer(Mpbs) é calculado com 48000 * 2 * 2. Onde seria 48khz x 2 bytes (16bits) x 2 canais.
A profundidade de bits (bit depth) é o que chamamos de bytes por amostra.
No exemplo, teriamos um arquivo de 192kb por segundo. Se a música tiver 60 segundo, teriamos um arquivo de 11.5mb.
Como funciona
Sabemos quais propriedades vamos usar, agora iremos popular o buffer que corresponde à 1 segundo de áudio com base nos bytes por amostra e na quantidade de amostra em si.
A ideia principal é fazer iterações em cada sample (vamos usar o tamanho de bytes que cada sample ocupa para isso) e então, escrever a amplitude da onda em cada um dos samples.
Depois, vamos pedir para audio card tocar essas amostras que já possuem uma amplitude.
A lógica para que tudo funcione seria a seguinte.
- Descobrir a posição atual do cursor ‘play’ e usá-lo para determinar onde podemos escrever no buffer.
- Descobrir o range disponível do buffer em que podemos trancar para ser escrito. Esse range irá depender se o cursor ‘play’ está à frente ou atrás do offset de escrita.
- Trancar a região que será escrita. É possível que o dsound divida em duas regiões onde você poderá escrever.
- Mapear os bytes em samples para podermos iterar em cada um dos samples e adicionar a amplitude da onda nele.
- Calcular o valor de cada sample para um ciclo especifico (square_wave_period). Lembrando que um ciclo é considerado o valor positivo e negativo, ou seja, uma onda completa.
- Destrancamos as regiões para que o cursor ‘play’ possa emitir ao audio card os samples e nós possamos ouvi-los.
1. GetCurrentPosition
1
2
3
4
DWORD play_cursor; // in bytes.
DWORD write_cursor; // Ignored. Não precisamos do write_cursor;
if (SUCCEEDED(the_sound_buffer->GetCurrentPosition(&play_cursor, &write_cursor))) {
}
Este método encontra a posição atual do cursor tocador.
A variável
write_cursor
não é necessária aqui.
2. Definindo Regiões para Escrita
Como descobrir qual o offset para realizar o Lock e quantos bytes travar?
O offset pode ser calculado ao incrementar cada sample e transformando esse valor em bytes. Ou seja, (sample_index * bytes_sample)
eu descubro em qual ponto (em bytes) eu estou, visto que haverá um looping de acesso por samples.
Depois uso o operador mod para o tamanho do buffer para construir uma escrita circular.
Lembrando que haverá uma iteração no sample_index e um incremento do mesmo.
1
2
// which bytes I'll start to lock
DWORD offset = (running_sample_index * bytes_sample) % sound_buffer_size;
O próximo passo é descobrir se o play_cursor
está a frente ou atrás ao bytes que queremos travar.
Se o offset estiver a frente do play_cursor
, vamos calcular para serem escritos os bytes do play_cursor
até o final do buffer + o início do buffer até o play_cursor
, formando assim uma escrita circular.
Agora, se o play_cursor
estiver à frente, significa que podemos escrever somente no range do offset até o próprio play_cursor
.
1
2
3
4
5
6
7
8
9
10
DWORD write_bytes;
if (offset > play_cursor)
{
write_bytes = buffer_size - offset; // cursor até o fim (region1)
write_bytes += play_cursor; // + começo até o play_cursor (region2)
}
else
{
write_bytes = play_cursor - offset;
}
3. Lock
Vamos travar as regiões que serão escritas.
1
2
3
4
5
6
7
8
VOID *region1;
DWORD region1_size;
VOID *region2;
DWORD region2_size;
if (SUCCEEDED(the_sound_buffer->Lock(offset_bytes, write_bytes,
®ion1, ®ion1_size,
®ion2, ®ion2_size, 0))) {
}
offset_bytes
é um valor em bytes que indica em o lock vai iniciar. Já write_bytes
é o tamanho de quantos bytes será trancado para escrita.
region1
e region2
será o endereço de ponteiro (array) para os blocos de bytes trancados.
4. Mapear Bytes para Sample
Vamos acessar cada região (em bytes) e mapeá-los para samples para que possamos iterar em cada um desses samples e modificar o seu conteúdo (em bytes).
1
2
DWORD sample_count_region1 = region1_size / bytes_per_sample;
DWORD sample_count_region2 = region2_size / bytes_per_sample;
5. Populando os samples
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Casting para popular cada channel
s16 *sample_out = (s16 *) region1;
for (DWORD i = 0; i < sample_count_region1; ++i) {
s16 value = ((sample_index / (square_wave_period / 2)) % 2) ? volume : -volume;
*sample_out++ = value;
*sample_out++ = value;
sample_index++;
}
sample_out = (s16 *) region2;
for (DWORD i = 0; i < sample_count_region2; ++i) {
s16 value = ((sample_index / (square_wave_period / 2)) % 2) ? volume : -volume;
*sample_out++ = value;
*sample_out++ = value;
sample_index++;
}
Agora que sabemos quantas iterações serão necessárias (region1_size / bytes_per_sample
), isto é, quantos samples vamos usar naquela faixa de região, iremos calcular o valor do sample.
O valor do sample é calculado usando a frequência de som (width) para a amplitude positivo e para amplitude negativa.
Ou seja, no exemplo acima, para emitir o som de Dó(C) (que é 256hz OU 1/256) em uma onda quadrada, precisaremos dividir essa onde em 2 (metade para o sinal positivo e metade para o sinal negativo) e dividir cada index por esse valor. Quantos samples precisamos para completar um som de DÓ (~256hz)?
Se hertz é samples/second e vamos trabalhar com 48Khz, então fazemos 48000 / 256 descobrimos quantos samples precisamos preencher.
Dessa forma, saberemos quantos samples(qtd) são necessários para preencher a parte positivo da frequencia de Dó e também da parte negativa.
Sabendo quantos samples são positivos e quantos negativos, cabe agora atribuir no valor o volume, que seria a amplitude da onda.
Em outras palavras, o looping irá representar a parte horizontal da onda (linha do tempo = hertz) e o valor irá representar a parte vertical da onda (amplitude = volume).
Nosso buffer terá 16 bits depth = 32 bits [LEFT + RIGHT]. Cada grupo de [LEFT RIGHT] é 1 sample, o que dá 32bits (4bytes) por sample.
6. Unlock
Precisamos destrancar o buffer para que ele seja tocado sem bugs.
1
buffer->Unlock(region1, region1_size, region2, region2_size);
Tocando o Som
Para iniciar o som, vamos chamar buffer->Play(0, 0, DSBPLAY_LOOPING);
.
1
2
3
4
if (!sound_playing) {
the_sound_buffer->Play(0, 0, DSBPLAY_LOOPING);
sound_playing = true;
}
Referencias: