Criar ondas, parte 1 - Criar um sintetizador

Vamos fazer barulho! Neste codelab, usaremos a API AAudio para criar um app sintetizador de baixa latência e controlado por toque para Android.

Nosso app produz sons o mais rápido possível após o usuário tocar na tela. O atraso entre a entrada e a saída é conhecido como latência. Entender e minimizar a latência é fundamental para criar uma ótima experiência de áudio. Na verdade, o principal motivo de usarmos a AAudio é a capacidade dela de criar streams de áudio de baixa latência.

O que você aprenderá

  • Conceitos básicos para criar apps de áudio de baixa latência
  • Como criar streams de áudio
  • Como gerenciar dispositivos de áudio conectados e desconectados
  • Como gerar dados de áudio e transmiti-los para um stream de áudio
  • Práticas recomendadas para a comunicação entre Java e C++
  • Como ouvir eventos de toque na IU

Pré-requisitos

O app produz um som sintetizado quando o usuário toca na tela. Veja a arquitetura:

213d64e35fa7035c.png

Nosso app sintetizador tem quatro componentes:

  • IU: escrita em Java, a classe MainActivity é responsável por receber eventos de toque e encaminhá-los para a ponte JNI.
  • Ponte JNI: este arquivo C++ usa JNI para fornecer um mecanismo de comunicação entre nossa IU e objetos C++. Ele encaminha eventos da IU para o mecanismo de áudio.
  • Mecanismo de áudio: esta classe C++ cria o stream de reprodução de áudio e configura o callback de dados usado para fornecer dados ao stream.
  • Oscilador: esta classe C++ gera dados de áudio digital usando uma fórmula matemática simples para calcular uma forma de onda senoidal.

Para começar, crie um novo projeto no Android Studio:

  • File -> New -> New Project...
  • Nomeie seu projeto como "WaveMaker".

À medida que você for passando pelo assistente de configuração do projeto, vá mudando os valores padrão da seguinte forma:

  • Inclua compatibilidade com C++
  • SDK mínimo para smartphones e tablets: API de nível 26: Android O
  • C++ padrão: C++11

Observação: se você precisar consultar o código-fonte finalizado para o app WaveMaker, clique aqui.

Como nosso oscilador é o objeto que produz os dados de áudio, faz sentido começar por ele. Vamos simplificar e fazer com que ele crie uma onda senoidal de 440 Hz.

Noções básicas de síntese digital

Os osciladores são um elemento fundamental da síntese digital. Nosso oscilador precisa produzir uma série de números, conhecidos como amostras. Cada amostra representa um valor de amplitude que é convertido por hardware de áudio em uma tensão para ser conduzida a fones de ouvido ou a um alto-falante.

Veja um diagrama de amostras que representam uma onda senoidal:

5e5f107a4b6a2a48.png

Antes de começar a implementação, veja alguns termos importantes para dados de áudio digital:

  • Formato de amostra: o tipo de dados usado para representar cada amostra. Formatos comuns de amostra incluem PCM16 e ponto flutuante. Usaremos o ponto flutuante devido à resolução de 24 bits e à melhor precisão em volumes menores, entre outros motivos.
  • Frame: ao gerar áudio multicanal, as amostras são agrupadas em frames. Cada amostra no frame corresponde a um canal de áudio diferente. Por exemplo, o áudio estéreo tem dois canais, o esquerdo e o direito. Portanto, um frame de áudio estéreo tem duas amostras, uma para o canal esquerdo e outra para o direito.
  • Taxa de frames: é o número de frames por segundo. Muitas vezes, isso é conhecido como taxa de amostragem. Taxa de frames e taxa de amostragem geralmente significam a mesma coisa e são termos usados alternadamente. Os valores comuns de taxa de frames são 44.100 e 48.000 frames por segundo. A AAudio usa o termo taxa de amostragem, portanto, usamos essa convenção em nosso app.

Criar os arquivos de origem e principais

Clique com o botão direito do mouse na pasta /app/cpp e acesse New->C++ class.

31d616d7c001c02e.png

Nomeie sua classe como "Oscilador".

59ce6364705b3c3c.png

Adicione o arquivo de origem C++ ao build adicionando as seguintes linhas a CMakeLists.txt. Ele pode ser encontrado na seção External Build Files da janela do projeto.

add_library(...existing source filenames...
src/main/cpp/Oscillator.cpp)

Confira se o projeto foi criado.

Adicionar o código

Adicione o seguinte código ao arquivo Oscillator.h:

#include <atomic>
#include <stdint.h>

class Oscillator {
public:
    void setWaveOn(bool isWaveOn);
    void setSampleRate(int32_t sampleRate);
    void render(float *audioData, int32_t numFrames);

private:
    std::atomic<bool> isWaveOn_{false};
    double phase_ = 0.0;
    double phaseIncrement_ = 0.0;
};

Depois, adicione o seguinte código ao arquivo Oscillator.cpp:

#include "Oscillator.h"
#include <math.h>

#define TWO_PI (3.14159 * 2)
#define AMPLITUDE 0.3
#define FREQUENCY 440.0

void Oscillator::setSampleRate(int32_t sampleRate) {
    phaseIncrement_ = (TWO_PI * FREQUENCY) / (double) sampleRate;
}

void Oscillator::setWaveOn(bool isWaveOn) {
    isWaveOn_.store(isWaveOn);
}

void Oscillator::render(float *audioData, int32_t numFrames) {

    if (!isWaveOn_.load()) phase_ = 0;

    for (int i = 0; i < numFrames; i++) {

        if (isWaveOn_.load()) {

            // Calculates the next sample value for the sine wave.
            audioData[i] = (float) (sin(phase_) * AMPLITUDE);

            // Increments the phase, handling wrap around.
            phase_ += phaseIncrement_;
            if (phase_ > TWO_PI) phase_ -= TWO_PI;

        } else {
            // Outputs silence by setting sample value to zero.
            audioData[i] = 0;
        }
    }
}

void setSampleRate(int32_t sampleRate) nos permite definir a taxa de amostragem desejada para nossos dados de áudio. Explicaremos melhor o porquê precisamos disso mais adiante. Com base em sampleRate e FREQUENCY, ele calcula o valor de phaseIncrement_, que é usado em render. Se você quiser mudar a inclinação da onda senoidal, basta atualizar FREQUENCY com um novo valor.

void setWaveOn(bool isWaveOn) é um método setter para o campo isWaveOn_. Ele é usado em render para determinar se a saída será a onda senoidal ou silêncio.

void render(float *audioData, int32_t numFrames) coloca os valores da onda senoidal de ponto flutuante na matriz audioData sempre que é chamado.

numFrames é o número de frames de áudio que precisamos renderizar. Para simplificar, nosso oscilador gera uma única amostra por frame, ou seja, uma saída mono.

phase_ armazena a fase de onda atual e é incrementada por phaseIncrement_ após cada amostra gerada.

Se isWaveOn_ for false, haverá saída apenas de zeros (silêncio).

O oscilador está pronto. Mas como podemos ouvir a onda senoidal? Para isso, precisamos de um mecanismo de áudio...

O mecanismo de áudio é responsável por:

  • configurar um stream de áudio para o dispositivo de áudio padrão;
  • conectar o oscilador ao stream de áudio usando um callback de dados;
  • ativar e desativar a saída de onda do oscilador;
  • fechar o stream quando ele não for mais necessário.

Se você ainda não fez isso, vale a pena se familiarizar com a API AAudio, porque ela aborda os principais conceitos por trás da criação de streams e do gerenciamento do estado deles.

Criar arquivos de origem e principais

Como na etapa anterior, crie uma classe C++ chamada "AudioEngine".

Adicione o arquivo de origem C++ e a biblioteca AAudio ao build acrescentando as seguintes linhas a CMakeLists.txt:

add_library(...existing source files...
src/main/cpp/AudioEngine.cpp )

target_link_libraries(...existing libraries...
aaudio)

Adicionar o código

Adicione o seguinte código ao arquivo AudioEngine.h:

#include <aaudio/AAudio.h>
#include "Oscillator.h"

class AudioEngine {
public:
    bool start();
    void stop();
    void restart();
    void setToneOn(bool isToneOn);

private:
    Oscillator oscillator_;
    AAudioStream *stream_;
};

Depois, adicione o seguinte código ao arquivo AudioEngine.cpp:

#include <android/log.h>
#include "AudioEngine.h"
#include <thread>
#include <mutex>

// Double-buffering offers a good tradeoff between latency and protection against glitches.
constexpr int32_t kBufferSizeInBursts = 2;

aaudio_data_callback_result_t dataCallback(
        AAudioStream *stream,
        void *userData,
        void *audioData,
        int32_t numFrames) {

    ((Oscillator *) (userData))->render(static_cast<float *>(audioData), numFrames);
    return AAUDIO_CALLBACK_RESULT_CONTINUE;
}

void errorCallback(AAudioStream *stream,
                  void *userData,
                  aaudio_result_t error){
   if (error == AAUDIO_ERROR_DISCONNECTED){
       std::function<void(void)> restartFunction = std::bind(&AudioEngine::restart,
                                                           static_cast<AudioEngine *>(userData));
       new std::thread(restartFunction);
   }
}

bool AudioEngine::start() {
    AAudioStreamBuilder *streamBuilder;
    AAudio_createStreamBuilder(&streamBuilder);
    AAudioStreamBuilder_setFormat(streamBuilder, AAUDIO_FORMAT_PCM_FLOAT);
    AAudioStreamBuilder_setChannelCount(streamBuilder, 1);
    AAudioStreamBuilder_setPerformanceMode(streamBuilder, AAUDIO_PERFORMANCE_MODE_LOW_LATENCY);
    AAudioStreamBuilder_setDataCallback(streamBuilder, ::dataCallback, &oscillator_);
    AAudioStreamBuilder_setErrorCallback(streamBuilder, ::errorCallback, this);

    // Opens the stream.
    aaudio_result_t result = AAudioStreamBuilder_openStream(streamBuilder, &stream_);
    if (result != AAUDIO_OK) {
        __android_log_print(ANDROID_LOG_ERROR, "AudioEngine", "Error opening stream %s",
                            AAudio_convertResultToText(result));
        return false;
    }

    // Retrieves the sample rate of the stream for our oscillator.
    int32_t sampleRate = AAudioStream_getSampleRate(stream_);
    oscillator_.setSampleRate(sampleRate);

    // Sets the buffer size.
    AAudioStream_setBufferSizeInFrames(
           stream_, AAudioStream_getFramesPerBurst(stream_) * kBufferSizeInBursts);

    // Starts the stream.
    result = AAudioStream_requestStart(stream_);
    if (result != AAUDIO_OK) {
        __android_log_print(ANDROID_LOG_ERROR, "AudioEngine", "Error starting stream %s",
                            AAudio_convertResultToText(result));
        return false;
    }

    AAudioStreamBuilder_delete(streamBuilder);
    return true;
}

void AudioEngine::restart(){

   static std::mutex restartingLock;
   if (restartingLock.try_lock()){
       stop();
       start();
       restartingLock.unlock();
   }
}

void AudioEngine::stop() {
    if (stream_ != nullptr) {
        AAudioStream_requestStop(stream_);
        AAudioStream_close(stream_);
    }
}

void AudioEngine::setToneOn(bool isToneOn) {
    oscillator_.setWaveOn(isToneOn);
}

Veja o que o código faz...

Iniciar o mecanismo

O método start() configura um stream de áudio. Os streams de áudio em AAudio são representados pelo objeto AAudioStream e, para criar um, precisamos de um AAudioStreamBuilder:

AAudioStreamBuilder *streamBuilder;
AAudio_createStreamBuilder(&streamBuilder);

Agora, podemos usar streamBuilder para definir vários parâmetros no stream.

Nosso formato de áudio são números de ponto flutuante:

AAudioStreamBuilder_setFormat(streamBuilder, AAUDIO_FORMAT_PCM_FLOAT);

A saída será em mono (um canal):

AAudioStreamBuilder_setChannelCount(streamBuilder, 1);

