Voice-Agenten: vom Telefon-Bot bis zum Sprachassistenten im Taschenformat

AgentensystemVoice · 2025 – 2026
TypeScriptNode.jsWebSocketOpenAI Whispergpt-4o-miniOpenAI TTSPython/FlaskESP32-S3 (Arduino/PlatformIO)ffmpeg

Projekt-Übersicht

Dieses Projekt besteht aus zwei Voice-Agenten, die ich unabhängig voneinander gebaut habe und die trotzdem dieselbe Grundkette nutzen: Sprache wird zu Text, ein Sprachmodell formuliert eine Antwort, eine Sprachsynthese macht daraus wieder Audio.

Der erste Agent ist ein telefonartiger Gesprächsagent in TypeScript. Er läuft im Browser über WebSocket oder als echter Telefonanschluss über eine Media-Stream-Schnittstelle eines Telefonie-Anbieters. Er erkennt selbst, wann jemand spricht und wann eine Äußerung zu Ende ist, transkribiert mit Whisper, antwortet mit gpt-4o-mini und spricht mit OpenAIs tts-1.

Der zweite Agent ist ein Push-to-Talk-Sprachassistent auf zwei gekauften Waveshare-Entwicklungsboards mit ESP32-S3: einem Board mit 1,8-Zoll-AMOLED-Touchdisplay und einem mit 1,54-Zoll-E-Paper. Die Hardware ist Standardware; die Firmware für beide Boards und der Python-Server dahinter sind von mir. Taste halten, sprechen, loslassen – das Gerät schickt die Aufnahme ins lokale Netz, der Server verkettet lokales Whisper, ein Chat-Backend und gpt-4o-mini-tts, und das Gerät spielt die Antwort über seinen Lautsprecher ab.

Beide Projekte waren für mich Versuchsfelder für die Frage, wie sich Sprachschnittstellen für Agentensysteme anfühlen müssen, damit man sie wirklich benutzt.

Ausgangslage

Ein Sprachagent ist schnell zusammengesteckt, wenn man nur die Demo betrachtet. Die eigentliche Arbeit liegt an drei Stellen, die in Tutorials meist zu kurz kommen:

  1. Wann ist der Mensch fertig? Ohne zuverlässige Erkennung von Sprechbeginn und Sprechende schneidet der Agent Sätze ab oder wartet unnötig lange.
  2. Audioformate. Telefonie liefert 8 kHz μ-law, Whisper erwartet sauberes PCM oder WAV, TTS-Dienste liefern MP3 oder WAV in anderen Abtastraten, und ein Mikrocontroller will rohe Samples, die er direkt in seinen Audio-Codec schieben kann. Jeder Übergang ist eine Fehlerquelle.
  3. Hardware-Grenzen. Auf einem ESP32-S3 entscheiden Speicheraufteilung, Pinbelegung und Mikrofonverstärkung darüber, ob überhaupt verwertbares Audio ankommt.

Ich wollte beide Varianten selbst bauen: einmal die „Telefon“-Variante mit kontinuierlichem Zuhören und einmal ein kleines Gerät, das man in die Hand nimmt und das eine klar abgegrenzte Frage beantwortet.

Architektur

flowchart LR
  A["Browser oder Telefonanruf"] -->|"WebSocket / Media Stream"| B["Node.js-Server"]
  B --> C["Voice Activity Detection"]
  C --> D["Whisper oder Deepgram"]
  D --> E["gpt-4o-mini"]
  E --> F["tts-1 oder ElevenLabs"]
  F -->|"Audio zurück"| A
  G["ESP32-S3-Board"] -->|"POST Aufnahme"| H["Flask-Server"]
  H --> I["Whisper lokal"]
  I --> J["Chat-Backend: gpt-4o-mini oder lokaler Agent"]
  J --> K["gpt-4o-mini-tts + ffmpeg"]
  G -->|"Status-Polling"| H
  K -->|"PCM 16 kHz"| G

