From 7c1f00662cb6ead65ebad8b0876fb2312e84db09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 18:07:36 +0200 Subject: [PATCH] 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 --- .env.example | 4 ++++ BEDIENUNGSANLEITUNG.md | 20 ++++++++++---------- app/api/ws.py | 4 +++- app/config.py | 1 + scripts/voice_loop.py | 36 +++++++++++++++++++----------------- tests/conftest.py | 2 ++ tests/test_ws.py | 13 +++++++++++++ 7 files changed, 52 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 8dd2e81..fa41003 100644 --- a/.env.example +++ b/.env.example @@ -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). diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 98b5c5f..8dc1287 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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) diff --git a/app/api/ws.py b/app/api/ws.py index 17f6282..4227131 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -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: diff --git a/app/config.py b/app/config.py index 2a43dd9..6011091 100644 --- a/app/config.py +++ b/app/config.py @@ -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 = "" diff --git a/scripts/voice_loop.py b/scripts/voice_loop.py index 5db35dd..0798968 100644 --- a/scripts/voice_loop.py +++ b/scripts/voice_loop.py @@ -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)") diff --git a/tests/conftest.py b/tests/conftest.py index cf9e574..ca09f51 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_ws.py b/tests/test_ws.py index e738581..2e3e1f2 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -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: