Audio Capture
Audio Capture est un prototype pour permettre à Unreal Engine d'envoyer le son capturé du microphone, pour faire du VoIP.
Introduction
Ce prototype survient durant la création du premier démonstrateur. Nous avons vite remarqué qu'il y avait un souci d'architecture avec l'audio. En effet, lorsque l'on chargeait les webviews sur Unreal Engine pour afficher les utilisateurs présents dans un salon, l'audio du client Unreal Engine n'était pas envoyé. Pouvant voir les autres clients et les entendre, les clients Unreal sont dans l'incapacité de partager le son de leur microphone.
Il a donc fallu revoir l'entièreté de l'architecture audio.
Etapes
Recherche de l'architecture
L'audio passe différemment selon le client :
Sur Web, toutes les secondes on envoie le flux audio récupéré et on l'envoie directement à l'API qui gère l'envoie à son tour à Icecast2. On a donc Web -> API -> MediaServer
Sur le client Unreal Engine il faut récupérer le flux audio et l'envoyer à l'API de la même façon que le web, si possible en théorie. Sauf que nous avons un souci de taille, c'est que le client Web a des méthodes existantes qui permettent de récupérer l'audio là où en C++, il n'existe pas vraiment de méthodes pour le faire de la même façon que le Web.
Voici les différentes itérations de l'architecture :
1 - Serveur Unreal qui fait office de passerelle entre les clients Unreal et l'API. Malheureusement on a vite abandonné cette idée car le serveur Unreal ne doit pas gérer les flux audio.
[Schéma 1]
2 - Créer un service Media qui gérerait tous les flux audio et vidéo. Un peu à l'image de l'API, ici le but serait de décentraliser les informations et concentrer entièrement tous les flux médias dans ce service. C'est l'architecture que nous avons choisie.
[Schéma 2]
Recherche des technologies
En C++ récupérer un flux audio peut être contraignant. Il fallait donc rechercher la technologie qu'il nous fallait pour récupérer le flux audio du microphone.
Plusieurs choix se sont portés à nous : l'API Windows, des bibliothèques externes comme PortAudio, LibSDL, etc.
Pour des raisons techniques nous avons commencé avec PortAudio, bibliothèque en C qui récupère le flux du microphone.
Il peut être compliqué de comprendre PortAudio et la façon dont ça fonctionne, nous avons donc aussi testé LibSDL pour voir la différence.
Au départ nous pensions que LibSDL ferait l'affaire et serait assez efficace pour fonctionner rapidement. Mais nous avons rapidement vu que les choix étaient limités et que PortAudio semblait plus intéressant pour manipuler les données reçues.
Comprendre l'audio
Pour aller plus loin, il faut comprendre comment l'audio fonctionne. C'est cette partie qui a été difficile pour nous.
L'audio récupéré (dans notre exemple) avec PortAudio est un tableau de short (16 bits) et ce tableau contient valeurs comprises entre 0 et X, plus X est élevé, plus l'audio écouté sera fort.
L'audio possède une fréquence ainsi qu'un sample. La fréquence est simplement le nombre d'éléments enregistrés en une seconde tandis que le sample est un nombre précis d'octets qui comporte tant de temps d'enregistrement.
Pour faire simple pour une fréquence de 48000 Hz, un exemple de sample est 480 * 2 (si stéréo).
Tous les calculs de bits et d'octets sont nécessaires pour bien redistribuer le son dans le bon ordre et en bonne qualité.
Chaque élément récupéré est ce qu'on appelle du PCM (Pulse-Code Modulation), il n'y donc ni entête, ni format, ce n'est qu'une succession de chiffres qui compose le son. Pour le rendre compréhensible il y a deux solutions : soit trouver un outil qui permettrait de comprendre le PCM soit de l'encoder.
Opus et AAC
Nous avons testé rapidement Opus pour encoder comme sur le web l'audio avec le même codec. Malheureusement, bien qu'Opus se marie bien avec PortAudio, l'encodeur n'est pas lisible avec l'outil Ffmpeg de notre API. Nous avons donc testé un autre codec : AAC (Avanced-Audio Coding) mais sans grand résultat.
Nous avons donc décidé d'abandonner les encodeurs et de se concentrer sur l'envoi du PCM à l'outil Ffmpeg.
Liquidsoap et les radioservers
Une des alternatives pour récupérer les données PCM, serait d'utiliser des radioservers. Un radioserver est un serveur radio qui propose de récupérer un flux audio et de le redistribuer à plusieurs clients, cela peut passer par un serveur média.
En théorie, cela pouvait être ce que nous recherchions. Nous avons trouvé un radioserver sous le nom de Liquidsoap qui semblait être prometteur.
Liquidsoap est un radio server qui propose de se rattacher entre autre à Icecast. Ce qui nous intéresse en soi.
Seulement après plusieurs recherches notamment sur l'architecture de Liquidsoap, cela ne peut pas nous convenir étant donné l'impossibilité d'envoyer plusieurs flux audio en même temps au radioserver. Liquidsoap ne permet que de prendre un seul flux audio et de le redistribuer à tout le monde, sauf que dans notre exemple nous gérons plusieurs flux audio simultanément et il n'est pas vraiment très intéressant en terme d'efficacité de lancer un radioserver par client, d'où le fait d'abandonner cette technologie.
Premières conclusions...
Premièrement, sortir l'audio d'Unreal Engine ne semble pas possible sans avoir recours à des méthodes qui nécessitent une couche basse d'abstraction. C'est-à-dire utiliser des technologies qui permettent d'utiliser directement le microphone et en extraire les informations. Ici, en C pour récupérer des données brutes.
Deuxièmement, les données récupérées doivent pouvoir être mises dans des paquets que le programme qui les reçoit puisse les traiter. Ici, les données brutes sont en PCM, qui représentent les valeurs en nombre de l'audio. Ces valeurs doivent par exemple être encodées pour pouvoir être réutilisées.
Troisièmement, la création de Threads dans Unreal Engine et l'architecture en général. Unreal Engine a sa propre boucle de jeu, celle qui fait tourner le jeu principal, y ajouter la capture de l'audio dedans c'est bloquer cette boucle de jeu principale. Il est donc nécessaire d'y créer des threads natifs d'Unreal Engine, des FRunnable. Cependant, les Threads de Unreal Engine nécessitent une approche plus théorique sur comment bien les placer car il n'est pas possible de discuter avec les threads enfants.
Dernièrement, le serveur qui reçoit l'audio doit être capable de comprendre les buffers envoyés chaque seconde de la même façon que le Web l'envoie. Le mediarecorder du Web récupère un tableau de bytes qui contient l'information, cependant, sur Unreal il est nécessaire de copier ce système car il fonctionne assez bien. Ce qui est une difficulté supplémentaire étant donné que les données reçues ne sont pas toujours comprises.
Retour au début
Après plusieurs semaines de réflexion, nous avons réessayé étape par étape d'encoder du PCM avec Opus.
Pour ce faire, nous avons utilisé PortAudio et libopus.
PortAudio est assez rapide à initialiser, nous avons donc réussi à mettre en place un petit système qui permet d'enregistrer le PCM dans un fichier pcm et le lire avec Audacity. Et cela fonctionne.
Ensuite, il fallait encoder avec Opus le pcm reçu. Pour le coup, encoder avec Opus n'est pas aussi simple qu'on puisse le penser. Il nous a fallu énormément de tests, et d'itérations pour parvenir à notre but.
Finalement, nous avons réussi à faire fonctionner Opus et en faire un fichier .opus lisible et réécoutable. C'est grâce à ce lien en partie : https://github.com/xiph/opus/blob/master/tests/test_opus_encode.c
Mais encoder du PCM en opus ne permet toujours pas à une machine de savoir quoi en faire, il doit y avoir des headers, des informations concernant les paquets.
Nous avons donc recherché un format de fichier compatible en stream et compatible avec opus. Nous avons donc choisi : ogg qui semble parfait pour ça.
Il existe une bibliothèque externe en C qui se nomme libogg qui permet de paquetter nos informations dans les standards de ogg.
Spécifications OGG et OPUS
Lien de la documentation officielle de ogg, toutes les informations écrites ici viennent de ce site : https://www.xiph.org/ogg/doc/.
Nous allons résumer ici les informations essentielles dans notre cas.
Dès que l'on a récupéré le PCM et qu'on l'a encodé avec Opus, il faut désormais formater proprement les informations. Pour ce faire, il faut ajouter des headers, mettre dans des pages Ogg les informations et voilà.
Il y a deux headers pour Ogg/Opus (il existe plusieurs autres codecs audio avec Ogg, ici on parle bien de OggOpus qui a ses propres spécifications). Le premier header est le header d'identification (Identification Header).
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 'O' | 'p' | 'u' | 's' |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 'H' | 'e' | 'a' | 'd' |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Version = 1 | Channel Count | Pre-skip |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Input Sample Rate (Hz) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Output Gain (Q7.8 in dB) | Mapping Family| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ :
| |
: Optional Channel Mapping Table... :
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Celui-ci se décompose ainsi :
- Magic Signature : contient les caractères d'authentification du codec, lisible pour tout humain. Chaque caractère sur 8 bits donc 8x8 = 64 bits = 8 octets. Doit être mis dans l'ordre.
- Version : sur 8 bits, d'après la documentation il est demandé que le chiffre soit toujours à 1, dans notre cas il doit être à 1.
- Channel count : le nombre de canal de sortie. 1 pour mono, 2 pour stéréo... Ici tous nos tests ont été faits sur du stéréo donc 2. Sur 8 bits aussi.
- Pre-skip : le pre-skip est la valeur qui permet d'éviter de démarrer le flux trop tôt, c'est la partie à décaler lors de la lecture. C'est le décalage entre la lecture et les informations du fichier, dans notre cas notre header est à 312 bits. Cela permet d'éviter aux lecteurs de lire les headers comme une donnée son. Sur 16 bits.
- Input Sample Rate : la fréquence du PCM enregistré, ici nous faisons tout sur du 48000 Hz. Sur 32 bits.
- Output Gain : Gain du son en dB à ajouter lors du décodage. Nous l'avons laissé à 0 car nous ne voulons pas effectuer de modification sur l'intensité du son. Sur 16 bits.
- Channel Mapping Family : Sur 8 bits. Correspond dans l'ordre la définition de la sortie audio. Inutilisée dans notre cas.
- Optional Channel Mapping Table : Inutilisé dans notre cas.
Ce premier header est à envoyer qu'une seule fois, il permet de définir les informations essentielles que les lecteurs vont traiter. Cependant, il y a un deuxième header à établir selon les spécifications Opus/Ogg, celui-ci se nomme le Tag Header ou Comment Header.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 'O' | 'p' | 'u' | 's' |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 'T' | 'a' | 'g' | 's' |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Vendor String Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
: Vendor String... :
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| User Comment List Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| User Comment #0 String Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
: User Comment #0 String... :
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| User Comment #1 String Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
: :
Voici quelques explications sur les éléments importants de ce header :
- Magic Signature : toujours sur 8 octets (64 bits), contient le nom d'identification du header.
- Vendor String Length : sur 32 bits. Contient la taille de la string Vendor String.
- Vendor String : taille dynamique. Correspond au nom de celui qui encode, donc chaîne de charactères au choix, lisible pour tout humain.
- User Comment List String Length : 32 bits. Contient le nombre des commentaires utilisateurs total.
Ce second header permet d'ajouter des informations sur l'encodeur qui permettent à ceux qui lisent le fichier d'avoir plus d'informations dessus.
Une fois la mise en place des deux headers, il faut empaqueter tout ça dans des pages Ogg.
Ogg découpe ses paquets en page qui contiennent toutes les informations concernant les paquets.

