Coin web de Frédéric Péters

fpeters@0d.be

Partager du son, deux ou trois notes

30 décembre 2023, 16:00

J’ai remis en place un serveur de streaming audio récemment; ça faisait bien longtemps mais en 2023 comme en 2003 c’est par icecast2 que ça passe (littéralement plus de 20 ans, la première version packagée dans Debian date du 16 mars 2003).

En 2023 par contre on ne se passe plus de chiffrement et j’étais parti assez flemmard à imaginer simplement mettre icecast2 derrière nginx et laisser à ce dernier l’https. Pour la diffusion c’est déjà quelque chose que j’avais mis en place pour radio Esperanzah! et ça marchait bien, avec quelque chose comme :

location ~ ^/streams/(.*)$ {
	gzip off;
	resolver 127.0.0.1;
	tcp_nodelay on;
	proxy_pass http://localhost:8000/$1;
	proxy_http_version 1.1;
	proxy_request_buffering off;
}

Pour l’envoi du stream par contre échec, et c’est clairement quelque chose qui n’est pas encouragé par le projet icecast, je me suis incliné et j’ai configuré l’https directement dans icecast.

Ça n’est pas bien compliqué, il y a juste un bout de configuration, pour pointer un fichier qui contiendra les clés publiques et privées à utiliser :

<ssl-certificate>/etc/icecast2/bundle.pem</ssl-certificate>

Bien sûr j’utilise let’s encrypt mais le client de base (certbot) ne produit pas de fichier avec les deux clés, c’est l’occasion de découvrir son système de "hooks", simplement dans la configuration, dans le fichier /etc/letsencrypt/renewal/example.net.conf, ajouter une ligne :

post_hook = cat /etc/letsencrypt/live/example.net/fullchain.pem /etc/letsencrypt/live/example.net/privkey.pem > /etc/icecast2/bundle.pem && systemctl restart icecast2

Rapidement je n’ai pas trouvé de possiiblité d’utiliser des variables pour le schemins des clés, c’est un peu dommage mais pas bien grave.

Côté serveur il n’y a pas grand choix par contre côté client, pour la diffusion vers un serveur de streaming, il y a l’embarras du choix mais pour le projet en cours je reste sur liquidsoap et une configuration basique,

set("log.file", false)
set("log.stdout", true)

source = mksafe(input.jack(clock_safe=false,id='liquidsoap'))

output.icecast(
  %opus(samplerate=48000, channels=2, application="audio", bitrate=192, vbr="constrained", signal="music"),
  name="...",
  description="...",
  genre="...",
  url="...",
  public=false,
  mount="/xxx.opus",
  host="server", port=8443, password="...",
  transport=http.transport.ssl(),
  source
)

Nouveauté ici la partie transport=http.transport.ssl() pour l’envoi chiffré, et comme l’usage va être limité je m’autorise le streaming encodé au format Opus.

Ce travail d’infrastructrure en place la suite devient spécifique au projet en cours, il s’agit de streamer ce qui est diffusé localement depuis un ordinateur, et aujourd’hui ça veut dire profiter de PipeWire, ça va se diviser en trois parties, d’abord le démarrage de liquidsoap,

async def liquidsoap():
    while True:
        cmd = await asyncio.create_subprocess_exec(
            '/usr/bin/liquidsoap',
            '.../config.liq',
            stdout=asyncio.subprocess.PIPE,
        )
        try:
            while cmd.returncode is None:
                lineout = await cmd.stdout.readline()
                if lineout == b'':
                    await asyncio.sleep(0.5)
                else:
                    print('[liquidsoap]', lineout.decode('utf-8').strip())
        finally:
            if cmd.returncode is None:  # not finished
                cmd.kill()

ensuite un monitoring de celui-ci parce que sur des déconnexions réseau, ou la sortie de veille, ou une coupure du serveur, la reprise n’était pas automatique, malgré le message "Will try again in 3.00 sec.". Ici toutes les cinq secondes, si la connexion HTTP donne une erreur 404, je provoque un redémarrage (l’erreur 404 signifie ici à la fois que le serveur icecast est disponible et que liquidsoap n’y est pas branché).

async def check_stream():
    await asyncio.sleep(10)
    while True:
        async with aiohttp.ClientSession() as session:
            async with session.get(url_du_stream) as resp:
                if resp.status != 200:
                    print('[check] stream is', resp.status)
                if resp.status == 404:
                    print('[check] stream is 404')
                    raise Restart()
        await asyncio.sleep(5)

Dernière étape et ce que traditionnellement je faisais via jack-plumbing, je le bricole ici en utilisant pw-link, pour établir les connexions entre programmes jouant du son (ci-dessous uniquement mpv, ça s’étend facilement à Firefox, Mixxx, ou n’importe quel autre programme qui serait utilisé) et liquidsoap,

async def do_links():
    link = await asyncio.create_subprocess_exec('/usr/bin/pw-link', 'mpv:output_FL', 'liquidsoap:in_0')
    await link.communicate()
    link = await asyncio.create_subprocess_exec('/usr/bin/pw-link', 'mpv:output_FR', 'liquidsoap:in_1')
    await link.communicate()


async def pw_link():
    while True:
        cmd = await asyncio.create_subprocess_exec('/usr/bin/pw-link', '-lm', stdout=asyncio.subprocess.PIPE)
        try:
            while cmd.returncode is None:
                lineout = await cmd.stdout.readline()
                if lineout == b'':
                    await asyncio.sleep(0.5)
                else:
                    print('[pw-link]', lineout.decode('utf-8').strip())
                    if lineout in (
                        b'+ mpv:output_FL\n',
                        b'+ mpv:output_FR\n',
                        b'+ liquidsoap:in_0\n',
                        b'+ liquidsoap:in_1',
                    ):
                        await do_links()
        finally:
            if cmd.returncode is None:  # not finished
                cmd.kill()

Et pour grouper tout ça je découvre TaskGroup, une nouveauté de Python 3.11,

async def main():
    while True:
        try:
            async with asyncio.TaskGroup() as tg:
                tg.create_task(liquidsoap())
                tg.create_task(pw_link())
                tg.create_task(check_stream())
        except ExceptionGroup as e:
            print('end of task group', e.exceptions)


try:
    asyncio.run(main())
except RuntimeError:
    pass

Voilà techniquement tout qui tourne, mais tristesse à écouter le stream produit, il sature, et je ne comprends pas comment le signal transmis par mpv sature alors qu’en local ça ne s’entend pas… finalement j’adopte la solution la plus basique qui soit, démarrer mpv avec un niveau sonore réglé à 95% et j’obtiens alors un signal correct.

Avant ça :