feat(voice): Pas 10 — eco doctor voice stack checks

cli.py: +101 / -0, append 9 checks după existing 15:
1. libopus loaded by discord.py (load_default fallback)
2. ffmpeg in PATH
3. Supertonic TTS reachable :7788 (5s timeout POST)
4. faster-whisper importable (no model load — too slow for doctor)
5. silero-vad importable
6. discord.ext.voice_recv importable (vendor package guard)
7-9. assets/voice/{thinking,beep_200ms,mhm}.wav exist + size thresholds

Helper _voice_doctor_checks() returns list[tuple[str, bool]] matching
doctor's reporting style. Replicates voice_setup.py logic in doctor
format (voice_setup uses ANSI colors directly, doctor uses (label, ok)
tuples — separate Option B implementation). Graceful ImportError
handling per check — never crashes the rest of eco doctor.

Exit code 1 corectly surfaces missing libopus (Discord voice silent
without it). Use `sudo apt install -y libopus0` to clear.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 14:44:13 +00:00
parent c93c4f822e
commit 0cc01c1450

101
cli.py
View File

@@ -114,6 +114,104 @@ def _load_sessions_file() -> dict:
return {} return {}
def _voice_doctor_checks() -> list[tuple[str, bool]]:
"""Voice-stack health checks (Pas 10).
Mirrors the logic in tools/voice_setup.py but returns (label, ok) tuples
so they integrate with cmd_doctor's PASS/FAIL output. All checks degrade
gracefully — ImportError on optional voice deps is reported as FAIL, never
raised, so the rest of `eco doctor` is unaffected.
"""
import importlib.util
import json as _json
import urllib.error
import urllib.request
results: list[tuple[str, bool]] = []
# 1. libopus0 loaded by discord.py
try:
import discord
if not discord.opus.is_loaded():
try:
discord.opus._load_default()
except Exception:
pass
results.append(("libopus loaded (discord.py)", discord.opus.is_loaded()))
except ImportError:
results.append(("libopus loaded (discord.py)", False))
except Exception:
results.append(("libopus loaded (discord.py)", False))
# 2. ffmpeg in PATH
results.append(("ffmpeg in PATH", shutil.which("ffmpeg") is not None))
# 3. Supertonic TTS reachable at http://127.0.0.1:7788/
supertonic_url = "http://127.0.0.1:7788/v1/audio/speech"
supertonic_ok = False
try:
payload = _json.dumps({
"model": "supertonic-3",
"input": "test",
"voice": "M2",
"response_format": "wav",
"lang": "ro",
}).encode("utf-8")
req = urllib.request.Request(
supertonic_url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
supertonic_ok = resp.status == 200
except (urllib.error.URLError, ConnectionError, OSError):
supertonic_ok = False
except Exception:
supertonic_ok = False
results.append(("Supertonic TTS reachable at :7788", supertonic_ok))
# 4. faster-whisper importable (don't load model — too slow)
results.append((
"faster-whisper importable",
importlib.util.find_spec("faster_whisper") is not None,
))
# 5. silero-vad importable
results.append((
"silero-vad importable",
importlib.util.find_spec("silero_vad") is not None,
))
# 6. discord.ext.voice_recv importable (vendor package)
voice_recv_ok = False
try:
voice_recv_ok = importlib.util.find_spec("discord.ext.voice_recv") is not None
except (ImportError, ValueError, ModuleNotFoundError):
voice_recv_ok = False
except Exception:
voice_recv_ok = False
results.append(("discord.ext.voice_recv importable", voice_recv_ok))
# 7-9. Voice assets present and non-trivial size
voice_assets = [
("assets/voice/thinking.wav", 1024),
("assets/voice/beep_200ms.wav", 512),
("assets/voice/mhm.wav", 512),
]
for rel_path, min_bytes in voice_assets:
path = PROJECT_ROOT / rel_path
ok = False
try:
ok = path.exists() and path.stat().st_size > min_bytes
except OSError:
ok = False
label = f"{rel_path} (>{min_bytes}B)"
results.append((label, ok))
return results
def cmd_doctor(args): def cmd_doctor(args):
"""Run diagnostic checks.""" """Run diagnostic checks."""
import re import re
@@ -227,6 +325,9 @@ def cmd_doctor(args):
else: else:
checks.append(("WhatsApp bridge (optional)", True)) checks.append(("WhatsApp bridge (optional)", True))
# ---- Voice stack checks (Pas 10) ----
checks.extend(_voice_doctor_checks())
# Print results # Print results
all_pass = True all_pass = True
for label, passed in checks: for label, passed in checks: