Post

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:

  1. sample-rate (taxa de amostragem)
  2. bit depth (profundidade de bits)
  3. 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.

  1. Descobrir a posição atual do cursor ‘play’ e usá-lo para determinar onde podemos escrever no buffer.
  2. 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.
  3. Trancar a região que será escrita. É possível que o dsound divida em duas regiões onde você poderá escrever.
  4. Mapear os bytes em samples para podermos iterar em cada um dos samples e adicionar a amplitude da onda nele.
  5. 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.
  6. 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,
                                     &region1, &region1_size,
                                     &region2, &region2_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:

Esta postagem está licenciada sob CC BY 4.0 pelo autor.