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

@ -111,22 +111,21 @@ Nützliche Optionen:
```bash ```bash
python scripts/voice_loop.py --stream-text # Antworttext live anzeigen, waehrend die KI generiert 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 --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 plughw:6,0 # bestimmtes Mikrofon erzwingen
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 python scripts/voice_loop.py --llm-provider openrouter --tts-provider openrouter
python scripts/voice_loop.py --token "$TOKEN" # falls AUTH_ENABLED=true python scripts/voice_loop.py --token "$TOKEN" # falls AUTH_ENABLED=true
python scripts/voice_loop.py --file frage.wav # ohne Mikrofon: WAV senden (Test) python scripts/voice_loop.py --file frage.wav # ohne Mikrofon: WAV senden (Test)
``` ```
> **Aufnahmewerkzeug:** `--recorder auto` (Standard) bevorzugt **PipeWire** (`pw-record`), > **Geräte = System-Standard (automatisch):** Ohne `--device` folgt der Loop dem
> sonst `parecord`/`arecord`. **Falls die Aufnahme scheitert** (z. B. > **am System eingestellten Standard-Mikrofon und -Lautsprecher** (inkl. Bluetooth —
> *„pw_context_connect() failed"* oder *„Fehler beim Öffnen des Gerätes"*), ist der > umstellbar über *Ubuntu → Einstellungen → Ton*, siehe Teil B2). `--recorder auto`
> verlässlichste Weg ein **direktes ALSA-Hardware-Gerät**: > (Standard) wählt selbsttätig ein Aufnahmewerkzeug, das dem Default folgt **und** im
> ```bash > **Kurztest wirklich Audio liefert** (Reihenfolge `ffmpeg``parecord``arecord`
> arecord -l # Kartennummern der Mikrofone > `pw-record`) — so wird nie ein totes Gerät gewählt. Beim Start erscheint kurz
> python scripts/voice_loop.py --recorder arecord --device plughw:2,0 > „Prüfe Standard-Aufnahmegerät …".
> ``` > **Ein bestimmtes Mikrofon** nur bei Bedarf erzwingen, z. B. `--recorder arecord
> (`plughw:KARTE,GERÄT` aus `arecord -l`; z. B. onboard oft Karte 2, USB-Webcam Karte 4.) > --device plughw:6,0` (`arecord -l` zeigt die Kartennummer).
## A2. Nur tippen → Antwort hören ## A2. Nur tippen → Antwort hören
@ -390,20 +389,24 @@ pactl set-default-sink <SINK_NAME> # z. B. die Bluetooth-Box
Sobald die Bluetooth-Box dort als Standard gesetzt ist (Schritt 2 oben), kommt die Sobald die Bluetooth-Box dort als Standard gesetzt ist (Schritt 2 oben), kommt die
gesprochene Antwort **automatisch über die Box** — ohne zusätzliche Option. Das ist der gesprochene Antwort **automatisch über die Box** — ohne zusätzliche Option. Das ist der
Grund, warum die Bluetooth-Umleitung „einfach funktioniert". Grund, warum die Bluetooth-Umleitung „einfach funktioniert".
- **Eingabe (Aufnahme):** Die Aufnahme folgt **nicht** automatisch dem System-Default — - **Eingabe (Aufnahme):** `voice_loop.py` folgt mit `--recorder auto` (Standard) ebenfalls
`voice_loop.py` nimmt mit einem fest gewählten Gerät auf. Damit der **ReSpeaker** genutzt dem **System-Standard-Mikrofon** — es probiert beim Start automatisch ein Werkzeug, das
wird, das Gerät explizit angeben (ReSpeaker = **Karte 6**`plughw:6,0`): dem Default folgt und im Kurztest wirklich Audio liefert (`ffmpeg``parecord``arecord`
`pw-record`). Stellst du also das Eingabegerät in *Einstellungen → Ton* um (z. B. auf den
ReSpeaker), nutzt der Loop es ohne weitere Option.
- **Bestimmtes Mikrofon erzwingen** (statt System-Default), z. B. den ReSpeaker fix als ALSA-
Gerät (Karte 6):
```bash ```bash
python scripts/voice_loop.py --recorder arecord --device plughw:6,0 python scripts/voice_loop.py --recorder arecord --device plughw:6,0
``` ```
(Auf diesem Rechner ist `--recorder arecord` mit `plughw:…` der zuverlässige Weg, weil die (Hilfreich, wenn du gezielt ein anderes als das Standard-Mikrofon willst; Kartennummer mit
Default-folgenden Recorder `pw-record`/`parecord` hier nicht stabil verbinden. Die richtige `arecord -l`. Auf diesem Rechner scheitern `pw-record`/`arecord default` — die `auto`-Probe
Kartennummer notfalls mit `arecord -l` prüfen.) überspringt sie automatisch und nimmt `ffmpeg -f pulse`.)
Kurz: **Lautsprecher/Bluetooth umstellen → System-Einstellungen genügen.** Kurz: **Mikrofon UND Lautsprecher/Bluetooth umstellen → die System-Einstellungen genügen;
**Mikrofon für den Sprech-Loop → zusätzlich `--device plughw:6,0` mitgeben.** der Sprech-Loop folgt dem Standard automatisch.** `--device` nur, wenn du bewusst abweichen willst.
### Weitere Einstellungen, die du vornehmen kannst ### Weitere Einstellungen, die du vornehmen kannst
- **Ausgabe-Lautstärke / Mikrofon-Empfindlichkeit:** *Ton*-Seite oder - **Ausgabe-Lautstärke / Mikrofon-Empfindlichkeit:** *Ton*-Seite oder
@ -614,7 +617,7 @@ curl -s "$URL/api/metrics?format=prometheus" # Prometheus-Text (kein jq)
| HTTP **502** bei STT/TTS | Cloud-Fehler/Format | `make smoke` ausführen; Modellnamen in `.env` prüfen | | HTTP **502** bei STT/TTS | Cloud-Fehler/Format | `make smoke` ausführen; Modellnamen in `.env` prüfen |
| `VA_PROFILE` wirkt nicht | `DEFAULT_*_PROVIDER` in `.env` überschreibt | diese Zeilen in `.env` auskommentieren | | `VA_PROFILE` wirkt nicht | `DEFAULT_*_PROVIDER` in `.env` überschreibt | diese Zeilen in `.env` auskommentieren |
| `Address already in use` | Port belegt | anderen `PORT` setzen | | `Address already in use` | Port belegt | anderen `PORT` setzen |
| `pw_context_connect() failed` / `arecord: Fehler beim Öffnen des Gerätes` | PipeWire-Client- bzw. ALSA-`default`-Pfad gestört | direktes Gerät nehmen: `arecord -l`, dann `--recorder arecord --device plughw:2,0` | | `pw_context_connect() failed` / `arecord: Fehler beim Öffnen des Gerätes` | PipeWire-Client- bzw. ALSA-`default`-Pfad gestört | `--recorder auto` (Standard) überspringt tote Werkzeuge automatisch (nutzt `ffmpeg -f pulse`); notfalls direktes Gerät: `arecord -l`, dann `--recorder arecord --device plughw:6,0` |
| Keine Aufnahme/Wiedergabe | Werkzeug/Gerät fehlt | `arecord -L` / `aplay -L`; Pakete `pipewire`/`alsa-utils`/`ffmpeg`; Default via `wpctl status` | | Keine Aufnahme/Wiedergabe | Werkzeug/Gerät fehlt | `arecord -L` / `aplay -L`; Pakete `pipewire`/`alsa-utils`/`ffmpeg`; Default via `wpctl status` |
| Profil greift nicht | `config/voice-assistant.toml` fehlt | aus `*.example.toml` kopieren (Abschnitt 2) | | Profil greift nicht | `config/voice-assistant.toml` fehlt | aus `*.example.toml` kopieren (Abschnitt 2) |

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- /ws/voice-Verbindung an das Gateway und spielt die Antwort ab. Das Gespraechs-
gedaechtnis bleibt ueber die `session_id` erhalten. 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: 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 --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 --llm-provider openrouter --tts-provider openrouter
python scripts/voice_loop.py --tts-provider openrouter --voice Puck # Cloud-Stimme testen python scripts/voice_loop.py --tts-provider openrouter --voice Puck # Cloud-Stimme testen
python scripts/voice_loop.py --file frage.wav # ohne Mikrofon (Test) python scripts/voice_loop.py --file frage.wav # ohne Mikrofon (Test)
Voraussetzungen: laufendes Gateway, `arecord` (Aufnahme), ein Player Voraussetzungen: laufendes Gateway, ein Aufnahmewerkzeug (`ffmpeg`/`parecord`/`arecord`),
(`ffplay`/`aplay`/`paplay`), Python-Paket `websockets`. ein Player (`ffplay`/`paplay`/`aplay`), Python-Paket `websockets`.
""" """
from __future__ import annotations from __future__ import annotations
@ -23,11 +26,13 @@ import argparse
import asyncio import asyncio
import io import io
import json import json
import os
import shutil import shutil
import signal import signal
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
import wave import wave
try: try:
@ -46,28 +51,73 @@ def _require(tool: str) -> str:
def _first_player() -> list[str] | None: 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"): if shutil.which("ffplay"):
return ["ffplay", "-loglevel", "quiet", "-nodisp", "-autoexit"] return ["ffplay", "-loglevel", "quiet", "-nodisp", "-autoexit"]
if shutil.which("aplay"):
return ["aplay", "-q"]
if shutil.which("paplay"): if shutil.which("paplay"):
return ["paplay"] return ["paplay"]
if shutil.which("aplay"):
return ["aplay", "-q"]
return None return None
def resolve_recorder(choice: str) -> str: def resolve_recorder(choice: str, rate: int = 16000) -> str:
"""Waehlt das Aufnahmewerkzeug. 'auto' bevorzugt PipeWire (pw-record).""" """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 choice != "auto":
if not shutil.which(choice): if not shutil.which(choice):
sys.exit(f"Fehlt: Aufnahmewerkzeug '{choice}' nicht gefunden.") sys.exit(f"Fehlt: Aufnahmewerkzeug '{choice}' nicht gefunden.")
return choice return choice
for tool in ("pw-record", "parecord", "arecord"): available = [t for t in ("ffmpeg", "parecord", "arecord", "pw-record") if shutil.which(t)]
if shutil.which(tool): 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 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]: 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": if recorder == "arecord":
cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(rate), "-c", "1"] cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(rate), "-c", "1"]
if device: if device:
@ -158,13 +208,14 @@ def open_stream_player():
waehrend die KI noch weiter generiert. None, wenn kein Player gefunden wird. waehrend die KI noch weiter generiert. None, wenn kein Player gefunden wird.
""" """
rate = str(TTS_SAMPLE_RATE) rate = str(TTS_SAMPLE_RATE)
# paplay vor aplay: folgt dem System-Standard-Ausgabegeraet (PulseAudio/PipeWire).
if shutil.which("ffplay"): if shutil.which("ffplay"):
cmd = ["ffplay", "-loglevel", "quiet", "-nodisp", "-autoexit", cmd = ["ffplay", "-loglevel", "quiet", "-nodisp", "-autoexit",
"-f", "s16le", "-ar", rate, "-ac", "1", "-i", "pipe:0"] "-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"): elif shutil.which("paplay"):
cmd = ["paplay", "--raw", f"--rate={rate}", "--format=s16le", "--channels=1"] 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: else:
return None return None
return subprocess.Popen(cmd, stdin=subprocess.PIPE) return subprocess.Popen(cmd, stdin=subprocess.PIPE)
@ -309,10 +360,10 @@ async def run(args) -> None:
await _send_and_play(url, wav, start_frame) await _send_and_play(url, wav, start_frame)
return return
recorder = resolve_recorder(args.recorder) recorder = resolve_recorder(args.recorder, args.rate)
print(f"Ziel: {args.url} (Session '{args.session}')") print(f"Ziel: {args.url} (Session '{args.session}')")
print(f"Aufnahme mit: {recorder}" 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.") print("Sprich nach 'START', stoppe mit Enter. Strg+C beendet den Loop.")
while True: while True:
try: try:
@ -321,11 +372,10 @@ async def run(args) -> None:
print("\nEnde.") print("\nEnde.")
return return
if len(wav) < 1000: if len(wav) < 1000:
print(" ⚠ Keine/zu kurze Aufnahme. Moegliche Ursache: Aufnahmewerkzeug" print(" ⚠ Keine/zu kurze Aufnahme. Pruefe das System-Standard-Mikrofon"
" oder Geraet nicht nutzbar.") " (Ubuntu: Einstellungen → Ton → Eingabe) und ob es Pegel zeigt.")
print(" Verfuegbare Mikrofone: arecord -l") print(" Notfalls direktes ALSA-Geraet erzwingen (arecord -l zeigt die Nummer):")
print(" Direktes ALSA-Geraet verwenden, z. B.:") print(" python scripts/voice_loop.py --recorder arecord --device plughw:6,0")
print(" python scripts/voice_loop.py --recorder arecord --device plughw:2,0")
continue continue
try: try:
await _send_and_play(url, wav, start_frame) 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("--session", default="voice-loop")
p.add_argument("--token", default=None, help="Bearer-Token, falls AUTH_ENABLED=true") p.add_argument("--token", default=None, help="Bearer-Token, falls AUTH_ENABLED=true")
p.add_argument("--device", default=None, 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", p.add_argument("--recorder", default="auto",
choices=["auto", "pw-record", "parecord", "arecord"], choices=["auto", "ffmpeg", "pw-record", "parecord", "arecord"],
help="Aufnahmewerkzeug; 'auto' bevorzugt PipeWire (pw-record)") 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("--rate", type=int, default=16000)
p.add_argument("--stt-provider", dest="stt_provider", default=None) p.add_argument("--stt-provider", dest="stt_provider", default=None)
p.add_argument("--llm-provider", dest="llm_provider", default=None) p.add_argument("--llm-provider", dest="llm_provider", default=None)