Hardware Linux OSBN/Ubuntuusers Planet XING / LinkedIn / Amazon

Eigenbau-Sprachassistent Teil 4: Internet-Radio und andere (schlechte) Witze

Im letzten Teil dieser Serie haben wir dem Sprachassistenten das Auslesen von Temperatursensoren und die Wetteransage über das Internet beigebracht. In diesem Artikel kommt die Unterhaltung mit einem selbstgebauten Internet-Radio und einer API für schlechte Witze nicht zu kurz.

Radio-Streams unter Linux

Eine beliebte Funktion fertiger Sprachassistenten ist das Abspielen von Musik und Internet-Radio – eine solche Funktion darf in einem Eigenbau natürlich nicht fehlen (nicht zuletzt um den WAF des Bastelprojekts zu erhöhen). Eine solche Funktion liefert Rhasspy nicht mit, was aber nicht weiter stört – immerhin eröffnet der Einsatz von Node-RED weitere Integrationsmöglichkeiten. So können wir beispielsweise komfortabel weitere Container ansprechen und fernsteuern.

Um unter Linux in den Genuss von Internet-Radio zu kommen gibt es zahlreiche Tools – eines davon ist der vielseitige Player mplayer, der mit einer angeführten URL in der Regel sofort die entsprechende Station abspielt:

$ mplayer http://rbb-fritz-live.cast.addradio.de/rbb/fritz/live/mp3/128/stream.mp3

Einige Stationen verbergen die tatsächliche MP3/AAC-URL in einer Playlist (*m3u, *.pls) – mplayer kann damit oftmals nicht umgehen und bricht ab. Hier hilft der Einsatz von curl, um die Weiterleitung herauszufinden:

$ curl http://streams.ffh.de/radioffh/mp3/hqlivestream.m3u
http://mp3.ffh.de/radioffh/hqlivestream.mp3

Einfacher Container

Schön und gut – jetzt könnte man innerhalb Node-RED Befehle erstellen um entsprechende Kommandos auf dem Host auszuführen. Hier wäre es aber auch wieder notwendig, einen Benutzer zu erstellen, den Login per SSH (oder einem ähnlichen Protokoll) zu implementieren. Pro Station könnte man ein entsprechendes Kommando erstellen. Das muss doch irgendwie schöner gehen…

Ich dachte hier an eine API, die Node-RED einfach direkt ansprechen kann um einfache Befehle abzusetzen (Starte Radio, wechsele Sender, etc.) Da ich öfter in Python programmiere fiel die Wahl schnell auf Flask – ein schlankes, aber dennoch leistungsfähiges Framework für Webservices und APIs. Folgende Funktionen wollte ich an Board haben:

  • Speichern der Radiostationen in einer SQLite-Datenbank
  • REST API und rudimentäre UI für die Pflege und Steuerung von Stationen
  • Steuerung der Lautstärke (über amixer)

An einem Nachmittag mit stark ausgepräter Langeweile ist hier eine rudimentäre App enstanden, die sich auf GitHub findet. Vorgefertige Container auf Basis von Ubuntu für x86_64 und ARM stelle ich auf Docker Hub zur Verfügung.

Die Anwendung stellt verschiedene API-Calls zur Verfügung, mit welchen die Funktionen gesteuert werden – beispielsweise:

Aufruf Methode Funktion
/api/stations POST Neuen Sender einspeichern
/api/stations/<id>/<name>/play GET Sender abspielen
/api/next GET Nächster Sender
/api/previous GET Vorheriger Sender
/api/stop GET Radio stoppen
/api/volume POST Lautstärke ändern

Für API-Faule gibt es eine rudimentäre Web-Oberfläche:

Rudimentäre Web-Oberfläche zum Pflegen und Steuern von Radio-Staionen

Im GitHub-Repository findet sich auch eine Konfigurationsdatei für docker-compose:

version: "3"

services:
  radio:
    container_name: radio_api
    image: stdevel/radio_api:latest
    ports:
      - "5000:5000"
    devices:
      - "/dev/snd:/dev/snd"
    volumes:
      - data:/opt/radio_api/instance
    restart: unless-stopped

volumes:
  data:

Die Anwendung lauscht auf TCP-Port 5000, für die Datenbank der Radio-Stationen wird ein Volume erstellt. Die Gerätedatei /dev/snd wird an den Container weitergeleitet.

Der Container wird wie folgt erstellt und gestartet:

$ docker-compose up -d

Henne-Ei-Problem

Das große Problem an der Sache ist, dass sich Rhasspy und radio_api die Soundkarte teilen. Das bedeutet konkret, dass Sprachkommandos nicht zuverlässig funktionieren, während das Radio läuft. Das Rufwort wird gelegentlich fälschlicherweise ausgelöst und eingesprochene Kommandos werden oft aufgrund der Hintergrundmusik nicht erkannt. Ein Umweg ist es natürlich, das Radio über die Web-Oberfläche zu deaktivieren bevor das nächste Kommando eingesprochen wird.