Observação: não configuramos alguns parâmetros porque queremos que a AAudio faça isso automaticamente. Eles incluem:

  • O ID do dispositivo de áudio: queremos usar o dispositivo de áudio padrão, em vez de especificar um explicitamente, como o alto-falante integrado. Veja uma lista de possíveis dispositivos de áudio que podem ser adquiridos usando AudioManager.getDevices().
  • A direção de stream: por padrão, um stream de saída é criado. Para fazer uma gravação, precisamos especificar um stream de entrada.
  • A taxa de amostragem (mais informações sobre isso mais adiante).

Modo de desempenho

Como queremos a menor latência possível, definimos o modo de desempenho de baixa latência:

AAudioStreamBuilder_setPerformanceMode(streamBuilder, AAUDIO_PERFORMANCE_MODE_LOW_LATENCY);

A AAudio não garante que o stream resultante tenha esse modo de desempenho de baixa latência. Ele pode não ter esse modo pelos seguintes motivos:

  • Você especificou uma taxa de amostragem, um formato de amostra ou amostras por frame não nativos (mais detalhes abaixo), o que pode causar reamostragem ou conversão de formato. Reamostragem é o processo de recalcular valores de amostra em uma taxa diferente. A reamostragem e conversão de formato podem adicionar latência e/ou carga computacional.
  • Não há fluxos de baixa latência disponíveis, provavelmente porque todos estão sendo usados pelo seu app ou por outros apps.

Verifique o modo de desempenho do seu stream usando AAudioStream_getPerformanceMode.

Abrir o stream

Assim que todos os parâmetros forem definidos (o callback de dados será abordado mais tarde), abriremos o stream e verificaremos o resultado:

aaudio_result_t result = AAudioStreamBuilder_openStream(streamBuilder, &stream_);

Se o resultado for qualquer um, exceto AAUDIO_OK, registraremos a saída na janela Android Monitor no Android Studio e retornaremos false.

if (result != AAUDIO_OK){
__android_log_print(ANDROID_LOG_ERROR, "AudioEngine", "Error opening stream", AAudio_convertResultToText(result));
        return false;
}

Definir a taxa de amostragem do oscilador

Não definimos a taxa de amostragem do stream de forma deliberada, porque queremos usar sua taxa de amostragem nativa, ou seja, a taxa que evita reamostragem e adição de latência. Agora que o stream está aberto, podemos consultá-lo para descobrir qual é a taxa de amostragem nativa:

int32_t sampleRate = AAudioStream_getSampleRate(stream_);

Em seguida, orientamos nosso oscilador a produzir dados de áudio usando essa taxa de amostragem:

oscillator_.setSampleRate(sampleRate);

Definir o tamanho do buffer

O tamanho do buffer interno do stream afeta diretamente a latência do stream. Quanto maior o buffer, maior a latência.

Definiremos o buffer para ter o dobro do tamanho de um burst. Um burst é uma quantidade discreta de dados gravados durante cada callback. Ele oferece uma boa compensação entre latência e proteção contra underrun. Saiba mais sobre o ajuste de tamanho do buffer na documentação da AAudio.

AAudioStream_setBufferSizeInFrames(
           stream_, AAudioStream_getFramesPerBurst(stream_) * kBufferSizeInBursts);

Iniciar o stream

Agora que tudo está configurado, podemos iniciar o stream, de forma que ele comece a consumir dados de áudio e acionar callbacks de dados.

result = AAudioStream_requestStart(stream_);

O callback de dados

Como transferir dados de áudio para o stream? Temos duas opções:

Usaremos a segunda abordagem porque é melhor para apps de baixa latência. A função de callback de dados será chamada de uma linha de execução de alta prioridade sempre que o stream exigir dados de áudio.

A função DataCallback

Para começar, definimos a função de callback no namespace global:

aaudio_data_callback_result_t dataCallback(
    AAudioStream *stream,
    void *userData,
    void *audioData,
    int32_t numFrames){
        ...
}

A parte inteligente é que o parâmetro userData é um indicador para o objeto Oscillator. Assim, podemos usá-lo para renderizar dados de áudio na matriz audioData. Veja como fazer isso:

((Oscillator *)(userData))->render(static_cast<float*>(audioData), numFrames);

Também transmitimos a matriz audioData como números de ponto flutuante, porque esse é o formato esperado pelo método render().

Por fim, o método retorna um valor que instrui o stream a continuar consumindo dados de áudio.

return AAUDIO_CALLBACK_RESULT_CONTINUE;

Configurar o callback

Agora que temos a função dataCallback, instruir o stream a usá-la pelo método start() é simples. Os :: indicam que a função está no namespace global:

AAudioStreamBuilder_setDataCallback(streamBuilder, ::dataCallback, &oscillator_);

Iniciar e interromper o oscilador

Ativar e desativar a saída de onda do oscilador é simples. Há apenas um método que transmite o estado do tom ao oscilador:

void AudioEngine::setToneOn(bool isToneOn) {
  oscillator_.setWaveOn(isToneOn);
}

Vale ressaltar que, mesmo quando a onda do oscilador está desativada, o método render() ainda produz dados de áudio preenchidos com zeros. Consulte Evitar a latência de aquecimento acima.

Organizar

Fornecemos um método start() que cria o stream, então precisamos fornecer um método stop() correspondente que o exclua. Esse método poderá ser chamado sempre que o stream não for mais necessário, por exemplo, quando o app é fechado. Ele interrompe o stream, que encerra os callbacks, e fecha o stream, fazendo com que ele seja excluído.

AAudioStream_requestStop(stream_);
AAudioStream_close(stream_);

Processar desconexões do stream usando o callback de erro

Quando o stream de reprodução é iniciado, ele usa o dispositivo de áudio padrão. Pode ser o alto-falante integrado, fones de ouvido ou algum outro dispositivo de áudio, como uma interface de áudio USB.

O que acontecerá se o dispositivo de áudio padrão for mudado? Por exemplo, se o usuário iniciar a reprodução no alto-falante e depois conectar fones de ouvido. Nesse caso, o stream de áudio é desconectado do alto-falante e o app não consegue mais gravar amostras de áudio para a saída. A reprodução é interrompida.

Isso provavelmente não é o que o usuário espera. O áudio precisa continuar tocando nos fones de ouvido. No entanto, existem outros cenários em que a interrupção da reprodução pode ser mais adequada.

Precisamos de um callback para detectar a desconexão do stream e uma função para reiniciá-lo no novo dispositivo de áudio, quando adequado.

Configurar o callback de erro

Para ouvir o evento de desconexão do stream, defina uma função do tipo AAudioStream_errorCallback.

void errorCallback(AAudioStream *stream,
                  void *userData,
                  aaudio_result_t error){
   if (error == AAUDIO_ERROR_DISCONNECTED){
       std::function<void(void)> restartFunction = std::bind(&AudioEngine::restart,
                                                           static_cast<AudioEngine *>(userData));
       new std::thread(restartFunction);
   }
}

Essa função será chamada sempre que o stream encontrar um erro. Se o erro for AAUDIO_ERROR_DISCONNECTED, poderemos reiniciar o stream.

O callback não pode reiniciar o stream de áudio diretamente. Em vez disso, para reiniciá-lo, criamos uma std::function que aponta para AudioEngine::restart(). Em seguida, invocamos a função de uma std::thread separada.

Por fim, definimos o errorCallback da mesma forma que fizemos para dataCallback em start().

AAudioStreamBuilder_setErrorCallback(streamBuilder, ::errorCallback, this);

Reiniciar o stream

Protegemos as seções críticas do código com um std::mutex, já que a função de reinicialização pode ser chamada em várias linhas de execução, por exemplo, se recebermos vários eventos de desconexão em rápida sucessão.

void AudioEngine::restart(){

    static std::mutex restartingLock;
    if (restartingLock.try_lock()){
        stop();
        start();
        restartingLock.unlock();
    }
}

Isso é tudo para nosso mecanismo de áudio, e não há muito mais a ser feito...

Precisamos que nossa IU em Java se comunique com as classes C++, e é aí que entra a JNI. Suas assinaturas de método podem não ser a melhor opção, mas pelo menos existem apenas três delas.

Renomeie o arquivo native-lib.cpp como jni-bridge.cpp. Você pode deixar o nome do arquivo como está, mas é preferível deixar bem claro que o arquivo C++ é destinado aos métodos JNI. Atualize CMakeLists.txt com o arquivo renomeado, mas deixe o nome da biblioteca como native-lib.

Adicione o código a seguir a jni-bridge.cpp:

#include <jni.h>
#include <android/input.h>
#include "AudioEngine.h"

static AudioEngine *audioEngine = new AudioEngine();

extern "C" {

JNIEXPORT void JNICALL
Java_com_example_wavemaker_MainActivity_touchEvent(JNIEnv *env, jobject obj, jint action) {
    switch (action) {
        case AMOTION_EVENT_ACTION_DOWN:
            audioEngine->setToneOn(true);
            break;
        case AMOTION_EVENT_ACTION_UP:
            audioEngine->setToneOn(false);
            break;
        default:
            break;
    }
}

JNIEXPORT void JNICALL
Java_com_example_wavemaker_MainActivity_startEngine(JNIEnv *env, jobject /* this */) {
    audioEngine->start();
}

JNIEXPORT void JNICALL
Java_com_example_wavemaker_MainActivity_stopEngine(JNIEnv *env, jobject /* this */) {
    audioEngine->stop();
}

}

Nossa ponte JNI é bastante simples:

  • Criamos uma instância estática do AudioEngine.
  • startEngine() e stopEngine() iniciam e interrompem o mecanismo de áudio.
  • touchEvent() converte eventos de toque em chamadas de método para ativar e desativar o tom.

Por fim, vamos criar a IU e conectá-la ao back-end...

Layout

Nosso layout é muito simples, vamos melhorá-lo nos codelabs subsequentes. É apenas um FrameLayout com uma TextView no centro:

4a039cdf72e4846f.png

Atualize res/layout/activity_main.xml para:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/touchArea"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.example.wavemaker.MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="@string/tap_anywhere"
        android:textAppearance="@android:style/TextAppearance.Material.Display1" />
</FrameLayout>

Adicione o recurso de string de @string/tap_anywhere a res/values/strings.xml:

<resources>
    <string name="app_name">WaveMaker</string>
    <string name="tap_anywhere">Tap anywhere</string>
</resources>

Atividade principal

Agora, atualize MainActivity.java com o seguinte código:

package com.example.wavemaker;

import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.view.MotionEvent;

public class MainActivity extends AppCompatActivity {

    static {
        System.loadLibrary("native-lib");
    }

    private native void touchEvent(int action);

    private native void startEngine();

    private native void stopEngine();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        startEngine();
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        touchEvent(event.getAction());
        return super.onTouchEvent(event);
    }

    @Override
    public void onDestroy() {
        stopEngine();
        super.onDestroy();
    }
}

Veja o que esse código faz:

  • Todos os métodos private native void estão definidos em jni-bridge.cpp. Precisamos declará-los aqui para poder usá-los.
  • Os eventos de ciclo de vida da atividade onCreate() e onDestroy() chamam a ponte JNI para iniciar e interromper o mecanismo de áudio.
  • Substituímos onTouchEvent() para receber todos os eventos de toque da Activity e transmiti-los diretamente para a ponte JNI para ativar e desativar o tom.

Acione seu emulador ou dispositivo de teste e execute o app WaveMaker nele. Ao tocar na tela, você deve ouvir uma onda senoidal sendo produzida.

Nosso app não ganhará prêmios por criatividade musical, mas ele demonstra as técnicas fundamentais para produzir áudio de baixa latência sintetizado no Android.

Não se preocupe, nos próximos codelabs deixaremos o app muito mais interessante. Agradecemos por concluir este codelab. Se você tiver dúvidas, faça perguntas no grupo android-ndk.

Leia mais

Amostras de áudio de alto desempenho (link em inglês).

Guia de áudio de alto desempenho na documentação do Android NDK.

Vídeo de Práticas recomendadas para áudio no Android - Google I/O 2017 (em inglês).