257 lines
7.6 KiB
Python
Executable File
257 lines
7.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Check each piece of the voice pipeline independently.
|
|
|
|
Run this first when something isn't working — it tells you which stage is at
|
|
fault instead of making you read pipeline logs.
|
|
"""
|
|
|
|
import asyncio
|
|
import sys
|
|
import time
|
|
import wave
|
|
from pathlib import Path
|
|
|
|
PHRASE = "The quick brown fox jumps over the lazy dog."
|
|
KOKORO_CACHE = Path.home() / ".cache/pipecat/kokoro-onnx"
|
|
|
|
results: list[tuple[str, bool, str]] = []
|
|
|
|
|
|
def record(name: str, ok: bool, detail: str):
|
|
results.append((name, ok, detail))
|
|
print(f" {'PASS' if ok else 'FAIL'} {name}: {detail}")
|
|
|
|
|
|
def check_devices():
|
|
import sounddevice as sd
|
|
|
|
try:
|
|
default_in, default_out = sd.default.device
|
|
names = sd.query_devices()
|
|
record(
|
|
"audio devices",
|
|
True,
|
|
f"in={names[default_in]['name']!r}, out={names[default_out]['name']!r}",
|
|
)
|
|
except Exception as e:
|
|
record("audio devices", False, str(e))
|
|
|
|
|
|
def check_microphone():
|
|
import numpy as np
|
|
import sounddevice as sd
|
|
|
|
try:
|
|
print(" ... recording 3 seconds, please say something")
|
|
rec = sd.rec(3 * 16000, samplerate=16000, channels=1, dtype="int16")
|
|
sd.wait()
|
|
peak = int(np.abs(rec).max())
|
|
except Exception as e:
|
|
record("microphone", False, str(e))
|
|
return None
|
|
|
|
if peak == 0:
|
|
record(
|
|
"microphone",
|
|
False,
|
|
"captured pure silence — grant your terminal microphone access in "
|
|
"System Settings > Privacy & Security > Microphone",
|
|
)
|
|
return None
|
|
record("microphone", True, f"peak amplitude {peak}")
|
|
return rec
|
|
|
|
|
|
def check_hold_key():
|
|
"""Hold-to-talk needs Input Monitoring; without it we fall back to a toggle."""
|
|
from global_hotkey import permission_granted
|
|
|
|
if permission_granted():
|
|
record("hold-to-talk permission", True, "Input Monitoring granted")
|
|
else:
|
|
record(
|
|
"hold-to-talk (optional)",
|
|
False,
|
|
"Input Monitoring not granted — add your terminal under System Settings > "
|
|
"Privacy & Security > Input Monitoring and restart it. SPACE toggle still works.",
|
|
)
|
|
|
|
|
|
def check_macos_voices():
|
|
from apple_tts import available_voices, find_voice
|
|
|
|
voices = available_voices()
|
|
irish = [name for name, language in voices if language == "en-IE"]
|
|
if not irish:
|
|
record(
|
|
"macos voices (optional)",
|
|
False,
|
|
"no Irish (en-IE) voice installed; add one under System Settings > "
|
|
"Accessibility > Spoken Content > System Voice",
|
|
)
|
|
return
|
|
record(
|
|
"macos voices",
|
|
bool(find_voice("Moira")),
|
|
f"{len(voices)} installed, Irish: {', '.join(irish)}",
|
|
)
|
|
|
|
|
|
def check_kokoro():
|
|
from kokoro_onnx import Kokoro
|
|
|
|
try:
|
|
kokoro = Kokoro(str(KOKORO_CACHE / "kokoro-v1.0.onnx"), str(KOKORO_CACHE / "voices-v1.0.bin"))
|
|
samples, rate = kokoro.create(PHRASE, voice="af_heart", speed=1.0, lang="en-us")
|
|
except Exception as e:
|
|
record("kokoro tts", False, str(e))
|
|
return None
|
|
record("kokoro tts", True, f"{len(samples) / rate:.2f}s of audio at {rate} Hz")
|
|
return samples, rate
|
|
|
|
|
|
def write_wav(synthesized, path="/tmp/voice-agent-probe.wav"):
|
|
import numpy as np
|
|
|
|
samples, rate = synthesized
|
|
pcm = (np.clip(samples, -1, 1) * 32767).astype(np.int16)
|
|
with wave.open(path, "wb") as f:
|
|
f.setnchannels(1)
|
|
f.setsampwidth(2)
|
|
f.setframerate(rate)
|
|
f.writeframes(pcm.tobytes())
|
|
return path
|
|
|
|
|
|
def matches(heard: str) -> bool:
|
|
return heard.lower().strip(" .") == PHRASE.lower().strip(" .")
|
|
|
|
|
|
def check_apple_stt(synthesized):
|
|
"""Transcribe Kokoro's own output — a full loop through the audio stack."""
|
|
from apple_stt import _recognize_file, probe
|
|
|
|
available, reason = probe()
|
|
if not available:
|
|
record("apple speech to text", False, reason)
|
|
return
|
|
if synthesized is None:
|
|
record("apple speech to text", False, "skipped, Kokoro produced no audio to transcribe")
|
|
return
|
|
|
|
started = time.time()
|
|
try:
|
|
heard = _recognize_file(write_wav(synthesized), "en-US", 20.0).strip()
|
|
except Exception as e:
|
|
record("apple speech to text", False, str(e))
|
|
return
|
|
record("apple speech to text", matches(heard), f"heard {heard!r} in {time.time() - started:.2f}s")
|
|
|
|
|
|
def check_mlx_whisper(synthesized):
|
|
"""Optional — only matters if you want --stt-engine mlx."""
|
|
if synthesized is None:
|
|
record("mlx whisper (optional)", False, "skipped, no audio to transcribe")
|
|
return
|
|
|
|
import mlx_whisper
|
|
import numpy as np
|
|
import soxr
|
|
|
|
samples, rate = synthesized
|
|
audio = soxr.resample(samples.astype(np.float32), rate, 16000)
|
|
try:
|
|
heard = mlx_whisper.transcribe(
|
|
audio, path_or_hf_repo="mlx-community/whisper-large-v3-turbo-q4", language="en"
|
|
)["text"].strip()
|
|
except Exception as e:
|
|
detail = str(e).splitlines()[0]
|
|
record("mlx whisper (optional)", False, f"{detail} — use --stt-engine apple or cpu")
|
|
return
|
|
record("mlx whisper (optional)", matches(heard), f"heard {heard!r}")
|
|
|
|
|
|
def check_opencode_llm():
|
|
from opencode_llm import probe_opencode
|
|
|
|
available, reason = probe_opencode(model="ollama-cloud/gemma4:31b")
|
|
record("opencode llm", available, reason)
|
|
|
|
|
|
def check_macos_llm():
|
|
from apple_llm import probe_apple_llm
|
|
|
|
available, reason = probe_apple_llm()
|
|
record("macos llm (optional)", available, reason)
|
|
|
|
|
|
async def check_claude():
|
|
from claude_agent_sdk import ClaudeSDKClient, ResultMessage, StreamEvent
|
|
|
|
from bot import build_claude_options
|
|
from claude_llm import _text_delta
|
|
|
|
import argparse
|
|
|
|
from bot import DEFAULT_CLAUDE_MODEL
|
|
|
|
options = build_claude_options(
|
|
argparse.Namespace(
|
|
allow_writes=False,
|
|
cwd=None,
|
|
claude_model=DEFAULT_CLAUDE_MODEL,
|
|
load_settings=False,
|
|
)
|
|
)
|
|
try:
|
|
async with ClaudeSDKClient(options=options) as client:
|
|
await client.query("Reply with exactly one word: ready")
|
|
spoken = []
|
|
async for message in client.receive_response():
|
|
if isinstance(message, StreamEvent):
|
|
text = _text_delta(message)
|
|
if text:
|
|
spoken.append(text)
|
|
elif isinstance(message, ResultMessage) and message.is_error:
|
|
record("claude session (optional)", False, f"error: {message.result}")
|
|
return
|
|
except Exception as e:
|
|
hint = ""
|
|
if "-9" in str(e):
|
|
hint = " — the CLI was killed applying its own sandbox; run this from a normal terminal"
|
|
record("claude session (optional)", False, f"{e}{hint}")
|
|
return
|
|
record(
|
|
"claude session (optional)",
|
|
True,
|
|
f"replied {''.join(spoken).strip()!r} using {DEFAULT_CLAUDE_MODEL}",
|
|
)
|
|
|
|
|
|
async def main():
|
|
print("Checking the voice pipeline...\n")
|
|
check_devices()
|
|
check_microphone()
|
|
check_hold_key()
|
|
check_macos_voices()
|
|
synthesized = check_kokoro()
|
|
check_apple_stt(synthesized)
|
|
check_mlx_whisper(synthesized)
|
|
check_macos_llm()
|
|
check_opencode_llm()
|
|
await check_claude()
|
|
|
|
required_failures = [
|
|
name for name, ok, _ in results if not ok and not name.endswith("(optional)")
|
|
]
|
|
print()
|
|
if required_failures:
|
|
print(f"{len(required_failures)} check(s) failed: {', '.join(required_failures)}")
|
|
return 1
|
|
print("Everything works. Run ./talk to start a conversation.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(asyncio.run(main()))
|