feat(voice_loop): per Default System-Standardgeraete + Probe gegen tote Geraete

'auto'-Recorder folgt jetzt dem System-Standard-Mikrofon und prueft per Kurztest
(~0,6 s), dass das Werkzeug WIRKLICH Audio liefert -> nie ein totes Geraet.
Kandidaten ffmpeg(-f pulse) -> parecord -> arecord -> pw-record; auf PipeWire-
Systemen ohne aktive Session gewinnt ffmpeg (gemessen: pw-record/arecord-default/
parecord liefern hier nichts). Neuer Recorder 'ffmpeg'. Player: paplay vor aplay
(folgt System-Default-Sink). --device nur noch, wenn man bewusst abweichen will.
Doku (Bedienung + B2 + Fehlerbehebung) entsprechend aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-17 23:54:45 +02:00
commit 295f066b6a
2 changed files with 99 additions and 43 deletions

View file

@ -5,16 +5,19 @@ Nimmt vom Mikrofon auf (Push-to-Talk), schickt das Audio ueber EINE
/ws/voice-Verbindung an das Gateway und spielt die Antwort ab. Das Gespraechs-
gedaechtnis bleibt ueber die `session_id` erhalten.
Standardmaessig folgt der Loop dem **System-Standard-Mikrofon und -Lautsprecher**
(inkl. Bluetooth). Ein bestimmtes Geraet nur, wenn `--device` explizit gesetzt ist.
Beispiele:
python scripts/voice_loop.py
python scripts/voice_loop.py # System-Standardgeraete
python scripts/voice_loop.py --url ws://localhost:8003/ws/voice --session oma-anna
python scripts/voice_loop.py --device hw:1,0 # bestimmtes Mikrofon (arecord -L)
python scripts/voice_loop.py --recorder arecord --device plughw:6,0 # bestimmtes Mikrofon
python scripts/voice_loop.py --llm-provider openrouter --tts-provider openrouter
python scripts/voice_loop.py --tts-provider openrouter --voice Puck # Cloud-Stimme testen
python scripts/voice_loop.py --file frage.wav # ohne Mikrofon (Test)
Voraussetzungen: laufendes Gateway, `arecord` (Aufnahme), ein Player
(`ffplay`/`aplay`/`paplay`), Python-Paket `websockets`.
Voraussetzungen: laufendes Gateway, ein Aufnahmewerkzeug (`ffmpeg`/`parecord`/`arecord`),
ein Player (`ffplay`/`paplay`/`aplay`), Python-Paket `websockets`.
"""
from __future__ import annotations
@ -23,11 +26,13 @@ import argparse
import asyncio
import io
import json
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import wave
try:
@ -46,28 +51,73 @@ def _require(tool: str) -> str:
def _first_player() -> list[str] | None:
# paplay vor aplay: paplay folgt dem System-Standard-Ausgabegeraet (PulseAudio),
# aplay nutzt das ALSA-Default, das auf PipeWire-Systemen oft tot ist.
if shutil.which("ffplay"):
return ["ffplay", "-loglevel", "quiet", "-nodisp", "-autoexit"]
if shutil.which("aplay"):
return ["aplay", "-q"]
if shutil.which("paplay"):
return ["paplay"]
if shutil.which("aplay"):
return ["aplay", "-q"]
return None
def resolve_recorder(choice: str) -> str:
"""Waehlt das Aufnahmewerkzeug. 'auto' bevorzugt PipeWire (pw-record)."""
def resolve_recorder(choice: str, rate: int = 16000) -> str:
"""Waehlt das Aufnahmewerkzeug.
'auto' folgt dem **System-Standard-Mikrofon** und prueft per kurzem Test, dass das
Werkzeug WIRKLICH Audio liefert -> es wird nie ein totes Geraet gewaehlt. Reihenfolge
der Kandidaten: ffmpeg (PulseAudio-Default) -> parecord -> arecord -> pw-record.
"""
if choice != "auto":
if not shutil.which(choice):
sys.exit(f"Fehlt: Aufnahmewerkzeug '{choice}' nicht gefunden.")
return choice
for tool in ("pw-record", "parecord", "arecord"):
if shutil.which(tool):
available = [t for t in ("ffmpeg", "parecord", "arecord", "pw-record") if shutil.which(t)]
if not available:
sys.exit("Kein Aufnahmewerkzeug gefunden (ffmpeg / parecord / arecord / pw-record).")
print(" Prüfe Standard-Aufnahmegerät …", flush=True)
for tool in available:
if _probe_records(tool, rate):
return tool
sys.exit("Kein Aufnahmewerkzeug gefunden (pw-record / parecord / arecord).")
print(f" ⚠ Kein Werkzeug lieferte im Test Audio; nutze '{available[0]}'."
" Ggf. --recorder/--device explizit setzen.")
return available[0]
def _probe_records(recorder: str, rate: int) -> bool:
"""Kurztest (~0,6 s), ob 'recorder' am System-Default tatsaechlich aufnimmt."""
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp.close()
size = 0
try:
proc = subprocess.Popen(
_record_cmd(recorder, None, rate, tmp.name),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
time.sleep(0.6)
proc.send_signal(signal.SIGINT)
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
size = os.path.getsize(tmp.name)
except Exception: # noqa: BLE001 - jedes Problem = Werkzeug taugt nicht
size = 0
finally:
try:
os.unlink(tmp.name)
except OSError:
pass
return size > 2000 # mehr als WAV-Header (44 B) + etwas Audio
def _record_cmd(recorder: str, device: str | None, rate: int, outfile: str) -> list[str]:
if recorder == "ffmpeg":
# PulseAudio-Eingang folgt dem System-Standard-Mikrofon (device=None -> "default").
return ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y",
"-f", "pulse", "-i", device or "default",
"-ar", str(rate), "-ac", "1", outfile]
if recorder == "arecord":
cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(rate), "-c", "1"]
if device:
@ -158,13 +208,14 @@ def open_stream_player():
waehrend die KI noch weiter generiert. None, wenn kein Player gefunden wird.
"""
rate = str(TTS_SAMPLE_RATE)
# paplay vor aplay: folgt dem System-Standard-Ausgabegeraet (PulseAudio/PipeWire).
if shutil.which("ffplay"):
cmd = ["ffplay", "-loglevel", "quiet", "-nodisp", "-autoexit",
"-f", "s16le", "-ar", rate, "-ac", "1", "-i", "pipe:0"]
elif shutil.which("aplay"):
cmd = ["aplay", "-q", "-f", "S16_LE", "-r", rate, "-c", "1"]
elif shutil.which("paplay"):
cmd = ["paplay", "--raw", f"--rate={rate}", "--format=s16le", "--channels=1"]
elif shutil.which("aplay"):
cmd = ["aplay", "-q", "-f", "S16_LE", "-r", rate, "-c", "1"]
else:
return None
return subprocess.Popen(cmd, stdin=subprocess.PIPE)
@ -309,10 +360,10 @@ async def run(args) -> None:
await _send_and_play(url, wav, start_frame)
return
recorder = resolve_recorder(args.recorder)
recorder = resolve_recorder(args.recorder, args.rate)
print(f"Ziel: {args.url} (Session '{args.session}')")
print(f"Aufnahme mit: {recorder}"
+ (f" (Gerät: {args.device})" if args.device else " (Standardgerät)"))
+ (f" (Gerät: {args.device})" if args.device else " (System-Standardgerät)"))
print("Sprich nach 'START', stoppe mit Enter. Strg+C beendet den Loop.")
while True:
try:
@ -321,11 +372,10 @@ async def run(args) -> None:
print("\nEnde.")
return
if len(wav) < 1000:
print(" ⚠ Keine/zu kurze Aufnahme. Moegliche Ursache: Aufnahmewerkzeug"
" oder Geraet nicht nutzbar.")
print(" Verfuegbare Mikrofone: arecord -l")
print(" Direktes ALSA-Geraet verwenden, z. B.:")
print(" python scripts/voice_loop.py --recorder arecord --device plughw:2,0")
print(" ⚠ Keine/zu kurze Aufnahme. Pruefe das System-Standard-Mikrofon"
" (Ubuntu: Einstellungen → Ton → Eingabe) und ob es Pegel zeigt.")
print(" Notfalls direktes ALSA-Geraet erzwingen (arecord -l zeigt die Nummer):")
print(" python scripts/voice_loop.py --recorder arecord --device plughw:6,0")
continue
try:
await _send_and_play(url, wav, start_frame)
@ -339,10 +389,13 @@ def main() -> None:
p.add_argument("--session", default="voice-loop")
p.add_argument("--token", default=None, help="Bearer-Token, falls AUTH_ENABLED=true")
p.add_argument("--device", default=None,
help="Aufnahmegeraet (arecord: -L; pw-record: --target; parecord: --device)")
help="Aufnahmegeraet explizit (ffmpeg/parecord: PulseAudio-Quelle; "
"arecord: ALSA z. B. plughw:6,0; pw-record: --target). "
"Ohne Angabe folgt der Loop dem System-Standardgeraet.")
p.add_argument("--recorder", default="auto",
choices=["auto", "pw-record", "parecord", "arecord"],
help="Aufnahmewerkzeug; 'auto' bevorzugt PipeWire (pw-record)")
choices=["auto", "ffmpeg", "pw-record", "parecord", "arecord"],
help="Aufnahmewerkzeug; 'auto' folgt dem System-Standardmikrofon und "
"prueft per Kurztest, dass es wirklich aufnimmt")
p.add_argument("--rate", type=int, default=16000)
p.add_argument("--stt-provider", dest="stt_provider", default=None)
p.add_argument("--llm-provider", dest="llm_provider", default=None)