Published on

Utiliser l'I2S avec le Raspberry Pico

Authors
  • Name
    Anthony Rabine

Le protocole I2S est utilisé pour généréer du son à partir d'un microcontrôlleur vers un DAC audio. Sauf que, le Raspberry Pico ne possède pas de périphérique I2S. Nous allons voir comment arriver à nos fins.

Introduction au protocole I2S

L'I2S est un protocole numérique dédié au transport d'échantillons musicaux vers et à partir de périphériques dédiés tels que des amplificateurs ou des micros. C'est un protocole à réserver pour des communications inter-composants électroniques à ne surtout pas sortir de la carte.

Avant propos sur le traitement numérique du son

Le son est une onde, elle peut avoir cette forme (temps en abscise, intensité en ordonnée) :

onde

Pour représenter le son sous forme numérique, on prend un échantillon à une fréquence définie (par exemple 44.1 KHz pour un CD).

Une onde échantillonnée ressemble à ça :

onde échantillonnée

On sélectionne un point à interval de temps régulier : on échantillonne. Un son est donc un ensemble de points (x, y). Le x n'est pas codé : comme la fréquence d'échantillonnage est connue, le x est simplement incrémenté de 1/f à chaque échantillon. Dans ce contexte, un signal audio numérique est une suite de valeurs, les intensités des points qui se suivent.

Dans le cas d'un signal stéréo :

stéréo

le signal numérique contient l'intensité d'un point du signal du gauche puis du point qui arrive au même moment mais pour le canal droit puis les données de l'échantillon suivant arrivent à la suite.

Signaux numériques

Un DAC va trasformer une information numérique (les échantillons de son) en analogique. Pour cela, il communique avec le microcontrôleur en I2S, un bus à trois signaux standard :

  • SCK ou BCLK : un signal d'horloge (Bit Clock Line)
  • FS (Frame Select) ou WS (Word Select, des fois appelé WSEL) : utilisé pour différencier la voie de gauche de la droite dans le cas d'un signal audio stéréoscopique
  • SD (Serial Data) ou DIN : les données audio à transférer

Le signal d'horloge a une fréquence dépendant du taux d'échantillonnage de votre son. La formule est la suivante :

Frequency = SampleRate x BitsPerChannel x numberOfChannels

Sans regarder en détail SD et SCK, on voit que l'I²S est une suite de bits qui représentent un point du signal de gauche puis un point du signal de droite. Le signal WS est haut pour sélectionner le canal droit puis bas pour sélectionner le canal gauche.

Si on zoom pour mieux voir SD et CK :

Signal I²S

CK est une horloge classique et chaque canal est codé sur 32 bits dans ce cas. Il est possible d'utiliser d'autres résolutions comme 16 ou 24 bits.

Le DAC détermine deux informations importantes en fonction de ces signaux :

  • La fréquence d'échantillonage utilisée (8 kHz, 44.1 kHz, etc.) : c'est la période de WS.
  • La résolution de chaque échantillon : c'est le nombre de coups d'horloge CK entre chaque front de WS.

Exemple de DAC

Voici par exemple la constitution d'un composant DAC assez populaire le PCM 5102 :

pcm5102

On voit bien la présence de l'I2S et les différents blocs pour transformer l'information numérique en analogique. S'ajoutent à cela plusieurs autres broches de configuration du DAC.

Modes de fonctionnement

La plupart des DAC supportent différents formats : la norme I2S officielle mais aussi d'autres variantes qui changent légèrement la façon dont les échantillons sont sérialisés sur la ligne de données.

On le voit ici :

pcm5102

C'est assez subtile mais en I2S le dernier bit est envoyé sur le front montant du canal suivant. En mode "left justified", c'est assez classique, le bit zéro commence dès le premier signal d'horloge.

Dans notre cas nous allons utiliser le mode I2S car c'est le standard le plus répendu.

Création d'un fichier de test

Pour créer un fichier .wav 16 bits, 2 canaux, 44.1 kHz avec ffmpeg, il faut avoir un fichier d'entrée (exemple : la_danse_des_canards.mp3) :

ffmpeg -i la_danse_des_canards.mp3 -sample_fmt s16 -ac 2 -ar 44100 out.wav

La carte choisie

La carte audio choisie est au format Pico et est fabriquée par Waveshare, c'est la Pico-Audio 2.1. Le composant DAC (Digital Analog Converter) est le CS4344. La carte peut fournir 2.6W par canal sur une impédance de 4 Ohms / 5V à l'aide d'un composant amplificateur APA2068.

SignalBrocheUsage
DINGPIO22Audio data input
MCLKGPIO26Chip main clock input
LRCKGPIO27Left Right Clock
SCLKGPIO28Audio data bit clock input

L'architecture proposée par Waveshare est un peu troublante ... et ne correspond pas énormément au schéma typique décrit dans la datasheet le l'amplificateur APA2068. La sortie casque n'est pas reliée à la sortie de l'ampli mais directement à la sortie du CS4344 et ne provite donc pas de la même amplification et surtout la sortie casque ne coupe pas la sortie enceintes lorsqu'un jack est inséré.

De plus, le CS4344 dispose d'un signal MCLK supplémentaire à ce que l'on peut trouver sur d'autres DAC qui se contentent de l'I2S classique à trois signaux.

Enfin, le choix des briches du Pico ont été faits en dépits du bon sens : les 4 signaux choisis prennent le port ADC du Pico, ce qui rend impossible l'usage de ces derniers pour y connecter des capteurs analogiques.

Bref, la carte n'est pas exempte de défauts. Nous allons nous en contenter.

Notions temporelles et architecture

On le sent bien, afin de pouvoir fournir au DAC audio un flux temps réel, sans trous (sinon l'écoute serait imparfaite), il va falloir lire le fichier WAV sur la carte SD plus rapidement que l'envoi. Si ce n'est pas le cas notre projet est infaisable, sauf à changer des paramètres. C'est ça le temps réel, pouvoir fournir les données selon le temps désiré par l'application.

Essayons d'avoir quelques grandeurs en tête.

Dans notre cas, on choisit un format audio avec les caractéristiques suivantes, à considéréer donc que ce sont des maximums :

  • 44,1 Kb/s
  • 32 bits par canal audio
  • Stéréo (donc deux canaux)

On envoie donc 64 bits pour un échantillon (droite + gauche). Dès lors on obtient les grandeurs suivantes:

La fréquence d'envoi d'un bit est de 44.1k * 64 : 2822400 Hz (2.8224 MHz !)
Duée de transfert un échantillon (1/44100) : 22,68 us (micro secondes, arrondi au pire)

Ok, nous voilà une première grandeur. Le coût pour aller lire des données en mémoire et le coût d'envoi des échantillons n'est pas anodin et il est préférable d'envoyer les données par grappe et la lecture des données en lot (plusieurs échantillons).

En effet, il faut passer du temps à préparer les transfers de données, temps qui est à effectuer une seule fois, donc autant le faire le plus rarement possible. Autre avantage du traitement par lot, c'est la présence du périphérique DMA (Direct Memory Access) sur le Pico qui permet de transférer des données une source à une destination (mémoire ou périphérique) sans l'intervention du CPU.

On voit que l'on a tout intérêt à utiliser le DMA pour le transfer audio pendant que l'on passe du temps à lire les prochaines données sur un périphérique "lent" comme la carte SD.

La problématique va être le bon dimensionnement de ces "lots" de données, nos buffers (mémoires temporaires).

Il existe plusieurs outils pouvant donner les informations d'un fichier WAV (sous Linux : mediainfo ou soxi). Voici ce que donne notre outil maison (disponibel dans les sources d'OpenStoryTeller):

Num Channels: 2
Num Samples Per Channel: 291504
Sample Rate: 44100
Bit Depth: 16
Length in Seconds: 6.61007

Le fichier WAV contient des échantillons sur 16-bits donc on va dans le bon sens : on a moins de données à lire de la SDCard qu'à en envoyer l'I2S. Nous allons tester notre code avec ce petit extrait de musique d'environ 6 secondes, qui va nous permettre de tester la détection de la fin du fichier sans devoir écouter une longue musique de plusieurs minutes.

Mesures

La prochaine étape va être d'instrumentaliser notre code afin de mesurer le temps pour récupérer quelques échantillons à partir de la carte SD.

Nous obtenons les grandeurs suivantes :

  • 1.04 ms pour l'ouverture du fichier et la préparation (la recherche vers le début des samples)
  • 834 ms pour la récupération de 2279 buffers (un buffer fait 512 octets, soit 2279 * 512 = 1.14 MiB ce qui correspond bien à la taille des données)
  • La récupération d'un buffer de 512 octets est assez variable, au pire on observe des pointes à environ 1.5ms (dans la plupart des cas 730us voire beaucoup moins)

Nous l'avons noté plus haut, transférer un échantillon (gauche + droite) dure 22,68 us. Un échantillon est composé de 4 octets (deux par canal), notre débit d'éctiture est donc de 1 octet transféré en 5.67 us. Si nous devons transférer 512 octets (taille de notre buffer de lecture), cela donne 512 x 5.67 = 2.9 ms.

Résumons :

  • Lire 512 octets à partir de la carte SD : 1 ms
  • Écrire 512 octets vers le périphérique I2S : 3 ms

Conclusion : notre système est viable, la lecture est plus rapide que l'écriture. Notre thread audio ne va pas trainer non plus, mais on a 2 ms de "marge", c'est beaucoup !

Technique de flux audio sans trous

On l'a vu, l'envoi d'un flux audio doit être continu, les échantillons les uns après les autres sans trou ! Dans le cas contraire la qualité audio s'ne fera ressentir.

Même si notre système est viable au niveau des temps d'exécution, nous allons mettre en oeuvre plusieurs techniques logicielles :

  • Un double buffer logiciel : pendant qu'un buffer est envoyé sur l'I2S, on ne va pas y toucher et préparer le buffer suivant
  • Un DMA automatique : l'idée est que le DMA bascule automatiquement sur l'autre buffer lorsqu'il a terminé d'envoyer le premier. Là, ça dépend beaucoup des capacités de votre modèle de microcontrôleur, il faudra voir cela au cas par cas.

La création d'un flux audio parfait ressemble donc à d'autres disciplines à l'instar d'un jeu vidéo : le dessin est réalisé dans une zone invisible et une fois l'écran entièrement dessiné, on l'affiche.

Voici ce que ça donne sur le principe :

image

L'interruption en provenance du DMA va être traitée dans un thread RTOS qui va lire les prochains octets à partir de la carte SD. Une fois les octets récupérés, les échantillons sont copiés dans le buffer qui n'est pas en cours d'envoi (forcément, sinon ça va les écraser).

Le Raspberry Pico n'a pas d'I2S

Aïe, les choses se corsent : contrairement à beaucoup de microcontrôleurs, le RP2040 n'a pas de périphérique I2S dédié. Mais en contrepartie, les concepteurs nous ont offert un périphérique programmable : le PIO ! À l'aide d'un petit langage assembleur de quelques instructions, il est possible de décrire comment sérialiser ou désérialiser des valeurs vers une broche d'entrée/sortie. Donc,

Voici le code du PIO permettant de se doter d'un périphérique I2S :

.program i2s_out_master
.side_set 2

public entry_point:
                    ;        /--- LRCLK
                    ;        |/-- BCLK
frameL:             ;        ||
    set x, 30         side 0b00 ; start of Left frame
    pull noblock      side 0b01 ; One clock after edge change with no data
dataL:
    out pins, 1       side 0b00
    jmp x-- dataL     side 0b01

frameR:
    set x, 30         side 0b10
    pull noblock      side 0b11 ; One clock after edge change with no data
dataR:
    out pins, 1       side 0b10
    jmp x-- dataR     side 0b11

Le principe est le suivant :

  1. on va sérialiser un premier mot de 32 bits (cela correspond à l'échantillon de la voie gauche), puis un deuxième (la voie de droite)
  2. Nous avons donc deux boubles successives
  3. Chaque bit de l'entier sérialisé sera copé sur la broche de sortie
  4. En parallèle (ce sont les instructions à la suite de side) les broches LRCLK et BCLK sont contrôlées pour séquencer notre DAC

Notez que l'instruction "noblock" signifie que notre I2S ne va jamais bloquer et toujours prendre ce qu'il y a dans la FIFO de données à l'entrée du PIO (typiquement la dernière valeur sérialisée). Cela va avoir un impact sur notre design lorsqu'il n'y aura pas de son (il faudra alors initialiser à zéro les buffers).

Le thread audio

Pour nous aider dans notre processus audio, nous allons utiliser un OS temps réel. Souvent, les exemples que l'on récupère des constructeurs sont difficilement intégrables dans un vrai projet qui ne fais pas que de l'audio. Voici donc un exemple d'intégration, simplifié ici pour montrer les parties les plus intéressantes :

C
static void audio_callback(void)
{
    // Interruption DMA: on va notifier le thread qu'il peut remplir le prochain buffer
    qor_mbox_notify(&AudioMailBox, (void **)&wake_up, QOR_MBOX_OPTION_SEND_BACK);
}

// Notre thread dédié à l'audio
void AudioTask(void *args)
{
    // ... code d'initialisation de l'audio, mailbox et autres callbacks, pas très intéressant à montrer

    while (1)
    {
        ost_audio_play("out2.wav"); // ouvertur du fichier Wav, récupération des données pour remplir les deux premiers buffers

        ost_audio_event_t *e = NULL;

        int isPlaying = 0;
        do
        {
            // Ici une mailbox, on attend le signal de fin de transfer DMA
            uint32_t res = qor_mbox_wait(&AudioMailBox, (void **)&e, 300); // On devrait recevoir un message toutes les 3ms (durée d'envoi d'un buffer I2S)

            if (res == QOR_MBOX_OK)
            {
                isPlaying = ost_audio_process(); // on vient lire dans le fichier WAV, sur la SD Card, les N prochains samples (ici 128 samples)
            }

        } while (isPlaying);

        ost_audio_stop(); // fermeture du fichier et désactivation propre de l'I1S, des DMA etc.
    }
}

La boucle audio est particulièrement simple : on attend que le DMA nous envoie un signal de fin de transmission pour remplir le prochain buffer. Notre système fonctionne car les temps sont respectés (voir ci-dessus la théorie). Il est possible de valider les différents temps en faisant bagotter une broche et regarder les temps à l'oscilloscope ou à l'analyseur logique.

Ici on voit la durée de notre interruption DMA (on est conformes à la théorie) :

image

Un zoom sur les échantillons en cours de transfer :

image

Outillage et code source

Retourvez le code source final dans les sources du projet Open Story Teller, la boîte à histoires libre : https://github.com/arabine/open-story-teller

Concernant l'outillage, j'ai utilisé tout particulièrement cet analyseur logique (Lien Amazon : Analyseur Logique) :

image

Je vous invite à vous outiller lorsque vous travaillez sur des projets comme cela, il est très facile de faire des erreurs car la chaîne complète de transfer audio n'est pas si simple !