Tutorial de SDL – parte 34 – Gravação de áudio

Continuando nossa série de artigos traduzidos do site lazyfoo, veremos agora como realizar a gravação de áudio com a biblioteca SDL. Em relação a áudio, existem mais coisas que podem ser feitas do que somente reproduzi-lo. Esse artigo irá cobrir o básico sobre gravação e reprodução de áudio. Para começar, certifique-se de possuir a última versão do SDL. Eu tive que atualizar para a versão 2.0.8 para que a gravação de áudio funcionasse direito. Para entender como gravação de áudio funciona, é de grande ajuda saber como dados de áudio funcionam. Isso é uma música:
Agora vamos dar um zoom:
Como você pode ver, som é uma onda. Uma onda pode ser representada por uma sequência de valores (ou no caso de uma música estéreo, por duas sequências com um valor para cada onda). Reproduzir som é basicamente enviar uma sequência de valores para o driver de som e gravação de áudio é copiar uma sequência de valores do driver de som.
//Maximum number of supported recording devices
const int MAX_RECORDING_DEVICES = 10;

//Maximum recording time
const int MAX_RECORDING_SECONDS = 5;

//Maximum recording time plus padding
const int RECORDING_BUFFER_SECONDS = MAX_RECORDING_SECONDS + 1;

//The various recording actions we can take
enum RecordingState
{
    SELECTING_DEVICE,
    STOPPED,
    RECORDING,
    RECORDED,
    PLAYBACK,
    ERROR
};
Aqui tem algumas constantes que estaremos usando. Em primeiro lugar, temos a constante que define que suportaremos no máximo 10 dispositivos de gravação (nós precisamos apenas de 1 para que o programa funcione). Em seguida temos a quantidade máxima de tempo que permitiremos que seja gravado e a quantidade de tempo máxima que podemos armazenar no buffer. Nós estaremos gravando por 5 segundos, mas permitiremos 6 segundos de gravação por segurança, no caso da aplicação gravar 5.1 segundo ou algo do tipo. Por último, temos uma enumeração para os diferentes estados do programa. Primeiro o usuário seleciona um dispositivo de gravação. Em seguida, ficamos aguardando o inicio da gravação. Depois o usuário inicia a gravação por 5 segundos. Quando a gravação é finalizada, pode-se ou reproduzir o áudio ou iniciar nova gravação. Se ocorrer um erro, iremos para um estado de erro. Normalmente nessa série de artigos, vamos percorrendo um código explicando o que cada parte faz, mas nesse artigo iremos seguir mais o fluxo de execução. Isso torna as coisas mais fáceis de entender do que somente seguindo o código fonte. Assim, não se perca a medida que seguimos.
//Recording/playback callbacks
void audioRecordingCallback( void* userdata, Uint8* stream, int len );
void audioPlaybackCallback( void* userdata, Uint8* stream, int len );
Essas duas funções irão executar a gravação e reprodução do áudio. Iremos mostrar mais detalhes delas mais tarde.
//Prompt texture
LTexture gPromptTexture;

//The text textures that specify recording device names
LTexture gDeviceTextures[ MAX_RECORDING_DEVICES ];

//Number of available devices
int gRecordingDeviceCount = 0;

//Recieved audio spec
SDL_AudioSpec gReceivedRecordingSpec;
SDL_AudioSpec gReceivedPlaybackSpec;
Aqui temos algumas texturas. Uma para mostrar ao usuário o que está acontecendo e outra é um array de texturas para armazenar os nomes dos dispositivos de gravação. Também temos um inteiro para registro de quantos dispositivos estão disponíveis Também temos duas variáveis SDL_AudioSpec. Esse tipo de variável é uma especificação de áudio que basicamente define como o áudio é gravado ou reproduzido. Quando abrimos um dispositivo de áudio para gravação ou reprodução, uma especificação é solicitada mas podemos não obter o que foi solicitado, pois o driver de áudio não suporta a especificação. Por isso que iremos armazenar a especificação que obtermos do driver para a gravação e a reprodução.
//Recording data buffer
Uint8* gRecordingBuffer = NULL;

//Size of data buffer
Uint32 gBufferByteSize = 0;

//Position in data buffer
Uint32 gBufferBytePosition = 0;

//Maximum position in data buffer for recording
Uint32 gBufferByteMaxPosition = 0;
O “gRecordingBuffer” é um buffer de bytes sem sinal que armazena os dados do áudio. “gBufferByteSize” irá armazenar quantos bytes o buffer irá armazenar. “gBufferBytePosition” controla a posição do buffer durante a gravação e/ou a reprodução. “gBufferByteMaxPosition” controla a parte máxima do buffer sendo usado. Se isso está confuso, lembre que “gBufferByteSize” contém 6 segundos de bytes (5 segundos + 1 byte de espaçamento) e “gBufferByteMaxPosition” é o quinto segundo de bytes sendo usados.
    //Initialize SDL
    if( SDL_Init( SDL_INIT_VIDEO | SDL_INIT_AUDIO ) < 0 )
    {
        printf( "SDL could not initialize! SDL Error: %s\n", SDL_GetError() );
        success = false;
    }
Lembre que é necessário inicializar o áudio antes da gravação ou reprodução. É bem comum esquecer disso.
bool loadMedia()
{
    //Loading success flag
    bool success = true;

    //Open the font
    gFont = TTF_OpenFont( "34_audio_recording/lazy.ttf", 28 );
    if( gFont == NULL )
    {
        printf( "Failed to load lazy font! SDL_ttf Error: %s\n", TTF_GetError() );
        success = false;
    }
    else
    {
        //Set starting prompt 
        gPromptTexture.loadFromRenderedText( "Select your recording device:", gTextColor );

        //Get capture device count
        gRecordingDeviceCount = SDL_GetNumAudioDevices( SDL_TRUE );

        //No recording devices
        if( gRecordingDeviceCount < 1 )
        {
            printf( "Unable to get audio capture device! SDL Error: %s\n", SDL_GetError() );
            success = false;
        }
Após carregar a fonte e renderizar a mensagem inicial obtemos o numero de dispositivos de gravação disponíveis usando SDL_GetNumAudioDevices. Quando o valor SDL_TRUE é passado, será retornado o numero de dispositivos de gravação. Com SDL_FALSE, será retornado o numero de dispositivos de reprodução. Se não houver nenhum dispositivo de gravação conectado, a função é encerrada com um erro.
        //At least one device connected
        else
        {
            //Cap recording device count
            if( gRecordingDeviceCount > MAX_RECORDING_DEVICES )
            {
                gRecordingDeviceCount = MAX_RECORDING_DEVICES;
            }

            //Render device names
            std::stringstream promptText;
            for( int i = 0; i < gRecordingDeviceCount; ++i )
            {
                //Get capture device name
                promptText.str( "" );
                promptText << i << ": " << SDL_GetAudioDeviceName( i, SDL_TRUE );

                //Set texture from name
                gDeviceTextures[ i ].loadFromRenderedText( promptText.str().c_str(), gTextColor );
            }
        }
    }

    return success;
}
Se houver dispositivos de gravação conectados, cortamos a quantidade para o máximo de 10 (o que pode disapontar quem tem 11 microfones conectados ao PC) e então percorremos cada um e renderizamos seus nomes em uma textura. Obtemos o nome do dispositivo usando SDL_GetAudioDeviceName usando como parametros SDL_TRUE para indicar que queremos o nome de um dispositivo de gravação e o indice do dispositivo.
            //Main loop flag
            bool quit = false;

            //Event handler
            SDL_Event e;

            //Set the default recording state
            RecordingState currentState = SELECTING_DEVICE;

            //Audio device IDs
            SDL_AudioDeviceID recordingDeviceId = 0;
            SDL_AudioDeviceID playbackDeviceId = 0;
