Créer un échantillonneur audio

Dans cet atelier de programmation, nous allons créer un échantillonneur audio. L'appli enregistre et lit des données audio issues du micro intégré au téléphone.

Elle enregistre jusqu'à 10 secondes d'audio lorsque vous maintenez le bouton Record (Enregistrer) enfoncé. Lorsque vous maintenez le bouton Play (Lecture) enfoncé, l'appli lit une fois la piste audio enregistrée. Vous pouvez également activer l'option Loop (Boucle) pour lire l'enregistrement en boucle jusqu'à ce que vous relâchiez le bouton Play (Lecture). Chaque fois que vous appuyez sur le bouton Record (Enregistrer), l'enregistrement audio précédent est écrasé.

7eb653b71774dfed.png

Points abordés

  • Concepts de base pour créer un flux d'enregistrement à faible latence
  • Comment stocker et lire des données audio enregistrées depuis un micro ?

Conditions préalables

Avant de commencer cet atelier de programmation, nous vous recommandons de suivre l'atelier de programmation Créer un synthétiseur. Il aborde certains concepts de base non traités ici pour créer des flux audio.

Prérequis

Notre appli d'échantillonnage comporte quatre composants :

  • Interface utilisateur : écrite en Java, la classe MainActivity est chargée de recevoir les événements tactiles et de les transférer au pont JNI.
  • Pont JNI : ce fichier C++ utilise JNI pour fournir un mécanisme de communication entre l'interface utilisateur et les objets C++. Il transmet les événements de l'interface utilisateur au moteur audio.
  • Moteur audio : cette classe C++ crée les flux audio d'enregistrement et de lecture.
  • Enregistrement audio : cette classe C++ stocke les données audio en mémoire.

Voici l'architecture :

a37150c7e35aa3f8.png

Cloner le projet

Clonez le dépôt de l'atelier de programmation sur github.

git clone https://github.com/googlecodelabs/android-wavemaker2

Importer un projet dans Android Studio

Ouvrez Android Studio et importez le projet :

  • File -> New -> Import project... (Fichier -> Nouveau -> Importer le projet…)
  • Sélectionnez le dossier android-wavemaker2.

Exécuter le projet

Choisissez la configuration d'exécution de base.

f65428e71e9bdbcf.png

Appuyez ensuite sur CTRL+R pour compiler et exécuter le modèle d'application. Il devrait pouvoir être compilé et exécuté, mais n'est pas encore fonctionnel. Vous lui ajouterez des fonctionnalités au cours de cet atelier de programmation.

Ouvrir le module de base

Les fichiers sur lesquels vous allez travailler pour cet atelier de programmation sont stockés dans le module base. Développez ce module dans la fenêtre du projet en veillant à sélectionner l'affichage Android.

cae7ee7b54407790.png

Remarque : Le code source terminé de l'application WaveMaker2 se trouve dans le module final.

L'objet SoundRecording représente les données audio enregistrées dans la mémoire. Il permet à l'appli d'écrire dans la mémoire des données issues du micro et de les lire.

Commençons par déterminer comment stocker ces données audio.

Sélectionner un format audio

Nous devons d'abord choisir un format audio pour nos échantillons. Deux formats sont pris en charge par AAudio :

  • float : virgule flottante à simple précision (4 octets par échantillon)
  • int16_t : entiers de 16 bits (2 octets par échantillon)

Pour obtenir un son de bonne qualité à faible volume et pour d'autres raisons, nous utilisons des échantillons float. Si votre capacité de mémoire n'est pas suffisante, vous pouvez sacrifier la fidélité et gagner de l'espace en utilisant des entiers de 16 bits.

Choisir la quantité d'espace de stockage nécessaire

Supposons que nous souhaitions stocker 10 secondes de données audio. Avec un taux d'échantillonnage de 48 000 échantillons par seconde, correspondant au taux d'échantillonnage le plus courant sur les appareils Android modernes, il faut allouer de la mémoire pour 480 000 échantillons.

Ouvrez le fichier base/cpp/SoundRecording.h et définissez cette constante en haut du fichier.

constexpr int kMaxSamples = 480000; // 10s of audio data @ 48kHz

Définir le tableau de stockage

Nous disposons maintenant de toutes les informations dont nous avons besoin pour définir un tableau de valeurs float. Ajoutez la déclaration suivante à SoundRecording.h :

private:
    std::array<float,kMaxSamples> mData { 0 };

Le { 0 } utilise l'initialisation agrégée pour définir toutes les valeurs du tableau sur 0.

Implémenter write

Le prototype de la méthode write est le suivant :

int32_t write(const float *sourceData, int32_t numFrames);

Cette méthode reçoit un tableau d'échantillons audio dans sourceData. La taille du tableau est spécifiée par numFrames. La méthode doit afficher le nombre d'échantillons qu'elle écrit réellement.

