Making Waves parte 1: crea un sintetizador

¡Hagamos ruido! En este codelab, usaremos la API de AAudio para compilar una app de sintetizador de baja latencia y controles de tacto para Android.

Nuestra app produce sonido lo más rápido posible después de que el usuario toca la pantalla. El retraso entre la entrada y la salida se conoce como latencia. Comprender y minimizar la latencia es fundamental para crear una excelente experiencia de audio. De hecho, usamos AAudio debido a su capacidad para crear transmisiones de audio de baja latencia.

Qué aprenderás

  • Conceptos básicos para crear apps de audio de baja latencia
  • Cómo crear transmisiones de audio
  • Cómo manejar la conexión y desconexión de dispositivos de audio
  • Cómo generar datos de audio y pasarlos a una transmisión de audio
  • Prácticas recomendadas para la comunicación entre Java y C++
  • Cómo detectar eventos táctiles en la IU

Requisitos

La app produce un sonido sintetizado cuando el usuario presiona la pantalla. Esta es la arquitectura:

213d64e35fa7035c.png

Nuestra app de sintetizadores tiene cuatro componentes:

  • IU: La clase MainActivity, escrita en Java, es responsable de recibir los eventos táctiles y reenviarlos al puente JNI.
  • Puente JNI: Este archivo C++ usa JNI para proporcionar un mecanismo de comunicación entre la IU y los objetos C++. Reenvía eventos desde la IU al motor de audio.
  • Motor de audio: Esta clase C++ crea la transmisión de audio para reproducción y configura la devolución de llamada de datos utilizada para proporcionar datos a la transmisión.
  • Oscilador: Esta clase C++ genera datos de audio digital usando una fórmula matemática simple para calcular una forma de onda sinusoidal.

Para comenzar, crea un proyecto nuevo en Android Studio:

  • File -> New -> New Project...
  • Asigna el nombre "WaveMaker" al proyecto.

Mientras sigues las instrucciones del asistente de configuración del proyecto, cambia los valores predeterminados por los siguientes:

  • Incluye compatibilidad con C++.
  • SDK mínimo para teléfonos y tablets, API 26: Android O.
  • Estándar de C++: C++11.

Nota: Si necesitas consultar el código fuente terminado de la app de WaveMaker, haz clic aquí.

Debido a que nuestro oscilador es el objeto que produce los datos de audio, tiene sentido comenzar con él. Lo mantendremos simple y haremos que cree una onda sinusoidal de 440 Hz.

Conceptos básicos de síntesis digital

Los osciladores son componentes fundamentales de la síntesis digital. Nuestro oscilador debe producir una serie de números, conocidos como muestras. Cada muestra representa un valor de amplitud que se convierte mediante hardware de audio en un voltaje que hace funcionar auriculares o una bocina.

A continuación, podemos ver unas muestras que representan una onda sinusoidal:

5e5f107a4b6a2a48.png

Antes de comenzar con la implementación, deberías conocer los siguientes términos importantes para los datos de audio digital:

  • Formato de muestra: Es el tipo de datos que se usa para representar cada muestra. Entre los formatos de muestra comunes se incluyen PCM16 y punto flotante. Usaremos punto flotante debido a su resolución de 24 bits y a que tiene una precisión mejorada con volúmenes bajos, entre otros motivos.
  • Trama: Cuando se genera audio multicanal, las muestras se agrupan en tramas. Cada muestra de la trama corresponde a un canal de audio diferente. Por ejemplo, el audio estéreo tiene dos canales (izquierdo y derecho), por lo que una trama de audio estéreo tiene dos muestras: una para el canal izquierdo y otra para el canal derecho.
  • Velocidad de tramas: La cantidad de tramas por segundo. Por lo general, se conoce como tasa de muestreo. La velocidad de tramas y la tasa de muestreo suelen hacer referencia a lo mismo y se usan de forma indistinta. Los valores comunes de la velocidad de tramas son de 44,100 y 48,000 tramas por segundo. AAudio usa el término tasa de muestreo, por lo que usaremos esa convención en nuestra app.

Crea los archivos fuente y de encabezado

Haz clic con el botón derecho en la carpeta /app/cpp y ve a New->C++ class.

31d616d7c001c02e.png

Ponle el nombre "Oscillator" a tu clase.

59ce6364705b3c3c.png

Para agregar el archivo fuente de C++ a la compilación, agrega las siguientes líneas a CMakeLists.txt. Se encuentra en la sección External Build Files de la ventana "Project".

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