Na função main, depois da inicialização e do carregamento, configuramos o estado de gravação inicial e declaramos dois IDs de dispositivo de áudio que são apenas inteiros que representam dispositivos de gravação e reprodução.
                    //Do current state event handling
                    switch( currentState )
                    {
                        //User is selecting recording device
                        case SELECTING_DEVICE:

                            //On key press
                            if( e.type == SDL_KEYDOWN )
                            {
                                //Handle key press from 0 to 9 
                                if( e.key.keysym.sym >= SDLK_0 && e.key.keysym.sym <= SDLK_9 )
                                {
                                    //Get selection index
                                    int index = e.key.keysym.sym - SDLK_0;
No loop de manipulação de eventos, temos uma sentença switch que lida com os diferentes estados. Quando o usuário pressiona 0-9, convertemos esse numero para um indíce, o que é fácil pois as contantes SDLK são sequenciais e podem ser convertidas pela subtração do simbolo por SDLK_0.
                                    //Index is valid
                                    if( index < gRecordingDeviceCount )
                                    {
                                        //Default audio spec
                                        SDL_AudioSpec desiredRecordingSpec;
                                        SDL_zero(desiredRecordingSpec);
                                        desiredRecordingSpec.freq = 44100;
                                        desiredRecordingSpec.format = AUDIO_F32;
                                        desiredRecordingSpec.channels = 2;
                                        desiredRecordingSpec.samples = 4096;
                                        desiredRecordingSpec.callback = audioRecordingCallback;

                                        //Open recording device
                                        recordingDeviceId = SDL_OpenAudioDevice( SDL_GetAudioDeviceName( index, SDL_TRUE ), SDL_TRUE, &desiredRecordingSpec, &gReceivedRecordingSpec, SDL_AUDIO_ALLOW_FORMAT_CHANGE );
Se o usuário pressionar uma tecla válida, passamos para a especificação da gravação do áudio. Primeiro, inicializamos a especificação do ´áudio com SDL_zero. Sempre inicialize a memória antes de usa-la. Pergunte a quem teve que lidar com bugs relacionados a isso o que acontece se você não fizer isso. Configuramos a frequência para 44.1 khz, que é a qualidade de um CD. Usamos o formato de ponto flutuante de 32 bits para os dados. Temos 2 canais já que queremos estéreo. Amostragem é configurada para 4096 pois é um tamanho bem padrão. Em seguida informamos a função a ser chamada para gravar o áudio. Com a especificação configurada, chamados SDL_OpenAudioDevice informando o nome do dispositivo de gravação, indicamos que iremos usar um dispositivo de gravação com SDL_TRUE, a especificação que iremos usar, um ponteiro para receber a especificação recebida do driver, e por fim um indicador que informa que SDL_OpenAudioDevice pode retornar um formato diferente do que foi especificado.
                                        //Device failed to open
                                        if( recordingDeviceId == 0 )
                                        {
                                            //Report error
                                            printf( "Failed to open recording device! SDL Error: %s", SDL_GetError() );
                                            gPromptTexture.loadFromRenderedText( "Failed to open recording device!", gTextColor );
                                            currentState = ERROR;
                                        }
                                        //Device opened successfully
                                        else
                                        {
                                            //Default audio spec
                                            SDL_AudioSpec desiredPlaybackSpec;
                                            SDL_zero(desiredPlaybackSpec);
                                            desiredPlaybackSpec.freq = 44100;
                                            desiredPlaybackSpec.format = AUDIO_F32;
                                            desiredPlaybackSpec.channels = 2;
                                            desiredPlaybackSpec.samples = 4096;
                                            desiredPlaybackSpec.callback = audioPlaybackCallback;

                                            //Open playback device
                                            playbackDeviceId = SDL_OpenAudioDevice( NULL, SDL_FALSE, &desiredPlaybackSpec, &gReceivedPlaybackSpec, SDL_AUDIO_ALLOW_FORMAT_CHANGE );
Se não obtivermos nenhum ID de dispositivo, temos um estado de erro. Se o dispositivo for aberto com sucesso, criamos uma especificação de reprodução que é praticamente a mesma da de gravação. A maior difereça é que usa função de reprodução ao invés da de gravação. Abri o dispositivo de reprodução é basicamente a mesma coisa. Nesse artigo, não nos importamos que dispositivo de reprodução iremos obter,k assim usamos NULL como argumento para que seja retornado o primeiro disponível. Em seguida, usamos SDL_FALSE para abrir um dispositivo de reprodução ao invés de um de gravação.
                                            //Device failed to open
                                            if( playbackDeviceId == 0 )
                                            {
                                                //Report error
                                                printf( "Failed to open playback device! SDL Error: %s", SDL_GetError() );
                                                gPromptTexture.loadFromRenderedText( "Failed to open playback device!", gTextColor );
                                                currentState = ERROR;
                                            }
                                            //Device opened successfully
                                            else
                                            {
                                                //Calculate per sample bytes
                                                int bytesPerSample = gReceivedRecordingSpec.channels * ( SDL_AUDIO_BITSIZE( gReceivedRecordingSpec.format ) / 8 );

                                                //Calculate bytes per second
                                                int bytesPerSecond = gReceivedRecordingSpec.freq * bytesPerSample;

                                                //Calculate buffer size
                                                gBufferByteSize = RECORDING_BUFFER_SECONDS * bytesPerSecond;

                                                //Calculate max buffer use
                                                gBufferByteMaxPosition = MAX_RECORDING_SECONDS * bytesPerSecond;

                                                //Allocate and initialize byte buffer
                                                gRecordingBuffer = new Uint8[ gBufferByteSize ];
                                                memset( gRecordingBuffer, 0, gBufferByteSize );

                                                //Go on to next state
                                                gPromptTexture.loadFromRenderedText("Press 1 to record for 5 seconds.", gTextColor);
                                                currentState = STOPPED;
                                            }
                                        }
                                    }
                                }
                            }
                            break;    
Se não obtivermos um ID de dispositivo para reprodução, um erro será disparado. Se o dispositivo for aberto com sucesso, criamos um buffer para armazenar os dados do áudio que iremos gravar para ser reproduzido em seguida. Para calcular quanto espaço será necessário primeiro precisamos calcular quantos bytes termos por amostra. Se temos 2 canais e 32 bits por canal (que podemos saber usando SDL_AUDIO_BITSIZE no formato do áudio) teremos então 2 canais * (32 bits / 8 bits por byte) que dá 8 bytes por amostra. Para obter a quantidade de bytes por segundo, multiplicamos a quantidade de bytes por amostra pela frequência, que é a quantidade de amostras por segundo. 8 bytes por amostra * 44100 amostras por segundo dá 705600 bytes por segundo. Queremos 6 segundos de buffer (5 segundos + 1 segundo de espaçamentp), então configuramos o tamanho do buffer para 4233600 bytes. Isso parece muito, mas é um pouco mais de 4 megabytes. Lembre de que por causa da posiçã omáxima, usamos apenas 5 segundos dos 6 segundos do buffer. Após calcular o tamanho do buffer, alocamos o buffer e inicializamos ele com memset. Finalmente, ajustamos a textura e passamos para o próximo estado.
                        //User getting ready to record
                        case STOPPED:

                            //On key press
                            if( e.type == SDL_KEYDOWN )
                            {
                                //Start recording
                                if( e.key.keysym.sym == SDLK_1 )
                                {
                                    //Go back to beginning of buffer
                                    gBufferBytePosition = 0;

                                    //Start recording
                                    SDL_PauseAudioDevice( recordingDeviceId, SDL_FALSE );

                                    //Go on to next state
                                    gPromptTexture.loadFromRenderedText( "Recording...", gTextColor );
                                    currentState = RECORDING;
                                }
                            }
                            break;    
Após alocarmos o bufer, estamos prontos para começar a gravação. Se o usuário pressiona 1, ajustamos a posição do buffer para 0 e iniciamos o dispositivo de áudio usando SDL_PauseAudioDevice. O primeiro parametro é o dispositivo que queremos iniciar/pausar e o segundo argumento determina se queremos iniciar ou pausar. Usando SDL_FALSE iremos iniciar o dispositivo. Dispositivos de áudio poir padrão ficam pausados, o que significa que eles não irão gravar ou reproduzir áudio até serem iniciados. Se você está se perguntando se a sua função não está sendo executada, pode ser por isso.
void audioRecordingCallback( void* userdata, Uint8* stream, int len )
{
    //Copy audio from stream
    memcpy( &gRecordingBuffer[ gBufferBytePosition ], stream, len );

    //Move along buffer
    gBufferBytePosition += len;
}
Quando o dispositivo de gravação é iniciado, começará a chamar a função de gravação que fornecemos em intervalos regulares. Como você pode ver, ela não faz muita coisa. Tudo que ela faz é copiar alguns bytes do stream do dispositivo para a posição atual de nosso buffer de gravação e então altera a posição atual do buffer. Isso é tudo que a gravação é, apenas pegar pedaços de dados de áudio. Apenas lembre-se que “len” é o tamanho do pedaço do stream de bytes.
                //Updating recording
                if( currentState == RECORDING )
                {
                    //Lock callback
                    SDL_LockAudioDevice( recordingDeviceId );

                    //Finished recording
                    if( gBufferBytePosition > gBufferByteMaxPosition )
                    {
                        //Stop recording audio
                        SDL_PauseAudioDevice( recordingDeviceId, SDL_TRUE );

                        //Go on to next state
                        gPromptTexture.loadFromRenderedText( "Press 1 to play back. Press 2 to record again.", gTextColor );
                        currentState = RECORDED;
                    }

                    //Unlock callback
                    SDL_UnlockAudioDevice( recordingDeviceId );
                }
Aqui pulamos para a parte de atualização do loop. Quando estamos gravando, precisamos verificar se atingimos os 5 segundos do buffer. Antes de podermos checar a posição no buffer, temos que chamar SDL_LockAudioDevice. Isso se deve ao fato da função de gravação estar sendo executada em uma outra thread e não queremos que duas thread acesses a mesma variável ao mesmo tempo. SDL_LockAudioDevice interrompe a função de gravação enquanto temos que acessar a posição no buffer que a função também manipula. Assim que o dispositivo estiver bloqueado, verificamos se a posição no buffer ultrapassou os 5 segundos de dados. Se tiver ultrapassado, pausamos o dispositivo de gravação para interromper a gravação e passarmos para outro estado. Por último, chamamos SDL_UnlockAudioDevice caso ainda tenhamos dados para gravar de modo que o dispositivo de gravação possa continuar. Isso é um exemplo bem simples de multithreading. Se quiser saber mais sobre o assunto, dê um olhada nos artigos dessa série sobre multithreading, semáforos e mutexes.
                        //User has finished recording
                        case RECORDED:

                            //On key press
                            if( e.type == SDL_KEYDOWN )
                            {
                                //Start playback
                                if( e.key.keysym.sym == SDLK_1 )
                                {
                                    //Go back to beginning of buffer
                                    gBufferBytePosition = 0;

                                    //Start playback
                                    SDL_PauseAudioDevice( playbackDeviceId, SDL_FALSE );

                                    //Go on to next state
                                    gPromptTexture.loadFromRenderedText( "Playing...", gTextColor );
                                    currentState = PLAYBACK;
                                }
Aqui voltamos para a manipulação de eventos depois de ter gravado os 5 segundos. Como você pode ver, é similar a quando começamos a gravação. Ajustamos a posição no buffer para o inicio, iniciamos o dispositivo de reprodução e mudamos para o estado.
void audioPlaybackCallback( void* userdata, Uint8* stream, int len )
{
    //Copy audio to stream
    memcpy( stream, &gRecordingBuffer[ gBufferBytePosition ], len );

    //Move along buffer
    gBufferBytePosition += len;
}
A função de reprodução também é similar à função de gravação. A diferença chave aqui é que ao invés de copiar do dispositivo para o buffer, pegamos os dados que gravamos no buffer e copiamos de volta para o dispositivo.
                //Updating playback
                else if( currentState == PLAYBACK )
                {
                    //Lock callback
                    SDL_LockAudioDevice( playbackDeviceId );

                    //Finished playback
                    if( gBufferBytePosition > gBufferByteMaxPosition )
                    {
                        //Stop playing audio
                        SDL_PauseAudioDevice( playbackDeviceId, SDL_TRUE );

                        //Go on to next state
                        gPromptTexture.loadFromRenderedText( "Press 1 to play back. Press 2 to record again.", gTextColor );
                        currentState = RECORDED;
                    }

                    //Unlock callback
                    SDL_UnlockAudioDevice( playbackDeviceId );
                }
Atualizar a reprodução também é similar à gravação. Bloqueamos o dispositivo de reprodução, verificamos a posição da reprodução, paramos a reprodução se a posição no buffer estiver além da posição final, e desbloquamos o dispositivo de reprodução.
                                //Record again
                                if( e.key.keysym.sym == SDLK_2 )
                                {
                                    //Reset the buffer
                                    gBufferBytePosition = 0;
                                    memset( gRecordingBuffer, 0, gBufferByteSize );

                                    //Start recording
                                    SDL_PauseAudioDevice( recordingDeviceId, SDL_FALSE );

                                    //Go on to next state
                                    gPromptTexture.loadFromRenderedText( "Recording...", gTextColor );
                                    currentState = RECORDING;
                                }
                            }
                            break;
                    }