Les pages Ogg ont leur propre header et leur propre taille. En théorie, une page Ogg ne doit pas dépasser 64kB environ.
Chaque page ogg contient donc un header qui correspond à ça :
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 'O' | 'g' | 'g' | 'S' |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Version | Type Flag | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ :
| Absolute Granule Position |
: +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| | Stream :
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
: Serial Number | Page :
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
: Sequence n° | Page :
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
: Checksum | Page Segments | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ :
| |
: Segment tables... :
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
: :
Liste des éléments :
- Magic Signature : Chaque paquet de Ogg est contenu dans une page, le magic signature est son identification. Sur 4 octets.
- Stream Struct Version : La version de la structure. Sur 1 octet.
- Header Type Flag : Contextualise la page. (0 par défaut, 1 pour page suivante, 2 pour première page, 4 pour dernière de la page). Sur 1 octet.
- Absolute Granule Position : sur 8 octets. Correspond à la position encodée après tous les paquets finis sur la page.
- Stream Serial Number : Permet d'authentifier quel flux correspond au quel. Sur 4 octets.
- Page Sequence N° : numéro de page actuelle. Sur 4 octets.
- Page Checksum : 32 bits, CRC value, générée par défaut (voir http://www.ross.net/crc/download/crc_v3.txt pour comprendre l'algorithme).
- Page Segments : Sur 1 octet, correspond au nombre d'entrées dans chaque segment table.
- Segment table : Contient les paquets dans l'ordre.
ID Header et Comment Header doivent être mis dans des pages à part et envoyés en premier.
Ainsi, on a donc dans l'ordre à envoyer :
[OggS[ID Header]] -> [OggS[Comment Header]] -> [OggS[Data]]
Normalement, avec ça, il est possible d'enregistrer le flux dans un fichier audio et d'avoir le son correctement transmis.
Dans un premier temps, nous avons testé uniquement avec des fichiers. Puis pour aller plus loin, nous avons commencé à envoyer les paquets à Ffmpeg.
OggOpus et ffmpeg
Maintenant que les fichiers sont corrects, il n'y a plus qu'une étape : celle de faire du streaming via ffmpeg et notre serveur RTMP.
Cela fonctionne car le stream est correct, après quelques optimisations sur les paquets, on est passé d'un overhead à 10% à 1,4% environ, ce qui est similaire à l'encodeur de ffmpeg.
Malheureusement, un des problèmes majeurs à ce jour est le chargement du stream. Le stream fonctionne mais n'importe quelle personne qui veut écouter le stream, se retrouve avec une forte latence. N'importe qui qui écoute le stream le charge toujours à son point initial : c'est-à-dire à son tout début même si le stream dure depuis plusieurs minutes. Cela est un problème majeur étant donné que si quelqu'un lance une conversation et que celle-ci dure des heures, la personne qui rejoint devra écouter le stream des heures avant (le point initial donc).
Pistes possibles
Suite à cette problématique, nous devons comprendre et isoler chaque compartiment de façon à trouver la source du problème.
- Vérifier un paramètre dans les headers de Ogg, il est probable qu'il y ait quelque chose à spécifier, le problème pour nous viendrait principalement de nous car c'est la seule vraie différence que nous avons avec d'autres fichiers ;
- Sur le web, il n'y a pas ce problème, est-ce Ogg et Opus sont incompatibles en streaming ? La réponse est non car il existe sur le site de xiph.org des radios OggOpus qui n'ont pas ce problème ;
- Le serveur média peut en être en cause même si on ne comprend pas pourquoi il y aurait une différence avec le web, le serveur média ne fait que de distribuer le flux ;
- Ffmpeg ne fait que de transcoder le flux en flv pour le flux en temps réel, est-ce un problème de paramètres ? On ne pense pas que ffmpeg soit la source du problème.
- Il est probable que ce soit aussi le lecteur de test qui génère ce problème (VLC), mais cela ne le fait pas avec WebM, alors il est probable que le problème ne vienne pas d'ici.
Après les pistes possibles
Nous avons testé toutes les pistes concernées une par une et il s'avère que le problème vienne du format Ogg lui-même qui dans son état actuel n'est pas fait pour ajouter des seekpoints (points de "chargement").
Du coup, l'option serait de revenir en arrière, retirer opus, retirer libogg et d'installer les bibliothèques (libav*) sur Unreal Engine. Cette bibliothèque est un fork de ffmpeg, on peut donc théoriquement faire du ffmpeg sans avoir l'outil d'installé sur la machine. La solution serait donc d'utiliser cette bibliothèque pour encoder rapidement en Webm les flux audio et avoir exactement la même sortie que le client Web. Si sur le Web cela fonctionne, alors il n'y a pas de raisons pour que ça ne fonctionne pas côté client.
Intégration dans Unreal Engine
Les problèmes d'audio sont assez spécifiques et la compréhension de libav (ffmpeg) prendrait trop de temps. Nous nous sommes donc concentrés sur l'audio (avec ses problèmes existants) pour l'intégrer à Unreal Engine.
Premièrement, il a fallu intégrer les bibliothèques externes dans l'éditeur de jeu. Pour ce faire, il suffit de les importer et les compiler.
Une fois cette étape faite, nous avons commencé l'intégration en les ajoutants dans des composants et à la logique du programme.
Globalement l'intégration s'est bien faite, mais quelques soucis techniques ont été rencontrés notamment avec le fait de séparer chaque lib dans un module. Ces 3 modules (ogg/opus/portaudio) font partie d'un plugin AudioApi. En rendant les classes le plus large possible dans leur utilisation, cela permet de les rendre indépendantes des autres modules du plugin. Par exemple, PortAudio peut s'exécuter sans Ogg et Opus. Le problème avec cette façon de faire est que nous avons eu des soucis dans la retransmission de l'audio qui ne s'effectuait pas correctement : pour cause la transmission des données entre les différentes classes.
Pour éviter une longue perte de temps, nous avons simplement repris un projet qui fonctionnait avec Unreal (un prototype fait par nous pour tester l'intégration des libs externes).
En mettant l'audio dans un thread qui fonctionne en autonomie, cela évite de bloquer la boucle de jeu principale. De cette façon, l'audio peut être récupérée et envoyée à l'API média avec succès.
Le démonstrateur est prêt pour l'audio.
Audio et casque VR
Une autre interrogation est comment utiliser l'audio sur Casque VR ? Car à ce jour, nous n'avons pas la possibilité de récupérer le microphone du casque pour envoyer l'audio.
Quelques essais ont toutefois été effectués, notamment avec la tentative de compilation de la bibliothèque PortAudio. Sauf que PortAudio n'est pas fait pour être compilé avec Android car il n'a initialement pas prévu pour l'être. Il a donc fallu modifier les règles de compilation, les paramètres du type du binaire pour avoir une bibliothèque .so compatible linux/android.
La compilation a été réussie, on a pu obtenir un .so. Pour le build android astc ça a fonctionné, l'installation du casque aussi mais le programme crash au démarrage ce qui fait que nous sommes bloqués à cette étape : un .so.2 non trouvé alors qu'il a été crée et relié.
Documentation technique
📄️ 1 - Buffer
Comprendre l'audio
📄️ 2 - Thread
Les Threads sur Unreal Engine écrasent celles en C++ par défaut (std::thread). Il faut donc utiliser les FRunnable et FRunnableThread proposés par le moteur de jeu.