Asegúrate de que tu proyecto se compile correctamente.

Agrega el código

Agrega el siguiente código al archivo 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;
};

Luego, agrega el siguiente código al archivo 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 establecer la tasa de muestreo deseada para nuestros datos de audio (más adelante, obtendrás más información sobre por qué esto es necesario). En función de sampleRate y FREQUENCY, calcula el valor de phaseIncrement_, que se usa en render. Si quieres cambiar el tono de la onda sinusoidal, actualiza el valor de FREQUENCY con uno nuevo.

void setWaveOn(bool isWaveOn) es un método set para el campo isWaveOn_. Se usa en el objeto render para determinar si se debe generar la onda sinusoidal o silencio.

void render(float *audioData, int32_t numFrames) coloca los valores de onda sinusoidal de punto flotante en el array audioData cada vez que se lo llama.

numFrames es la cantidad de tramas de audio que debemos procesar. Para simplificar el proceso, nuestro oscilador genera una muestra única por cada trama, p. ej., mono.

phase_ almacena la fase de onda actual y aumenta debido a phaseIncrement_ después de que se genera cada muestra.

Si el valor de isWaveOn_ es false, solo obtenemos ceros (silencio).

¡Ya está listo nuestro oscilador! Pero ¿cómo podemos escuchar la onda sinusoidal? Para eso necesitamos un motor de audio…

Nuestro motor de audio realiza las siguientes acciones:

  • Configura una transmisión de audio en el dispositivo de audio predeterminado.
  • Conecta el oscilador a la transmisión de audio usando una devolución de llamada de datos.
  • Activa y desactiva la salida de la onda del oscilador.
  • Cierra la transmisión cuando ya no es necesaria.

Si aún no lo hiciste, te recomendamos que te familiarices con la API de AAudio, ya que abarca los conceptos clave de la compilación de transmisiones y la administración de estados de transmisión.

Crea los archivos fuente y de encabezado

Al igual que en el paso anterior, debes crear una clase C++ denominada "AudioEngine".

Agrega el archivo fuente C++ y la biblioteca AAudio a la compilación agregando las siguientes líneas a CMakeLists.txt.

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

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

Agrega el código

Agrega el siguiente código al archivo 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_;
};

Luego, agrega el siguiente código al archivo 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);
}

Esto es lo que hace el código...

Cómo iniciar el motor

Nuestro método start() configura una transmisión de audio. En AAudio se representan las transmisiones de audio con el objeto AAudioStream y, para crear una, necesitamos una función AAudioStreamBuilder:

AAudioStreamBuilder *streamBuilder;
AAudio_createStreamBuilder(&streamBuilder);

Ahora podemos usar streamBuilder para configurar varios parámetros en la transmisión.

Nuestro formato de audio incluye números de punto flotante:

AAudioStreamBuilder_setFormat(streamBuilder, AAUDIO_FORMAT_PCM_FLOAT);

La salida será en mono (un canal):

AAudioStreamBuilder_setChannelCount(streamBuilder, 1);

Nota: No definimos parámetros porque queremos que AAudio se ocupe de ellos automáticamente, por ejemplo:

  • El ID del dispositivo de audio: conviene usar el dispositivo de audio predeterminado, en lugar de especificar uno de manera explícita, como el altavoz incorporado. Puedes usar un objeto AudioManager.getDevices() para obtener una lista de los posibles dispositivos de audio.
  • La dirección de transmisión: de forma predeterminada, se crea una transmisión de salida. Si queremos realizar una grabación, especificaremos una transmisión de entrada.
  • La tasa de muestreo (más adelante volveremos sobre este tema).

Modo de rendimiento

Buscamos la latencia más baja posible, por lo que establecemos el modo de rendimiento de baja latencia:

AAudioStreamBuilder_setPerformanceMode(streamBuilder, AAUDIO_PERFORMANCE_MODE_LOW_LATENCY);

AAudio no garantiza que la transmisión tenga este modo de rendimiento de baja latencia. Entre de los motivos por los que podría no obtener este modo se incluyen los siguientes:

  • Especificaste una tasa de muestreo, un formato de muestra o muestras por trama no nativos (más información a continuación), lo que puede generar remuestreo o conversión de formato. El remuestreo es el proceso de volver a calcular los valores de muestra con una tasa diferente. Tanto el remuestreo como la conversión de formato pueden agregar carga de procesamiento o latencia.
  • No hay transmisiones de baja latencia disponibles, probablemente porque tu app u otras apps las están utilizando.

Puedes verificar el modo de rendimiento de la transmisión con AAudioStream_getPerformanceMode.

Abre la transmisión

Una vez que todos los parámetros están configurados (hablaremos sobre la devolución de llamada de datos más adelante), abrimos la transmisión y analizamos el resultado:

aaudio_result_t result = AAudioStreamBuilder_openStream(streamBuilder, &stream_);

Si se obtiene cualquier resultado excepto AAUDIO_OK, lo registramos en la ventana Android Monitor de Android Studio y mostramos false.

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

Configura la tasa de muestreo del oscilador

No configuramos la tasa de muestreo de la transmisión de forma deliberada porque queremos usar la tasa de muestreo nativa, es decir, la tasa que evita el remuestreo y la latencia adicional. Ahora que la transmisión está abierta, podemos consultarla para averiguar cuál es la tasa de muestreo nativa:

int32_t sampleRate = AAudioStream_getSampleRate(stream_);

Luego, le indicamos a nuestro oscilador que genere datos de audio con esta tasa de muestreo:

oscillator_.setSampleRate(sampleRate);

Configura el tamaño del búfer

El tamaño del búfer interno de la transmisión afecta directamente a la latencia de la transmisión. Cuanto más grande sea el tamaño del búfer, mayor será la latencia.

Estableceremos el tamaño del búfer para que sea dos veces el tamaño de una ráfaga. Una ráfaga es una cantidad discreta de datos escritos durante cada devolución de llamada, lo que ofrece una buena compensación entre la latencia y la protección contra subdesbordamiento. Puedes obtener más información sobre el ajuste de tamaño del búfer en la documentación de AAudio.

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

Inicia la transmisión

Ahora que todo está configurado, podemos iniciar la transmisión, por lo que comienza a consumir datos de audio y activa devoluciones de llamada de datos.

result = AAudioStream_requestStart(stream_);

Devolución de llamada de datos

¿Cómo podemos usar datos de audio en nuestra transmisión? Hay dos opciones:

Usaremos el segundo enfoque porque es mejor para apps de baja latencia. Se llama a la función de devolución de llamada de datos desde un subproceso de alta prioridad cada vez que la transmisión necesita datos de audio.

Función dataCallback

Comenzaremos por definir la función de devolución de llamada en el espacio de nombres global:

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

La parte más interesante es que nuestro parámetro userData es un puntero a nuestro objeto Oscillator. Por lo tanto, podemos usarlo para procesar datos de audio en el array audioData. A continuación, te indicamos cómo hacerlo:

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

Ten en cuenta que también se transmite el array audioData a los números de punto flotante porque ese es el formato que espera nuestro método render().

Por último, el método muestra un valor que le indica a la transmisión que debe seguir consumiendo datos de audio.

return AAUDIO_CALLBACK_RESULT_CONTINUE;

Cómo configurar la devolución de llamada

Ahora que tenemos la función dataCallback, indicarle a la transmisión que la debe usar desde nuestro método start() es simple (:: indica que la función está en el espacio de nombres global):

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

Cómo iniciar y detener el oscilador

Activar y desactivar la salida de la onda del oscilador es simple, solo tenemos un método que pasa el estado del tono al oscilador:

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

Cabe observar que, incluso cuando la onda del oscilador está desactivada, el método render() continúa produciendo datos de audio llenos de ceros (consulta la sección sobre cómo evitar la latencia de preparación, que se incluye más arriba).

Cómo ordenar

Proporcionamos un método start() que crea nuestra transmisión, por lo que también deberíamos proporcionar un método stop() correspondiente que la borre. Se puede llamar a este método cuando ya no se necesita la transmisión (por ejemplo, cuando se cierra la app). Se detiene la transmisión, lo que a su vez detiene las devoluciones de llamada, y se cierra y borra la transmisión.

AAudioStream_requestStop(stream_);
AAudioStream_close(stream_);

Cómo manejar las desconexiones de transmisión con la devolución de llamada de error

Cuando comienza la transmisión de reproducción, se usa el dispositivo de audio predeterminado. Puede ser la bocina integrada, los auriculares o algún otro dispositivo de audio, como una interfaz de audio USB.