Pulando de volta para a manipulação de eventos após o final da gravação, permitimos que o usuário possa iniciar nova gravação. Quando quisermos iniciar nova gravação, apenas ajustamos a posição no buffer para o início, inicializamos o buffer e iniciamos o dispositivo de gravação.
void close()
{
    //Free textures
    gPromptTexture.free();
    for( int i = 0; i < MAX_RECORDING_DEVICES; ++i )
    {
        gDeviceTextures[ i ].free();
    }

    //Free global font
    TTF_CloseFont( gFont );
    gFont = NULL;

    //Destroy window    
    SDL_DestroyRenderer( gRenderer );
    SDL_DestroyWindow( gWindow );
    gWindow = NULL;
    gRenderer = NULL;

    //Free playback audio
    if( gRecordingBuffer != NULL )
    {
        delete[] gRecordingBuffer;
        gRecordingBuffer = NULL;
    }

    //Quit SDL subsystems
    TTF_Quit();
    IMG_Quit();
    SDL_Quit();
}
Como sempre, não esqueça de desalocar o buffer depois de usa-lo para prevenir vazamentos de memória. Programação com áudio é uma campo muito grande que eu não sou expert. Porém, agora que você sabe como lidar com dados brutos de áudio, pode ver como usar bibliotecas de áudio para fazer coisas mais complicadas como compressão de áudio e char por voz. Baixe os arquivos de mídia e código fonte desse artigo aqui.