feat: Audio-Streaming als Default (Admin abschaltbar via AUDIO_STREAM_DEFAULT)

- Config audio_stream_default=True; _run_turn nutzt es, wenn die Anfrage audio_stream
  nicht explizit setzt (explizite Anfrage gewinnt)
- voice_loop: immer durchgehender Player (spielt 1 oder N Haeppchen luekenlos);
  --stream-audio / --no-stream-audio als Tri-State (sonst entscheidet der Server-Default)
- conftest: audio_stream_default=False fuer deterministische Tests; neuer Test fuer
  Default-an-Streaming ueber /ws/voice; 67 Tests gruen
- Doku + .env.example aktualisiert (AUDIO_STREAM_DEFAULT, --no-stream-audio)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-17 18:07:36 +02:00
commit 7c1f00662c
7 changed files with 52 additions and 28 deletions

View file

@ -7,6 +7,10 @@ LOG_LEVEL=info
# Secret nur ueber die Umgebung setzen (nicht hier eintragen), z. B. export in ~/.bashrc
OPENROUTER_API_KEY=
# Satzweises Vorlesen (Audio-Streaming) als Default fuer WebSocket-Turns.
# false = Antwort erst komplett synthetisieren, dann abspielen.
AUDIO_STREAM_DEFAULT=true
# --- Authentifizierung -----------------------------------------------------
# AUTH_ENABLED=true (Standard) schuetzt chat/speak/transcribe/sessions per Bearer-Token.
# Fuer lokale Entwicklung/Tests auf false setzen (dann gilt ein anonymer Nutzer).

View file

@ -109,8 +109,8 @@ Ablauf je Runde:
Nützliche Optionen:
```bash
python scripts/voice_loop.py --stream-audio # satzweises Vorlesen: Antwort beginnt frueher
python scripts/voice_loop.py --stream-text # Antworttext live anzeigen (kombinierbar mit --stream-audio)
python scripts/voice_loop.py --stream-text # Antworttext live anzeigen, waehrend die KI generiert
python scripts/voice_loop.py --no-stream-audio # satzweises Vorlesen abschalten (Audio erst komplett)
python scripts/voice_loop.py --recorder pw-record # PipeWire-Aufnahme (Standard bei 'auto')
python scripts/voice_loop.py --recorder arecord --device hw:1,0 # ALSA, bestimmtes Mikrofon
python scripts/voice_loop.py --llm-provider openrouter --tts-provider openrouter
@ -177,11 +177,11 @@ curl -s -X POST "$URL/api/chat?debug=true" \
Wie viele Nachrichten einfließen, steuert `HISTORY_MAX_MESSAGES` (Standard 10).
- **Echtzeit-Streaming:** WebSocket `/ws/chat` mit `{"text":"…","stream":true}` liefert
die Antwort wortweise; `"audio_stream":true` zusätzlich das Audio satzweise. Im
Sprech-Loop aktivierst du das satzweise Vorlesen mit `--stream-audio` (das Vorlesen
beginnt dann schon nach dem ersten Satz, statt erst nach der ganzen Antwort) und die
**Live-Anzeige des Antworttextes** mit `--stream-text` (erscheint Wort für Wort,
während die KI generiert — beides kombinierbar). Provider-Overrides, `--stream-audio`
und `--stream-text` wirken auch über `/ws/voice` (start-Frame).
**Satzweises Vorlesen ist jetzt Standard** (das Vorlesen beginnt schon nach dem ersten
Satz; abschaltbar serverseitig mit `AUDIO_STREAM_DEFAULT=false` oder pro Aufruf mit
`--no-stream-audio`). Die **Live-Anzeige des Antworttextes** aktivierst du mit
`--stream-text` (erscheint Wort für Wort, während die KI generiert). Provider-Overrides
und diese Schalter wirken auch über `/ws/voice` (start-Frame).
- **Unterbrechen (Barge-in):** während der Assistent spricht `{"type":"interrupt"}`
senden → laufende Antwort wird abgebrochen.
- **Automatische Sprechpausen-Erkennung (VAD):** im Start-Frame von `/ws/voice`
@ -232,14 +232,14 @@ echo 'LOCAL_LLM_MODEL=llama3.2:latest' >> .env
make run
# in Terminal 2 — Sprech-Loop mit der Hybrid-Kombi (MOTU = plughw:5,0):
python scripts/voice_loop.py --recorder arecord --device plughw:5,0 --session hybrid \
--stream-audio \
--stream-text \
--stt-provider faster-whisper \
--llm-provider local-openai-compatible \
--tts-provider openrouter
```
Erster Turn ist langsamer (Whisper- und Ollama-Modell laden), danach zügig. STT-Modell
und Gerät steuern `FASTER_WHISPER_MODEL`/`FASTER_WHISPER_DEVICE` in `.env`. `--stream-audio`
lässt das Vorlesen schon nach dem ersten Satz beginnen (geringere Wartezeit bis zum Ton).
und Gerät steuern `FASTER_WHISPER_MODEL`/`FASTER_WHISPER_DEVICE` in `.env`. Satzweises
Vorlesen ist Standard (früher Ton); `--stream-text` zeigt den Text live dazu.
## B2. Soundquelle & Ausgabe-Gerät wechseln (Mikrofon, Lautsprecher, Bluetooth, Handy)