Vous pouvez l'implémenter en stockant le prochain indice d'écriture disponible. Au départ, sa valeur est de 0 :

9b3262779d7a0a8c.png

Une fois l'échantillon écrit, on passe à l'indice d'écriture suivant :

2971acee93b9869d.png

Cette méthode peut facilement être implémentée en tant que boucle for. Ajoutez le code suivant à la méthode write dans SoundRecording.cpp.

for (int i = 0; i < numSamples; ++i) {
    mData[mWriteIndex++] = sourceData[i];
}
return numSamples;

Mais attendez… Et si jamais nous essayons d'écrire plus d'échantillons que nous ne pouvons en stocker ? Ça ne va pas aller ! Une erreur de segmentation apparaîtra, résultant d'une tentative d'accès à un indice de tableau hors limites.

Ajoutons une option de vérification qui modifie numSamples si mData n'a pas assez d'espace. Ajoutez ce qui suit au-dessus du code existant.

if (mWriteIndex + numSamples > kMaxSamples) {
    numSamples = kMaxSamples - mWriteIndex;
}

Implémenter read

La méthode read est semblable à write. Nous stockons le prochain indice de lecture.

488ab2652d0d281d.png

Puis, nous l'incrémentons de 1 après la lecture de chaque échantillon.

1a7fd22f4bbb4940.png

Nous répétons cette opération jusqu'à ce que le nombre d'échantillons demandés ait été lu. Que se passe-t-il lorsque la limite des données disponibles est atteinte ? Deux comportements sont possibles :

  • Si la lecture en boucle est activée : l'indice de lecture est remis à zéro (le début du tableau de données).
  • Si la lecture en boucle est désactivée : il ne se passe rien, on n'incrémente pas l'indice de lecture.

789c2ce74c3a839d.png

Pour ces deux comportements, nous devons savoir à quel moment la limite des données disponibles a été atteinte. Heureusement, c'est à cela que sert mWriteIndex. Il indique le nombre total d'échantillons qui ont été écrits dans le tableau de données.

Cela nous permet d'implémenter la méthode read dans SoundRecording.cpp.

int32_t framesRead = 0;
while (framesRead < numSamples && mReadIndex < mWriteIndex){
    targetData[framesRead++] = mData[mReadIndex++];
    if (mIsLooping && mReadIndex == mWriteIndex) mReadIndex = 0;
}
return framesRead;

AudioEngine effectue les tâches principales suivantes :

  • Créer une instance de SoundRecording
  • Créer un flux d'enregistrement pour enregistrer des données depuis le micro
  • Écrire les données enregistrées dans l'instance SoundRecording dans le rappel du flux d'enregistrement
  • Créer un flux de lecture pour lire les données enregistrées
  • Lire les données enregistrées à partir de l'instance SoundRecording dans le rappel du flux de lecture
  • Répondre aux événements d'interface utilisateur pour l'enregistrement, la lecture simple et la lecture en boucle

Commençons par créer l'instance SoundRecording.

Démarrez avec quelque chose de facile. Créez une instance de SoundRecording dans AudioEngine.h :

private:
    SoundRecording mSoundRecording;

Nous avons deux flux à créer : le flux de lecture et le flux d'enregistrement. Lequel faut-il créer en premier ?

Il faut d'abord créer le flux de lecture, car il ne présente qu'un seul taux d'échantillonnage, et celui-ci permet d'obtenir la latence la plus faible. Une fois créé, nous pouvons renseigner son taux d'échantillonnage dans l'outil de création de flux d'enregistrement. Cela permet de nous assurer que les flux de lecture et d'enregistrement disposent du même taux d'échantillonnage, ce qui signifie que nous n'aurons pas de travail de rééchantillonnage à faire entre les flux.

Propriétés du flux de lecture

Insérez les propriétés suivantes dans le StreamBuilder, qui créera le flux de lecture :

  • Direction : non spécifiée, réglée par défaut sur "sortie"
  • Taux d'échantillonnage : non spécifié, réglé par défaut sur le taux avec la latence la plus faible
  • Format : float
  • Nombre de canaux : 2 (stéréo)
  • Mode de performance : faible latence
  • Mode de partage : exclusif

Créer le flux de lecture

Nous avons désormais tout ce qu'il nous faut pour créer et ouvrir le flux de lecture. Ajoutez le code suivant tout en haut de la méthode start dans AudioEngine.cpp.

// Create the playback stream.
StreamBuilder playbackBuilder = makeStreamBuilder();
AAudioStreamBuilder_setFormat(playbackBuilder.get(), AAUDIO_FORMAT_PCM_FLOAT);
AAudioStreamBuilder_setChannelCount(playbackBuilder.get(), kChannelCountStereo);
AAudioStreamBuilder_setPerformanceMode(playbackBuilder.get(), AAUDIO_PERFORMANCE_MODE_LOW_LATENCY);
AAudioStreamBuilder_setSharingMode(playbackBuilder.get(), AAUDIO_SHARING_MODE_EXCLUSIVE);
AAudioStreamBuilder_setDataCallback(playbackBuilder.get(), ::playbackDataCallback, this);
AAudioStreamBuilder_setErrorCallback(playbackBuilder.get(), ::errorCallback, this);

aaudio_result_t result = AAudioStreamBuilder_openStream(playbackBuilder.get(), &mPlaybackStream);

if (result != AAUDIO_OK){
   __android_log_print(ANDROID_LOG_DEBUG, __func__,
                       "Error opening playback stream %s",
                       AAudio_convertResultToText(result));
   return;
}

// Obtain the sample rate from the playback stream so we can request the same sample rate from
// the recording stream.
int32_t sampleRate = AAudioStream_getSampleRate(mPlaybackStream);

result = AAudioStream_requestStart(mPlaybackStream);
if (result != AAUDIO_OK){
   __android_log_print(ANDROID_LOG_DEBUG, __func__,
                       "Error starting playback stream %s",
                       AAudio_convertResultToText(result));
   closeStream(&mPlaybackStream);
   return;
}

Notez que les modèles de méthode pour les rappels de données et d'erreurs ont été créés pour vous. Si vous avez besoin d'un récapitulatif sur leur fonctionnement, consultez le premier atelier de programmation.

Enregistrer les propriétés du flux