¿Qué sucede si se cambia el dispositivo de audio predeterminado? Por ejemplo, si el usuario inicia la reproducción a través del altavoz y conecta los auriculares. En este caso, se desconecta la transmisión de audio del altavoz y tu app ya no puede escribir muestras de audio en la salida. Simplemente se detiene la reproducción.

Esto probablemente no sea lo que espera el usuario. El audio debería seguir reproduciéndose en los auriculares. (Sin embargo, hay otras situaciones en las que detener la reproducción podría ser más apropiado).

Necesitamos una devolución de llamada para detectar la desconexión de la transmisión y una función para reiniciar la transmisión en el nuevo dispositivo de audio, cuando corresponda.

Cómo configurar la devolución de llamada de error

Para detectar el evento de desconexión de la transmisión, define una función de 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);
   }
}

Se llamará a esta función cada vez que la transmisión detecte un error. Si el error es AAUDIO_ERROR_DISCONNECTED, podemos reiniciar la transmisión.

Ten en cuenta que la devolución de llamada no puede reiniciar directamente la transmisión de audio. En cambio, para reiniciar la transmisión, creamos un una función std::function que apunta a AudioEngine::restart() y, luego, invocamos la función desde un subproceso std::thread.

Finalmente, configuramos la devolución de llamada errorCallback de la misma manera que lo hicimos para dataCallback en start().

AAudioStreamBuilder_setErrorCallback(streamBuilder, ::errorCallback, this);

Cómo reiniciar la transmisión

Dado que se puede llamar a la función de reinicio desde varios subprocesos (por ejemplo, si recibimos varios eventos de desconexión en una sucesión rápida), podemos proteger las secciones críticas del código con una clase std::mutex.

void AudioEngine::restart(){

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

Eso es todo en cuanto a motor de audio, y no hay mucho más para hacer…

Necesitamos un método para que , en Java, nuestra IU se comunique con nuestras clases C++ y es aquí donde utilizaremos JNI. Es posible que las firmas de métodos no sean la mejor opción, pero por suerte solo hay tres.

Cambia el nombre del archivo native-lib.cpp por jni-bridge.cpp. Puedes dejar el nombre del archivo tal como está, pero es necesario aclarar que este archivo C++ es para métodos de JNI. Asegúrate de actualizar el nombre del archivo CMakeLists.txt (sin modificar el nombre de la biblioteca, native-lib).

Agrega el siguiente código 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();
}

}

Nuestro puente JNI es bastante simple:

  • Creamos una instancia estática de nuestro AudioEngine.
  • Las funciones startEngine() y stopEngine() inician y detienen el motor de audio.
  • touchEvent() convierte eventos táctiles en llamadas de métodos para activar y desactivar el tono.

Finalmente, crearemos nuestra IU y la aplicaremos en nuestro backend.

Diseño

Nuestro diseño es muy simple (lo mejoraremos en codelabs posteriores). Es solo una clase FrameLayout con una clase TextView en el centro:

4a039cdf72e486f.png

Actualiza la clase res/layout/activity_main.xml de la siguiente manera:

<?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>

Agrega nuestro recurso de strings de @string/tap_anywhere a res/values/strings.xml:

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

Actividad principal

Ahora actualiza la clase MainActivity.java con el siguiente 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();
    }
}

Este código realiza las siguientes acciones:

  • Todos los métodos private native void se definen en jni-bridge.cpp. Necesitamos declararlos aquí para poder usarlos.
  • Los eventos de ciclo de vida de la actividad onCreate() y onDestroy() llaman al puente JNI para iniciar y detener el motor de audio.
  • Anulamos el método onTouchEvent() para recibir todos los eventos táctiles de nuestra Activity y pasarlos directamente al puente JNI a fin de activar y desactivar el tono.

Enciende tu dispositivo o emulador de prueba y ejecutar la app de WaveMaker. Cuando presiones la pantalla, deberías escuchar una onda sinusoidal clara.

Claramente, nuestra app no va a ganar premios a la creatividad musical, pero debería demostrar las técnicas fundamentales necesarias para producir audio sintetizado y de baja latencia en Android.

No te preocupes, en los siguientes codelabs haremos una app mucho más interesante. Gracias por completar este codelab. Si tienes preguntas, puedes hacerlas en el grupo android-ndk.

Lecturas adicionales

Muestras de audio de alto rendimiento

Guía de audio de alto rendimiento en la documentación del NDK de Android

Prácticas recomendadas para videos de audio de Android: Google I/O 2017