Die beiden Systeme teilen sich die Pipeline, aber nicht das Interaktionsmodell. Der Telefonagent hört dauerhaft zu und muss selbst entscheiden, wann er antwortet. Der ESP32-Assistent arbeitet mit Push-to-Talk: Der Mensch entscheidet per Taste, wann eine Äußerung beginnt und endet. Das macht das Gerät einfacher und robuster, verschiebt die Komplexität aber auf die Hardware-Seite.

Technische Umsetzung

Telefonartiger Agent: Server und Provider-Abstraktion

Der Server ist in TypeScript geschrieben (Express, ws). Für Spracherkennung und Sprachsynthese gibt es jeweils ein Interface mit mehreren Implementierungen: Transkription über OpenAI Whisper (whisper-1) oder Deepgram, Sprachsynthese über OpenAI (tts-1, Stimme konfigurierbar) oder ElevenLabs. Ein HybridTranscriptionManager entscheidet anhand von Konfiguration und Anbieterfähigkeit, ob im Datei-Modus (Äußerung komplett aufnehmen, dann transkribieren) oder im Streaming-Modus gearbeitet wird. Streaming steht nur mit Deepgram zur Verfügung; fällt die Streaming-Verbindung aus, fällt der Agent auf den Datei-Modus zurück.

Auf der TTS-Seite gibt es einen HybridTTSManager mit demselben Gedanken: Schlägt der primäre Anbieter fehl, wird ein Fallback-Dienst erzeugt und für die nächste Anfrage verwendet; klappt der Fallback, kehrt der Manager zum primären Dienst zurück.

Das Sprachmodell selbst bekommt bewusst enge Leitplanken. gpt-4o-mini antwortet mit höchstens 150 Tokens, damit Antworten am Telefon kurz bleiben. Der Gesprächsverlauf wird auf einen System-Prompt plus die letzten zehn Nachrichten begrenzt; ältere Paare aus Frage und Antwort fallen heraus.

Voice Activity Detection mit adaptiver Schwelle

Für den Telefonpfad habe ich eine eigene, energiebasierte Voice Activity Detection geschrieben. Sie arbeitet auf 20-ms-Frames bei 8 kHz, berechnet den RMS-Wert und durchläuft einen kleinen Zustandsautomaten: SILENCE → SPEECH_START → SPEAKING → SPEECH_END. Ein Sprechbeginn gilt erst nach 200 ms anhaltender Energie als bestätigt, damit Knackser keine Transkription auslösen. Das Sprechende ist erreicht, wenn 500 ms Stille folgen; nach spätestens zehn Sekunden wird eine Äußerung zwangsweise abgeschlossen.

Der wichtigste Teil ist die Kalibrierung. Eine feste Schwelle funktioniert in einem ruhigen Büro, aber nicht bei einem Anruf aus dem Auto. Deshalb misst die VAD in den ersten zehn Frames das Grundrauschen und legt die Schwelle drei Standardabweichungen darüber:

const n = this.noiseCalibrationSamples.length;
const avg = this.noiseCalibrationSamples.reduce((a, b) => a + b, 0) / n;
const variance = this.noiseCalibrationSamples.reduce((sum, val) => sum + Math.pow(val - avg, 2), 0) / n;
const stdDev = Math.sqrt(variance);

this.backgroundNoiseLevel = avg;
this.adaptiveThreshold = Math.max(
  this.energyThreshold,
  avg + (stdDev * 3) // 3 standard deviations above noise
);

Damit der Anfang eines Satzes nicht verloren geht, merkt sich der Handler beim Übergang von Stille zu Sprechbeginn eine Position etwa zehn Pakete vor dem erkannten Start. Die eigentliche Transkription bekommt also ein kleines Vorlauf-Polster.

Im Browser-Client gibt es eine einfachere Variante: Die Web Audio API liefert einen Lautstärkepegel, ab einem Schwellwert gilt der Nutzer als sprechend, und nach zwei Sekunden Stille wird die Aufnahme beendet und an den Server geschickt.

Telefonie: μ-law hin und zurück

Für echte Anrufe antwortet der Server auf den Webhook des Telefonie-Anbieters mit einer Anweisung, die den Anruf an einen WebSocket-Media-Stream koppelt. Über diesen Stream kommen Audiopakete im Format 8 kHz μ-law. Der Handler dekodiert jedes Paket zu PCM, füttert damit die VAD und sammelt die Pakete in einem Puffer. Erkennt die VAD das Sprechende, wird der Abschnitt ab der gemerkten Startposition zu WAV zusammengesetzt und transkribiert.

Der Rückweg ist ebenso unspektakulär wie fehleranfällig: Die TTS-Antwort kommt als MP3, wird mit ffmpeg auf 8 kHz Mono-PCM gewandelt, nach μ-law kodiert und Base64-verpackt in den Stream zurückgeschickt. Ein konfigurierbarer Begrüßungssatz wird beim Verbindungsaufbau auf demselben Weg gesprochen. Alle Gespräche lassen sich optional sitzungsweise als MP3 mitschneiden, getrennt nach Nutzer- und Agentenaudio, was bei der Fehlersuche in der VAD sehr geholfen hat.

Unterbrechungen im Protokoll

Unterbrechen können ist das, was einen Telefonagenten natürlich wirken lässt. Im WebSocket-Protokoll des Agenten sind Unterbrechungen deshalb von Anfang an vorgesehen: Es gibt einen eigenen Nachrichtentyp interrupt, auf den der Server die laufende Verarbeitung abbricht und mit interrupted bestätigt. Das automatische Auslösen beim Dazwischenreden, im Browser wie am Telefon, ist der nächste Ausbauschritt; die Grundlage dafür liegt mit diesem Protokoll bereits im Server.

ESP32-Assistent: eine Firmware für zwei Boards

Beim Push-to-Talk-Assistenten wollte ich eine Firmware für zwei unterschiedliche Boards. Beide haben denselben Audioteil (ES8311-Codec, Mono-Mikrofon, Lautsprecher), unterscheiden sich aber bei Display, Bedienung und Stromversorgung: Das AMOLED-Board hat Touch und einen Power-Management-Chip mit Ladeanzeige, das E-Paper-Board nur Tasten, einen per Software gehaltenen Einschalt-Latch und eine Akkumessung über den ADC.

Gelöst habe ich das mit einer schmalen Schnittstelle in board.h. Die Ablauflogik in main.cpp – WLAN, Aufnahme, Upload, Status-Polling, Wiedergabe – ist boardunabhängig. Alles, was sich unterscheidet, steckt hinter Funktionen wie boardShowStatus(), boardTriggerHeld() oder boardBatteryPercent(). Welche Implementierung gebaut wird, entscheidet ein Build-Flag in PlatformIO.

Ein Detail, das mich einen Abend gekostet hat, steht jetzt als Konstante im Code: die Mikrofonverstärkung. Die beiden Boards klingen bei gleicher Einstellung völlig verschieden.

// ES8311 microphone gain (0-7, ~6 dB per step). Board-specific: the AMOLED
// board needs 7 or speech stays in the noise floor, but the ePaper board's mic
// is a lot hotter -- measured at gain 7 its noise floor sits at a healthy
// -42 dBFS while ordinary room transients already clip at 0 dBFS, leaving no
// headroom for speech.
#if defined(BOARD_EPAPER_154)
  #define BOARD_MIC_GAIN 5
#else
  #define BOARD_MIC_GAIN 7
#endif

Speicher, Audio und Display auf dem Mikrocontroller

Die Aufnahme läuft mit 16 kHz über I2S in den PSRAM. Direkt in den PSRAM zu lesen führte zu hängenden I2S-Reads und Watchdog-Resets; deshalb landen die DMA-Daten zuerst in einem kleinen Puffer im internen RAM und werden dann umkopiert. Die Puffergrößen bestimmt die Firmware beim Start aus dem tatsächlich gefundenen PSRAM: Bei 8 MB reicht es für 15 Sekunden Aufnahme und 30 Sekunden Antwort, bei der älteren Board-Revision mit 2 MB wird im Verhältnis 1:2 verkleinert.

Das Loslassen der Taste wird erst nach 120 ms durchgehend gelöster Taste als Ende gewertet, damit ein einzelner Störimpuls auf dem I2C-Bus keine Aufnahme abschneidet. Vor dem Upload wird die Stereo-Aufnahme auf Mono reduziert.

Auch das E-Paper-Display brauchte eigene Regeln: Jede Statusänderung ist ein Teil-Refresh, jeder zehnte ein voller, sonst bleiben Geisterbilder stehen. Identische Anzeigen werden verworfen, nach 15 Sekunden Ruhe geht das Panel in den Ruhezustand. Weil Schwarz-Weiß keine Statusfarben kennt, bekommen Aufnahme und Fehler einen dicken Rahmen.

Der Server: Jobs, Status und sprechbarer Text

Der Python-Server (Flask) nimmt die Aufnahme als rohes 16-kHz-PCM oder WAV entgegen, legt einen Job an und verarbeitet ihn in einem Hintergrund-Thread. Das Gerät fragt den Status ab und zeigt jeden Schritt an: transkribieren, nachdenken, sprechen. Danach lädt es die Antwort als rohes 16-kHz-Stereo-PCM ohne WAV-Header herunter, damit es die Bytes direkt an den Codec weiterreichen kann, ohne etwas parsen zu müssen.

Die Transkription läuft lokal über das Open-Source-Whisper (Modell small, Sprache Deutsch). Für die Antwort gibt es zwei umschaltbare Backends: gpt-4o-mini direkt oder ein lokaler, OpenAI-kompatibler Agent mit Werkzeugzugriff, etwa auf Kalender und Aufgaben. Für den Agenten hält der Server eine rollende Sitzungs-ID, damit aufeinanderfolgende Sprachnachrichten einen gemeinsamen Gesprächskontext haben; nach zehn Minuten Stille beginnt eine neue Sitzung. Laut meinen Notizen braucht das direkte Modell 6 bis 8 Sekunden pro Durchlauf, der Agent mit Werkzeugnutzung 15 bis 40 Sekunden. Das Gerät wartet deshalb bis zu zwei Minuten auf eine Antwort.

Ein unterschätztes Problem war Formatierung. Sprachmodelle antworten gern mit Markdown, Listen und Links, und eine TTS-Engine liest „Sternchen“ und URLs dann brav vor. Der System-Prompt verlangt deshalb ausdrücklich Fließtext, und zusätzlich bereinigt der Server die Antwort, bevor sie zur Sprachsynthese geht:

def sanitize_for_speech(text: str) -> str:
    text = _MD_IMAGE.sub("", text)
    text = _MD_LINK.sub(r"\1", text)
    text = _URL.sub("", text)
    text = _MD_INLINE.sub("", text)
    text = _BULLET.sub("", text)
    # Collapse line breaks into spoken flow, keep sentence breaks as pauses.
    text = re.sub(r"\n{2,}", ". ", text)
    text = text.replace("\n", ", ")
    text = _WS.sub(" ", text)
    # ... Leerzeichen vor Satzzeichen und doppelte Satzzeichen entfernen
    return text.strip()

Die TTS-Antwort von gpt-4o-mini-tts wird per ffmpeg auf das feste Ausgabeformat des Geräts gebracht: 16 kHz, 16 Bit, Stereo.

Betrieb

Beide Systeme sind Werkzeuge für den eigenen Einsatz und für Experimente, keine Produkte mit Nutzerbasis.

Der ESP32-Server schreibt jedes Gespräch als JSON-Zeile in ein Protokoll: Transkript, Antwort, verwendetes Backend, Ein- und Ausgabedauer, Gesamtlaufzeit und Fehler. Optional werden Aufnahme und Antwort als WAV daneben archiviert. Ein Fehler beim Archivieren darf die Anfrage nie abbrechen. Mit diesem Protokoll lassen sich Fragen wie „Lag es an der Erkennung oder am Modell?“ in Sekunden beantworten.

Der Telefonagent protokolliert VAD-Zustände, Energiewerte und die Laufzeiten jeder Station. Gerade die Kalibrierwerte der VAD (Grundrauschen und abgeleitete Schwelle) waren beim Testen mit verschiedenen Telefonen die wichtigste Information.

Beim Datenschutz gilt: Der ESP32-Assistent transkribiert lokal, Chat und Sprachsynthese laufen über die OpenAI-API beziehungsweise den lokalen Agenten. Der Telefonagent nutzt ausschließlich Cloud-Dienste für Transkription, Antwort und Stimme. Wer so etwas produktiv mit fremden Anrufern betreibt, muss Aufzeichnung und Verarbeitung entsprechend transparent machen; die Mitschnittfunktion ist deshalb abschaltbar.

Ergebnis

Entstanden sind zwei funktionsfähige Voice-Agenten mit unterschiedlichem Interaktionsmodell:

  • Ein telefonartiger Agent, der über Browser oder echte Telefonanrufe erreichbar ist, selbst erkennt, wann jemand fertig gesprochen hat, und zwischen mehreren Anbietern für Transkription und Sprachsynthese umschalten kann, inklusive Rückfall auf den Datei-Modus und einen Ersatz-TTS-Dienst.
  • Ein Push-to-Talk-Assistent, der mit einer gemeinsamen Firmware auf zwei unterschiedlichen ESP32-S3-Boards läuft, den Verarbeitungsfortschritt sichtbar macht und wahlweise an ein schnelles Sprachmodell oder an einen lokalen Agenten mit Werkzeugen angebunden ist.

Als nächste Schritte sind das automatische Unterbrechen beim Dazwischenreden im Telefonpfad und ein Streaming-Rückkanal geplant, damit die Antwort schon gesprochen wird, während sie noch erzeugt wird.

Was ich dabei gelernt habe

  • Die VAD ist das eigentliche Produkt. Ob sich ein Sprachagent natürlich anfühlt, entscheidet weniger das Sprachmodell als die Frage, wann er zu sprechen beginnt. Eine adaptive Schwelle aus dem gemessenen Grundrauschen war der größte einzelne Fortschritt.
  • Push-to-Talk ist kein Rückschritt. Für ein Handgerät mit klar abgegrenzten Fragen ist es robuster als kontinuierliches Zuhören und spart die komplette Endpunkt-Erkennung.
  • Formate früh festnageln. Ein festes, headerloses Ausgabeformat für den Mikrocontroller hat mehr Probleme gelöst als jede Optimierung. Alle Umwandlungen gehören auf den Server.
  • Hardware-Wissen gehört in den Code. Mikrofonverstärkung, reservierte PSRAM-Pins und die Reihenfolge der SPI-Initialisierung stehen als kommentierte Konstanten in der Firmware, nicht nur in einer Notiz.
  • Agenten brauchen sprechbare Ausgaben. Ein Agent mit Werkzeugen ist mächtiger, aber langsamer. Für Sprache muss man die Antwortform erzwingen und die Wartezeit sichtbar machen, sonst wirkt das Gerät kaputt.

Technologien

  • Telefonartiger Agent: TypeScript, Node.js, Express, WebSocket (ws), Web Audio API, MediaRecorder, ffmpeg (fluent-ffmpeg), OpenAI SDK
  • Sprachdienste: OpenAI Whisper (whisper-1), Deepgram (Datei- und Streaming-Modus), gpt-4o-mini, OpenAI TTS (tts-1), ElevenLabs als Alternative
  • Telefonie: Media-Stream-Schnittstelle eines Telefonie-Anbieters, μ-law 8 kHz
  • ESP32-Assistent: ESP32-S3 (Waveshare ESP32-S3-Touch-AMOLED-1.8 und ESP32-S3-ePaper-1.54), Arduino-Framework, PlatformIO, ES8311-Codec über I2S, GxEPD2
  • Server: Python, Flask, openai-whisper (lokal, Modell small), gpt-4o-mini, gpt-4o-mini-tts, ffmpeg