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

@ -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)")