Hier habe ich noch keine elegante Lösung gefunden – über Anregungen und Tipps bin ich an dieser Stelle dankbar. Ein für mich funktionaler Workaround ist jedoch der Einsatz von MQTT in Kombination mit einer Smartphone-App wie IoT OnOff (iOS) bzw. MQTT Dash (Android). Das Betätigen eines Knopfs auf dem Smartphone ist für mich einfacher als das Aufrufen einer Webseite, die nicht für Smartphones optimiert ist. Innerhalb Mosquitto habe ich den MQTT-Benutzer operator in der ACL um folgende Topics erweitert:

# operator
user operator
topic read #
topic read $SYS/#
topic readwrite radio/status
topic readwrite radio/station
topic readwrite radio/volume

Node-RED lauscht in einem Flow auf die Topics und steuert das Radio per API sobald Kommandos eingehen:

Payload Topic Beschreibung
stop radio/status Stoppt das Radio
prev Vorheriger Sender
next Nächster Sender
<int> radio/volume Lautstärke ändern (0 – 100%)

Ein entsprechendes Dashboard auf dem Smartphone steuert so Lautstärke und Radiosender:

MQTT-Dashboard für Radio-API

Der entsprechende Flow ist auf GitHub verfügbar.

Schlechte Witze as a Service

Ich bin ein großer Freund schlechter (Wort)witze – also lag es nahe, hierfür eine API zu entwickeln und mit dem Sprachassistent zu verknüpfen. Das sorgt auf Partys entweder für großen Spaß oder Fremdscham – eine schmale Gratwanderung. 🙂

Hier habe ich den Code der Radio-API wiederverwendet – folgende Anforderungen empfand ich als wichtig:

  • Bereitstellen verschiedener Kategorien (normale Witze, schlechte Witze, Filmzitate,…)
  • Speichern von Witzen in Kategorien in einer SQLite-Datenbank
  • Zufallsmodus
  • REST-Calls und rudimentäre UI für die Pflege von Kategorien und Witzen

Gesagt, getan – daraus ist an einem langen Wochenende eine fertige App geworden, die sich auf GitHub findet. Auch hier gibt es wieder vorgefertige Container auf Basis von Alpine Linux für x86_64 und ARM auf Docker Hub.

Die entsprechenden API-Calls finden sich in der Dokumentation oder einer Postman-Collection – ein Auszug:

Aufruf Methode Funktion
/api/categories POST Neue Kategorie erstellen
/api/categories GET Kategorie-Informationen beziehen
/api/jokes POST Neuen Witz speichern
/api/jokes/random/ GET Zufälliger Witz
/api/jokes/random/<id,name> GET Zufälliger Witz einer bestimmten Kategorie
/api/jokes/random/<id,name>/<rank> POST Zufälliger Witz einer bestimmten Kategorie mit Mindest-Ranking

Für die Pflege steht ebenfalls eine rudimentäre Web-Oberfläche bereit:

Erstellen eines Witzes

Im GitHub-Repository findet sich auch eine Konfigurationsdatei für docker-compose:

version: "3"

services:
  joke_api:
    container_name: joke_api
    image: stdevel/joke-api:latest
    ports:
      - "5001:5000/tcp"
    volumes:
       - data:/opt/joke_api/instance
    restart: unless-stopped

volumes:
  data:

Die Applikation ist per TCP-Port 5001 erreichbar, für die Datenbank wird ein dediziertes Volume angelegt – dieses kann zwischen Updates dann entsprechend gesichert und wiederhergestellt werden (damit niemand auf schlechte Witze verzichten muss).

Der Container wird in nun gewohnter Manier erstellt und gestartet:

$ docker-compose up -d

Sentences

Damit Rhasspy die neuen Kommandos anwenden kann, müssen zwei entsprechende Sentences definiert werden:

[TellJoke]
erzähl einen witz

[PlayRadio]
radio an
spiele radio
schalte das radio ein

TellJoke bezieht einen Witz und liest diesen vor, während PlayRadio das Radio anschaltet. Entsprechende Kommandos zum Wechseln der Sender und Abschalten des Radios haben sich bei mir als nicht praxistauglich erwiesen, da diese wie oben beschrieben nicht zuverlässig funktionieren.

Mit einem Klick auf Train werden die Änderungen übernommen.

Verknüpfen mit Node-RED

Der erste Schritt besteht darin, in der Node-RED Oberfläche den Rhasspy-Handler zu öffnen und den Commands-Switch um zwei Fälle zu erweitern: TellJoke und PlayRadio.

Anschließend wird ein http request-Node in den Flow gezogen und mit dem TellJoke-Fall verbunden. Per Doppelklick werden die folgenden Einstellungen übernommen:

  • Method: GET
  • URL: http://localhost:5001/api/jokes/random
  • Return: a parsed JSON object
  • Name: Random joke
Die URL bezieht einen zufälligen Witz aus einer beliebigen Kategorie. Falls nur eine bestimmte Kategorie ausgewählt werden soll, genügt das Anhängen des Kategorienamen, z.B. /generic

Bei Return ist wieder darauf zu achten, dass ein JSON-Objekt zurückgegeben wird. Dieses wird im nächsten Schritt weiter verarbeitet – es folgt ein Template-Node, dessen Eingang mit dem Ausgang des http request-Node verbunden wird. Die nachfolgenden Einstellugen sind zu übernehmen:

  • Name: Intent response
  • Format: Mustache template
  • Output as: Parsed JSON

Auch hier sind das Template-Format und das zurückgegebene JSON-Objekt wieder wichtig – das eigentliche Template sieht wie folgt aus:

{
  "intent": {
    "name": "TellJoke",
    "confidence": 0
  },
  "speech": {
    "text": "{{ payload.results.0.joke_text }}"
  },
  "wakeId": "",
  "siteId": "default",
  "time_sec": 0.010800838470458984
}

Der speech.text-Eigenschaft wird der Text des zufällig bezogenen Witzes zugewiesen.

Abschließend wird der Ausgang des Template-Objekts dem http reponse-Objekt zugewiesen.

Für den zweiten Fall wird erneut ein http request-Node erstellt und mit dem verbleibenden Fall verbunden. Dabei sind die folgenden Einstellungen zu übernehmen:

  • Method: GET
  • URL: http://localhost:5000/api/stations/1/play
  • Return: a parsed JSON object
  • Name: Play radio

Die URL zeigt zur Radio-API und startet dort den ersten eingespeicherten Radio-Sender. Senderwechsel werden dannach über den vorhin beschriebenen Workaround vorgenommen.

Der Ausgang des Nodes wird mit einem weiteren Template-Node verbunden, welches die folgenden Anpassungen erfährt:

  • Name: Intent response
  • Format: Mustache template
  • Output as: Parsed JSON

Das Template enthält diesmal keinen auszugebenden Text:

{
  "intent": {
    "name": "PlayRadio",
    "confidence": 0
  },
  "wakeId": "",
  "siteId": "default",
  "time_sec": 0.010800838470458984
}

Zuletzt wird der Ausgang des Templates mit dem http response-Node verbunden, die Änderungen werden mit einem Klick auf Deploy gespeichert.

Der erweiterte Rhasspy-Handler

Die Funktionen stehen nun zur Verfügung – Zeit für einen Test; entweder per curl oder Verwendung des Sprachkommandos.

$ curl -H "Content-Type: application/json" -X POST -d '{"intent": {"name": "TellJoke"}}' http://<node-red-ip-adresse>:1880/intent
{"intent":{"name":"TellJoke","confidence":0},"speech":{"text":"Wie heißt einen Spanier ohne Auto? Carlos."},"wakeId":"","siteId":"default","time_sec":0.010800838470458984}

Ausblick

In diesem Artikel haben wir mit einem Online-Radio und schlechten Witzen am laufenden Band einiges für das Entertainment getan. Jedoch gibt es natürlich auch hier wieder Optimierungspotential.

Für einige mag es etwas unschön sein, dass wir nun schon vier Ports für die verwendeten Applikationen geöffnet haben. Eine elegantere Lösung wäre es, die einzelnen Ports hinter einen Reverse Proxy zu legen und die einzelnen Anwendungen per URL-Weiterleitung zugänglich zu machen – beispielsweise:

URL Port
/node-red localhost:1880
/radio localhost:5000
/jokes localhost:5001
/rhasspy localhost:12101

Hier wäre der Einsatz einer Software wie NGINX oder Traefik denkbar.

Sharing is caring

5 Kommentare Neuen Kommentar hinzufügen

  1. MM sagt:

    Ich muss sagen, dass dein FOSSGIS Talk zu dem Thema wirklich das beste war, was ich seit langem angeschaut habe. Super informativ und breit. Konzentriert, ohne einen mit Details zu erschlagen. Hut ab!
    https://media.ccc.de/v/froscon2020-2565-eigenbau-sprachassistenten

    Motiviert mich sehr, mich endlich auch so einem Projekt mal zu nähern. Das schlummert schon so lange in meinem Hinterkopf, aber mir war es bisher immer zu unübersichtlich, in welche Richtung man eigentlich gehen will 😉

    1. Christian sagt:

      Hi MM,
      vielen Dank für die Blumen! 🙂

      Freut mich sehr zu hören, dass dich der Vortrag motivieren konnte.
      Verstehe gut, dass man am Anfang an Informationen erschlagen wird – ging mir auch so.

      Lass doch mal hören, was es dann bei dir geworden ist. Ich bin immer an neuen Ansätzen und Ideen interessiert.

      Beste Grüße,
      Christian.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.