Utilisez les propriétés suivantes pour créer le flux d'enregistrement :

  • Direction : entrée (l'enregistrement est une entrée, tandis que la lecture est une sortie)
  • Taux d'échantillonnage : identique au flux de sortie
  • Format : float
  • Nombre de canaux : 1 (mono)
  • Mode de performance : faible latence
  • Mode de partage : exclusif

Créer le flux d'enregistrement

Ajoutez à présent le code suivant sous le code précédemment ajouté dans start.

// Create the recording stream.
StreamBuilder recordingBuilder = makeStreamBuilder();
AAudioStreamBuilder_setDirection(recordingBuilder.get(), AAUDIO_DIRECTION_INPUT);
AAudioStreamBuilder_setPerformanceMode(recordingBuilder.get(), AAUDIO_PERFORMANCE_MODE_LOW_LATENCY);
AAudioStreamBuilder_setSharingMode(recordingBuilder.get(), AAUDIO_SHARING_MODE_EXCLUSIVE);
AAudioStreamBuilder_setFormat(recordingBuilder.get(), AAUDIO_FORMAT_PCM_FLOAT);
AAudioStreamBuilder_setSampleRate(recordingBuilder.get(), sampleRate);
AAudioStreamBuilder_setChannelCount(recordingBuilder.get(), kChannelCountMono);
AAudioStreamBuilder_setDataCallback(recordingBuilder.get(), ::recordingDataCallback, this);
AAudioStreamBuilder_setErrorCallback(recordingBuilder.get(), ::errorCallback, this);

result = AAudioStreamBuilder_openStream(recordingBuilder.get(), &mRecordingStream);

if (result != AAUDIO_OK){
   __android_log_print(ANDROID_LOG_DEBUG, __func__,
                       "Error opening recording stream %s",
                       AAudio_convertResultToText(result));
   closeStream(&mRecordingStream);
   return;
}

result = AAudioStream_requestStart(mRecordingStream);
if (result != AAUDIO_OK){
   __android_log_print(ANDROID_LOG_DEBUG, __func__,
                       "Error starting recording stream %s",
                       AAudio_convertResultToText(result));
   return;
}

Passons maintenant à la partie amusante : stocker dans l'objet SoundRecording les données enregistrées depuis le micro.

Lors de la création du flux d'enregistrement, nous avions défini le rappel de données comme ::recordingDataCallback. Cette méthode appelle AudioEngine::recordingCallback, dont le prototype est le suivant :

aaudio_data_callback_result_t AudioEngine::recordingCallback(float *audioData,
                                                            int32_t numFrames)

Les données audio sont fournies dans audioData.. Leur taille (en échantillons) est de numFrames, car il n'y a qu'un échantillon par trame étant donné que notre enregistrement est en mono.

Il suffit de procéder comme suit :

  • Consulter mIsRecording pour savoir s'il faut procéder à l'enregistrement
  • Sinon, ignorer les données entrantes
  • Si l'on est en train d'enregistrer :
  • Fournir audioData à SoundRecording à l'aide de sa méthode write
  • Vérifier la valeur renvoyée par write (si elle est égale à zéro, cela signifie que SoundRecording est plein et qu'il faut arrêter l'enregistrement)
  • Renvoyer AAUDIO_CALLBACK_RESULT_CONTINUE pour que les rappels continuent

Ajoutez le code suivant à recordingCallback :

if (mIsRecording) {
    int32_t framesWritten = mSoundRecording.write(audioData, numFrames);
    if (framesWritten == 0) mIsRecording = false;
}
return AAUDIO_CALLBACK_RESULT_CONTINUE;

À l'instar du flux d'enregistrement, le flux de lecture appelle playbackDataCallback lorsqu'il a besoin de nouvelles données. Cette méthode appelle AudioEngine::playbackCallback,, dont le prototype est le suivant :

aaudio_data_callback_result_t AudioEngine::playbackCallback(float *audioData, int32_t numFrames)

Dans cette méthode, nous devons :

  • Remplir le tableau avec des zéros en utilisant fillArrayWithZeros
  • Si les données enregistrées sont en cours de lecture (indiqué par mIsPlaying), il faut procéder comme suit :
  • Lire numFrames de données à partir de mSoundRecording
  • Passer audioData de mono à stéréo en utilisant convertArrayMonoToStereo
  • Si le nombre de trames lues est inférieur au nombre de trames demandées, c'est que la limite des données enregistrées a été atteinte (arrêter la lecture en définissant mIsPlaying sur false)

Ajoutez le code suivant à playbackCallback :

fillArrayWithZeros(audioData, numFrames * kChannelCountStereo);

if (mIsPlaying) {
   int32_t framesRead = mSoundRecording.read(audioData, numFrames);
   convertArrayMonoToStereo(audioData, framesRead);
   if (framesRead < numFrames) mIsPlaying = false;
}
return AAUDIO_CALLBACK_RESULT_CONTINUE;

Démarrer et arrêter l'enregistrement

La méthode setRecording permet de démarrer et d'arrêter l'enregistrement. Voici son prototype :

void setRecording(bool isRecording)

Lorsque l'on appuie sur le bouton d'enregistrement, isRecording a la valeur true. Lorsque le bouton est relâché, isRecording est défini sur false.

Une variable membre mIsRecording permet d'activer/de désactiver le stockage des données dans recordingCallback. Il suffit de définir sa valeur sur isRecording.

Il y a un autre comportement à ajouter. Lorsque l'enregistrement démarre, toutes les données existantes dans mSoundRecording doivent être écrasées. Pour ce faire, utilisez clear afin de réinitialiser sur zéro l'indice d'écriture de mSoundRecording.

Voici le code pour setRecording :

if (isRecording) mSoundRecording.clear();
mIsRecording = isRecording;

Démarrer et arrêter la lecture

Le contrôle de lecture est semblable à celui de l'enregistrement. La fonction setPlaying est appelée lorsque l'utilisateur appuie sur le bouton de lecture ou bien le relâche. La variable membre mIsPlaying active/désactive la lecture dans playbackCallback.

Lorsque l'utilisateur appuie sur le bouton de lecture, il faut que la lecture démarre au début des données audio enregistrées. Pour ce faire, vous pouvez utiliser setReadPositionToStart, qui vous permet de réinitialiser la tête de lecture de mSoundRecording sur zéro.

Voici le code pour setPlaying :

if (isPlaying) mSoundRecording.setReadPositionToStart();
mIsPlaying = isPlaying;

Activer/Désactiver la lecture en boucle

Enfin, lorsque l'option LOOP (Boucle) est activée ou désactivée, la commande setLooping est appelée. La manipulation de cette méthode est simple. Il suffit de transmettre l'argument isOn à mSoundRecording.setLooping :

Voici le code pour setLooping :

mSoundRecording.setLooping(isOn);

Les applications qui enregistrent des données audio doivent demander l'autorisation RECORD_AUDIO à l'utilisateur. Une grande partie du code gérant les autorisations est déjà écrite. Toutefois, il convient encore de déclarer que notre appli utilise cette autorisation.

Ajoutez la ligne suivante au fichier manifests/AndroidManifest.xml dans la section <manifest> :

<uses-permission android:name="android.permission.RECORD_AUDIO" />

Il est temps de voir si votre dur labeur s'est avéré payant. Compilez et exécutez l'application. L'interface utilisateur suivante doit s'afficher.

7eb653b71774dfed.png

Appuyez sur le bouton rouge pour lancer l'enregistrement. L'enregistrement se poursuit tant que vous maintenez le bouton enfoncé (jusqu'à 10 secondes). Appuyez sur le bouton vert pour lire les données enregistrées. La lecture se poursuit tant que vous maintenez le bouton enfoncé, jusqu'à la fin des données audio. Si l'option LOOP (Boucle) est activée, les données audio sont lues en boucle indéfiniment.

Complément d'informations

Échantillons audio haute performance

Guide sur les applications audio haute performance disponible dans la documentation Android NDK

Bonnes pratiques pour les contenus audio et vidéo sous Android – Google I/O 2017