View file

@ -92,7 +92,9 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out
voice = options.get("voice") or settings.openrouter_tts_voice
stream = bool(options.get("stream"))
audio_stream = bool(options.get("audio_stream"))
# audio_stream: explizite Anfrage gewinnt, sonst der serverseitige Default (Admin).
audio_stream = bool(options["audio_stream"]) if "audio_stream" in options \
else settings.audio_stream_default
on_token = None
if stream:

View file

@ -127,6 +127,7 @@ class Settings(BaseSettings):
admin_api_key: str = ""
auth_enabled: bool = True
history_max_messages: int = 10
audio_stream_default: bool = True # satzweises TTS als Default (Admin kann abschalten)
stt_fallback: str = "" # kommaseparierte Provider-Namen (Fallback-Kette)
llm_fallback: str = ""
tts_fallback: str = ""

View file

@ -190,8 +190,9 @@ def _close_player(player) -> None:
def _start_frame(args) -> dict:
frame = {"type": "start", "format": "wav"}
if getattr(args, "stream_audio", False):
frame["audio_stream"] = True
# audio_stream ist Tri-State: True/False explizit, None -> Server-Default entscheidet.
if getattr(args, "stream_audio", None) is not None:
frame["audio_stream"] = args.stream_audio
if getattr(args, "stream_text", False):
frame["stream"] = True
for key in ("stt_provider", "llm_provider", "tts_provider", "language"):
@ -252,22 +253,21 @@ async def one_turn(ws, wav: bytes, start_frame: dict, player=None) -> bytes:
return audio
async def _send_and_play(url: str, wav: bytes, start_frame: dict, stream_audio: bool = False) -> None:
async def _send_and_play(url: str, wav: bytes, start_frame: dict) -> None:
"""Eine kurze Verbindung pro Runde (Gedaechtnis bleibt serverseitig via session_id).
Ohne Stream: komplettes Audio sammeln, NACH dem Schliessen abspielen.
Mit Stream-Audio: durchgehenden Player oeffnen, Haeppchen sofort einspeisen; der
Player wird nach dem Schliessen der Verbindung geleert (kein Keepalive-Timeout)."""
player = open_stream_player() if stream_audio else None
if stream_audio and player is None:
print(" (kein Player fuer Stream-Audio gefunden spiele am Ende komplett ab)")
Es wird immer ein durchgehender Player benutzt: der spielt sowohl viele Haeppchen
(Stream-Audio) als auch ein einzelnes Komplett-Audio luekenlos ab. Wird kein Player
gefunden, wird das Audio gesammelt und als WAV abgespielt. Die Wiedergabe blockiert
nie die offene Verbindung (Schreiben im Thread; Player wird nach Schliessen geleert)."""
player = open_stream_player()
try:
async with websockets.connect(url, max_size=None) as ws:
audio = await one_turn(ws, wav, start_frame, player)
finally:
if player is not None:
await asyncio.to_thread(_close_player, player)
if audio:
if audio: # nur wenn kein Streaming-Player verfuegbar war
play_pcm(audio)
@ -281,12 +281,11 @@ async def run(args) -> None:
with open(args.file, "rb") as fh:
wav = fh.read()
print(f"Sende Datei: {args.file}")
await _send_and_play(url, wav, start_frame, args.stream_audio)
await _send_and_play(url, wav, start_frame)
return
recorder = resolve_recorder(args.recorder)
print(f"Ziel: {args.url} (Session '{args.session}')"
+ (" [Stream-Audio: satzweises Vorlesen]" if args.stream_audio else ""))
print(f"Ziel: {args.url} (Session '{args.session}')")
print(f"Aufnahme mit: {recorder}"
+ (f" (Gerät: {args.device})" if args.device else " (Standardgerät)"))
print("Sprich nach 'START', stoppe mit Enter. Strg+C beendet den Loop.")
@ -304,7 +303,7 @@ async def run(args) -> None:
print(" python scripts/voice_loop.py --recorder arecord --device plughw:2,0")
continue
try:
await _send_and_play(url, wav, start_frame, args.stream_audio)
await _send_and_play(url, wav, start_frame)
except Exception as exc: # noqa: BLE001 - Verbindung pro Runde; Fehler nicht fatal
print(f" Verbindungsfehler: {exc}")
@ -324,9 +323,12 @@ def main() -> None:
p.add_argument("--llm-provider", dest="llm_provider", default=None)
p.add_argument("--tts-provider", dest="tts_provider", default=None)
p.add_argument("--language", default=None)
p.add_argument("--stream-audio", dest="stream_audio", action="store_true",
help="Antwort satzweise vorlesen, sobald der erste Satz fertig ist "
"(geringere Zeit bis zum ersten Ton)")
# Audio-Streaming: Default entscheidet der Server (AUDIO_STREAM_DEFAULT, i. d. R. an).
audio_grp = p.add_mutually_exclusive_group()
audio_grp.add_argument("--stream-audio", dest="stream_audio", action="store_const", const=True,
default=None, help="satzweises Vorlesen erzwingen (frueher Ton)")
audio_grp.add_argument("--no-stream-audio", dest="stream_audio", action="store_const", const=False,
default=None, help="satzweises Vorlesen abschalten (komplett am Ende)")
p.add_argument("--stream-text", dest="stream_text", action="store_true",
help="Antworttext live am Monitor anzeigen, waehrend die KI ihn erzeugt")
p.add_argument("--file", default=None, help="WAV statt Mikrofon senden (Test ohne Aufnahme)")

View file

@ -17,6 +17,8 @@ def reset_state(tmp_path, monkeypatch):
metrics.reset()
monkeypatch.setattr(settings, "auth_enabled", False)
monkeypatch.setattr(settings, "admin_api_key", "")
# deterministische Event-Reihenfolge in Tests; Stream-Tests setzen es explizit
monkeypatch.setattr(settings, "audio_stream_default", False)
yield
deps._store = None
deps._audio_router = None

View file

@ -136,6 +136,19 @@ def test_ws_voice_stream_text_emits_token_events(monkeypatch):
assert ws.receive_json()["type"] == "token" # Antworttext kommt als token-Event(e)
def test_ws_voice_audio_stream_default_on(monkeypatch):
# Server-Default an -> Audio wird gestreamt, auch ohne expliziten audio_stream-Flag.
monkeypatch.setattr(settings, "audio_stream_default", True)
opts = _install_voice_stubs(monkeypatch)
with client.websocket_connect("/ws/voice") as ws:
ws.send_json({"type": "start", "format": "wav", **opts}) # kein audio_stream gesetzt
ws.send_bytes(b"PCMDATA")
ws.send_json({"type": "end"})
assert ws.receive_json()["type"] == "transcript"
assert ws.receive_json()["type"] == "ack"
assert ws.receive_json()["type"] == "audio" # Audio VOR semantic -> Streaming aktiv
def test_ws_voice_empty_buffer_errors(monkeypatch):
opts = _install_voice_stubs(monkeypatch)
with client.websocket_connect("/ws/voice") as ws: