Audio: Echo, VAD, Wakeword, Arbitrierung, Duplex
Wichtigste Anforderung: Der Assistent reagiert nie auf seine eigene Stimme. Netzwerkkameras als Mikrofon und Lautsprecher machen das schwer: Die Ausgabe kommt ĂŒber go2rtc und den Kamera-Backchannel mit 1â2 s Verzögerung und schwankender Latenz wieder im Mikrofon an. Deshalb gibt es mehrere unabhĂ€ngige Schutzschichten, nicht nur einen Echo-Canceller (ADR 0005).

Code: backend/src/justai/audio/.
Signalweg
Abschnitt betitelt âSignalwegâgo2rtc (RTSP) ââș ffmpeg ââș 16 kHz mono s16le ââș SampleClock (Zeitstempel) ââș MicChannel ââș pro Endpoint: EndpointAudioProcessor (10-ms-Frames, eigener Thread) 1. Gain 2. Echo-Fenster? (PlaybackTracker: Wiedergabe-Bursts + Latenz + Nachhall) 3. WebRTC APM: HPF, AEC3 (Referenz um die gemessene Latenz verschoben), NS 4. Silero-VAD, Rauschboden, Sprachsegmentierung 5. Gating: Half Duplex â im Echo-Fenster alles verwerfen Full Duplex â nur durchlassen, wenn Double-Talk erkannt (Barge-in) 6. openWakeWord (mit UnterdrĂŒckung wĂ€hrend der Wiedergabe) ââș Wake-Kandidaten ââș WakeArbiter ââș GesprĂ€chssession ââș ProcessedFrame (gated-Flag) ââș Session (STT bzw. Realtime-Upload)
Antwort ââș Output Router ââș SpeakerChannel (Echtzeit-Pacing) ââș Sink: go2rtc-Talkback | Plugin-Senke ââș jedes ausgegebene Frame mit Ausgabezeitpunkt ââș ReferenceBuffer + PlaybackTrackerEigene Lautsprecher-Verbindung pro GesprĂ€ch: Eine Tapo-Kamera spielt nur die Sprech-Sitzung ab, die zuletzt geöffnet wurde. FrĂŒher blieb der Kanal zum Kameralautsprecher stundenlang auf der Hauptverbindung offen. Hatte zwischendurch ein anderer Client gesprochen (z. B. die Tapo-App), kamen weiter Bytes an der Kamera an, aber nichts war zu hören. go2rtc erneuert diese Sitzung nicht, solange das Video die Verbindung am Leben hĂ€lt. Live beobachtet an der C200 (2026-10-20): Zustellung vollstĂ€ndig, nichts hörbar, auch nach go2rtc- und Kamera-Neustart; eine frisch geöffnete zweite Verbindung spielte sofort. Deshalb:
- Der Lautsprecher hat einen eigenen go2rtc-Stream
<stream>_talk(gleiche Quelle wie der Hauptstream, also eine eigene Kameraverbindung). Er wird bei jedem Ăffnen neu angelegt: beim Wakeword (VorwĂ€rmen), fĂŒr Testtöne und Automationen. - Nach 90 s ohne Ausgabe wird er geschlossen und gelöscht
(
speaker_idle_close_spro GerĂ€t ĂŒberschreibbar). Damit ist die Sprech-Sitzung der Kamera auch wieder fĂŒr die Tapo-App frei. - Der Tapo-Login der neuen Verbindung dauert gemessen ca. 4 s. Bis dahin hĂ€lt der
Core Sprache zurĂŒck (plus
talkback_settle_s, Standard 1,5 s), statt sie in die noch nicht verbundene Leitung zu schicken; der Anfang geht nicht verloren.
Zustellung prĂŒfen statt annehmen: go2rtc holt den Audiostrom auch dann weiter ab, wenn die Verbindung zum Kameralautsprecher verloren ging (z. B. nach einem Kamera-Reconnect). Das Audio verschwand dann lautlos. Der Core fragt deshalb jede Sekunde bei go2rtc ab, ob tatsĂ€chlich Bytes an die Kamera gehen. Erst dann gibt er Sprache frei, und nach 6 s ohne Zustellung baut er die Strecke neu auf (bei offline-Kamera mit wachsendem Abstand bis 60 s). Eine frisch geöffnete Verbindung hat 20 s Zeit fĂŒr den Login. GezĂ€hlt werden die Bytes an die Kamera auch dann, wenn go2rtc die Kameraverbindung als âConsumerâ fĂŒhrt (Stream nur fĂŒr den Lautsprecher); sonst hielt der WĂ€chter funktionierende Verbindungen fĂŒr tot und baute sie stĂ€ndig neu auf. Live geprĂŒft: Nach âNeu verbindenâ der C200 war die Strecke nach ~15 s wieder da (8 KB/s an die Kamera, stabil).
Talkback zu Kameras: Der Core stellt pro Lautsprecher einen Live-WAV-Stream
bereit (/internal/talkback/<token>.wav, Token zufÀllig, nur im internen Netz).
go2rtc zieht ihn als Quelle in den Backchannel der Kamera
(POST /api/ffmpeg?dst=<stream>&live=<url>). Der Core taktet die Ausgabe selbst in
Echtzeit. Deshalb weiĂ er fĂŒr jedes Frame, wann es ausgegeben wurde; das ist die
Echo-Referenz. Bricht der Pfad ab, wird er neu aufgebaut.
Schutzschichten gegen SelbstgesprÀche
Abschnitt betitelt âSchutzschichten gegen SelbstgesprĂ€cheâ| # | Schicht | Wo | Wirkt gegen |
|---|---|---|---|
| 1 | AEC3 mit Bulk-Delay-Kompensation | echo.py EchoCanceller + DelayEstimator |
Echo im Signal (Full Duplex) |
| 2 | Echo-Fenster + Half-Duplex-Gate: Solange eigene Ausgabe hörbar sein kann (Ausgabe + Latenz + Nachhall), wird das Mikrofon verworfen | PlaybackTracker, processor.py |
alles Echo, deterministisch |
| 3 | Wakeword-UnterdrĂŒckung wĂ€hrend der Wiedergabe: Half Duplex: keine Wakewords im Echo-Fenster; Full Duplex: nur ĂŒber threshold_during_playback (0,85) |
processor._run_wakeword |
Assistent sagt selbst âHey Justyâ |
| 4 | Echo-Transkriptfilter: Ăhnelt ein Benutzertranskript stark dem, was der Assistent in den letzten 25 s gesagt hat, wird es verworfen | conversation/echo_filter.py |
Restecho, das trotzdem transkribiert wurde |
| 5 | Wakeword-Nachlauf weglassen: Nach einer Pause beginnt die Session hinter der Pause; ĂuĂerungen mit weniger als 200 ms Sprache startet die Pipeline gar nicht erst (Sprechen ohne Pause) | conversation/session.py |
âJarvisâ-Rest oder Hall wird als Frage interpretiert oder blockiert die Session |
| 6 | InaktivitÀts-Timeout startet erst nach Ende des Echo-Fensters | conversation/state.py |
Session endet nicht wÀhrend der Ausgabe |
ZusĂ€tzlich: Der Echo-Fenster-Tracker merkt sich mehrere Ausgabe-Bursts, weil das Echo eines frĂŒheren Satzes noch unterwegs sein kann, wĂ€hrend der nĂ€chste beginnt.
Messung: AEC3 und lange Verzögerungen
Abschnitt betitelt âMessung: AEC3 und lange VerzögerungenâWebRTC AEC3 schĂ€tzt die Verzögerung zwischen Referenz und Mikrofon nur in einem
begrenzten Bereich. Messung mit scripts/measure_aec.py (synthetisches
sprachÀhnliches Signal, kurzer Nachhall, kein Nahsprecher; ERLE = DÀmpfung des
Echos nach Konvergenz):
| Echo-Verzögerung | AEC3 allein | AEC3 + Bulk-Delay-Kompensation |
|---|---|---|
| 0,05 s | 32,1 dB | 32,2 dB |
| 0,20 s | 31,4 dB | 32,3 dB |
| 0,40 s | 39,6 dB | 39,7 dB |
| 0,60 s | 10,5 dB | 40,0 dB |
| 0,80 s | 9,9 dB | 40,0 dB |
| 1,20 s | 9,8 dB | 40,3 dB |
| 1,80 s | 9,4 dB | 40,5 dB |
Ab etwa 0,5 s versagt AEC3 allein; ĂŒbrig bleiben ca. 10 dB aus der
NachunterdrĂŒckung. Netzwerkkameras liegen deutlich darĂŒber (C200: ~1,8 s).
JuStAI verschiebt die Referenz deshalb selbst um die gemessene Verzögerung und
ĂŒberlĂ€sst AEC3 nur noch den Rest (10 ms). Die Verzögerung schĂ€tzt
DelayEstimator laufend per GCC-PHAT aus Mikrofon und Referenz (nur SchÀtzungen
mit ausreichender Konfidenz, Median der letzten 7).
Echte RÀume mit Nachhall, NichtlinearitÀten billiger Lautsprecher und schwankender Netzwerklatenz erreichen weniger. Deshalb bleibt Schicht 2 (Gating) die Grundsicherung, und Full Duplex wird nur nach Messung aktiviert.
Duplex-Modi
Abschnitt betitelt âDuplex-Modiâ| Modus | Verhalten |
|---|---|
half |
Im Echo-Fenster wird das Mikrofon komplett verworfen. Kein Barge-in wÀhrend der Ausgabe. Sicher mit jedem GerÀt. |
full |
AEC aktiv, Mikrofon bleibt offen. Barge-in ĂŒber Double-Talk-Erkennung. Nur fĂŒr GerĂ€te mit echtem Full-Duplex-Audio und gutem Echo-Abstand. |
auto (Standard) |
Startet als half. Wechselt zu full nur, wenn das GerĂ€t audio.full_duplex meldet, die Verzögerung gemessen ist und die gemessene EchodĂ€mpfung (Median ĂŒber â„ 200 Frames) mindestens 18 dB betrĂ€gt. Der Grund steht in der Audio-Diagnose (duplex_reason). |
Barge-in (Full Duplex): Die AEC-NachunterdrĂŒckung dĂ€mpft auch die Stimme des
Benutzers wÀhrend Double-Talk. Die Erkennung arbeitet deshalb auf dem Rohsignal:
Mikrofonpegel minus erwarteter Echopegel (Referenzenergie + gemessene
Raumkopplung). Liegt der Pegel â„ 8 dB darĂŒber (mit VAD â„ 0,5, sonst â„ 14 dB) fĂŒr
rund zwei Drittel von barge_in_min_speech_ms, gilt das als Unterbrechung. Die
Ausgabe stoppt sofort, der Tracker kĂŒrzt das Echo-Fenster, und die Session hört zu.
Echo allein löst im Test nie einen Barge-in aus.
Kalibrierung
Abschnitt betitelt âKalibrierungâAdmin-UI â Endpoints â Kalibrieren (POST /api/endpoints/{id}/calibrate):
drei Sweeps (400â3200 Hz) ĂŒber den Lautsprecher, Aufnahme ĂŒber das Mikrofon,
Auswertung per GCC-PHAT. Ist die Korrelation zu schwach, wird der
Einschwing-Zeitpunkt (Onset) mit KonsensprĂŒfung ĂŒber alle Sweeps verwendet.
Ergebnisse und ihre Deutung:
echo delay measured: Verzögerung wird als Startwert gesetzt.- Kein Echo am Mikrofon: Das GerÀt schaltet sein Mikrofon wÀhrend der Wiedergabe stumm (Hardware-Half-Duplex, z. B. Tapo C200). Das ist sicher; die konservative Standardlatenz bleibt aktiv.
- Echo hörbar, aber nicht messbar: Half Duplex beibehalten.
Ohne Kalibrierung nimmt das System fĂŒr NetzwerkgerĂ€te konservativ 2,2 s Latenz an, fĂŒr lokale GerĂ€te 0,35 s. Die tatsĂ€chlich verwendete Latenz ist nie kleiner als die gemessene Verzögerung + 0,15 s.
VAD und ĂuĂerungsende
Abschnitt betitelt âVAD und ĂuĂerungsendeâSilero-VAD (ONNX) auf dem verarbeiteten Signal. Sprache beginnt nach 3
aufeinanderfolgenden Frames ĂŒber vad_threshold (30 ms) und endet nach 300 ms
darunter. Das Ende einer ĂuĂerung im Pipeline-Modus bestimmt
end_of_utterance_silence_ms (Standard 700 ms), HöchstlÀnge max_utterance_s.
Im Realtime-Modus ĂŒbernimmt der Provider die Turn-Erkennung; JuStAI schickt
dabei nur nicht-gegatete Frames.
openWakeWord 0.6.0 (ONNX), Schwelle 0,8 pro Endpoint (Standard), RefraktĂ€rzeit 1,5 s. Auswahl: RĂ€ume â Endpoint bearbeiten â Wakeword (mehrere gleichzeitig möglich).
Mitgeliefert: Hey Justy (hey_justi, Standard), Hey Jarvis, Alexa,
Hey Mycroft und Hey Rhasspy. âHey Justyâ ist mit synthetischen englischen und
deutschen Stimmen trainiert und reagiert auf âHey Justyâ und âHey Justiâ. Es ist
empfindlicher als die anderen Modelle, deshalb ist die Standard-Schwelle 0,8 (nur senken,
wenn es nicht reagiert). Die anderen sind auf Englisch trainiert und funktionieren genauso
gut, wenn der Rest des GesprĂ€chs Deutsch oder Italienisch ist; fĂŒr sie ist 0,5 ein guter
Start. Mischt man Modelle in einem Endpoint, gilt die Schwelle fĂŒr alle.
Eigene Wakewords (z. B. âHey Justiâ): Ein openWakeWord-Modell trainieren (Anleitung und
Skripte: tools/wakeword, nur CPU, kein Colab nötig), dann die .onnx-Datei im selben Tab mit Eigenes Wakeword
hochladen installieren. Der Dateiname wird zur ID (hey_justi.onnx â hey_justi).
Der Core prĂŒft, ob es wirklich ein openWakeWord-Klassifikator ist (Eingang
[1, n, 96], Ausgang [1, 1]), und legt ihn im Volume /data/wakewords ab, sodass
er Updates ĂŒbersteht. Eigene Modelle lassen sich löschen
(DELETE /api/ai/wakewords/<id>), solange kein Endpoint sie nutzt.
Zuhören pausieren: âHör im Schlafzimmer eine Stunde nicht zuâ schaltet das Wakeword
der Endpoints im Raum ab (audio.listening); eine einmalige Automation schaltet es
wieder ein (DEVICES.md). Bleibt eine Kamera
stumm: âVerbinde die Kamera im BĂŒro neuâ (devices.reconnect) und âTeste den
Lautsprecher im BĂŒroâ (devices.test_speaker).
Raumbewusste Wakeword-Arbitrierung
Abschnitt betitelt âRaumbewusste Wakeword-Arbitrierungâaudio/arbitration.py. Mehrere Mikrofone hören dasselbe Wakeword. Kandidaten aus
derselben Arbitrierungsgruppe, die innerhalb von 0,8 s eintreffen, werden
gesammelt, bewertet, und genau einer gewinnt. Ein zuverlÀssiger Raum ist wichtiger
als die schnellste Aktivierung.
score = 10·wake_score + 0.25·clamp(SNR_phrase, â10, 70) + 2·VAD + priority â 3 (falls wĂ€hrend eigener Wiedergabe) + 100 (falls dort schon eine Session lĂ€uft)- SNR der Phrase: 90. Perzentil der Rohpegel der letzten 1,2 s (das ist das Wakeword selbst, nicht die Pause danach) minus Roh-Rauschboden. Die RauschunterdrĂŒckung wĂŒrde den echten Abstand verfĂ€lschen. LautstĂ€rke ist der beste Hinweis auf NĂ€he und dominiert bewusst kleine Unterschiede in der Wakeword-Konfidenz.
- SpÀte Duplikate: Mikrofone hinter unterschiedlichen Netzwerkpfaden liefern
dieselbe ĂuĂerung unterschiedlich spĂ€t. Kandidaten anderer Endpoints derselben
Gruppe, die innerhalb von 2 s nach einer Entscheidung eintreffen, werden verworfen
(
audio.wakeword.rejected). Ein spÀteres, echtes Wakeword wird akzeptiert. - Endpoints in verschiedenen Gruppen werden unabhÀngig entschieden: Parallele GesprÀche sind möglich.
Events: audio.wakeword.detected (jeder Kandidat), audio.wakeword.accepted
(mit Scores aller Kandidaten), audio.wakeword.rejected.
Sprechen ohne Pause nach dem Wakeword
Abschnitt betitelt âSprechen ohne Pause nach dem WakewordââHey Justy, wie spĂ€t ist es?â in einem Atemzug funktioniert. Zwischen Erkennung und bereiter Session vergehen 1,4â1,7 s (gemessen: Arbitrierungsfenster 0,8 s, Sessionaufbau 0,55â0,9 s inkl. Verbindung zu Gemini Live). Damit diese Worte nicht verloren gehen:
- Jeder Endpoint hÀlt die letzten 10 s verarbeitetes Audio (
EndpointAudio.recent). - Ist die Session bereit, bekommt sie zuerst das gepufferte Audio ab dem Wakeword,
dann live weiter (ohne
awaitdazwischen: kein Frame fehlt oder kommt doppelt). - Wo das Gesagte beginnt, entscheidet die SprachaktivitÀt (
replay_start):- Pause (â„ 150 ms unter der Sprachschwelle innerhalb von 0,8 s nach der Erkennung): ab der Pause; der Nachhall von âJarvisâ bleibt drauĂen.
- Keine Pause: ab 0,15 s vor der Erkennung. openWakeWord meldet gemessen 0,04â0,27 s nach dem Ende von âHey Justyâ, also oft schon wĂ€hrend des ersten Wortes der Frage; so bleibt es erhalten.
- Die Pipeline startet keine Erkennung fĂŒr âĂuĂerungenâ mit weniger als 200 ms Sprache (Klicks, Hall). Sonst wĂ€re die Session wĂ€hrend der unnötigen Spracherkennung taub fĂŒr die eigentliche Frage.
Getestet: tests/unit/test_wake_replay.py, tests/audio/test_wake_replay.py
(echtes openWakeWord, Piper-Stimme: mit und ohne Pause), E2E
test_question_right_after_the_wakeword_without_a_pause in Pipeline- und
Realtime-Modus (vorher fehlgeschlagen: die Frage ging verloren).
backend/tests/unit/test_echo_blocks.py,test_wake_arbitration.py,test_calibration.py: Bausteine (Referenzpuffer, GCC-PHAT, Tracker, Arbitrierung inkl. spĂ€ter Duplikate, Onset-Kalibrierung).backend/tests/audio/test_self_speech.py(echte Modelle: Piper, openWakeWord, Silero): Der Assistent sagt absichtlich âHey Jarvisâ in seiner Antwort; die Ausgabe kommt ĂŒber einen simulierten Raum (Netzwerkverzögerung, Hall, Rauschen, optional Tapo-artiges Ducking) zurĂŒck.- Half Duplex Tapo-artig, Half Duplex mit lautem Echo ohne Ducking: kein Selbst-Wake, kein durchgelassenes Echo, das echte Wakeword danach wird erkannt.
- UnterschÀtzte Latenz wird durch Messung korrigiert.
- Full Duplex: AEC unterdrĂŒckt Selbst-Wake, echter Barge-in wird erkannt; Echo allein löst nie Barge-in aus.
- E2E (
backend/tests/e2e): GesprĂ€ch ĂŒber simulierte GerĂ€te mit Echo, keine Echo-Turns im Transkript, Arbitrierung zwischen zwei RĂ€umen. - Echte Hardware (Tapo C200): Wakeword â Antwort ĂŒber den Kameralautsprecher, kein Echo-Loop, Ende durch InaktivitĂ€t. Barge-in mit der C200 ist wegen des Hardware-Duckings nicht möglich und wurde nicht getestet.