Files
Bolt-Pet/bolt_pet/doctor.py
T
themajesticmagician 3a0959f55d Streaming replies and STT, amplitude lip-sync, one place for speaking
Latency: replies are spoken sentence-by-sentence off the desk API's NDJSON
endpoint, so the wait is time-to-first-sentence rather than the whole model
call, and Deepgram's live websocket transcribes while you're still talking
instead of uploading the WAV afterwards. Both fall back invisibly — a stream
that fails before anything was said drops to converse(), and a socket that
never opens just means the old one-shot path.

Speaking lived in four near-copies in the controller (a reply, a holding line,
a streamed sentence, a dialogue scene) that had already drifted: one didn't arm
barge-in, another skipped the follow-up rule. It's now speech.Speaker plus an
Utterance describing the policy differences, with collaborators injected so the
whole of it tests without Qt or audio.

The mouth follows the audio rather than a timer: tts.level_of reduces each PCM
frame to a 0..1 loudness on a sqrt curve (speech sits well below peak, and a
linear map leaves the mouth barely open during normal talking) and that indexes
the talking frames, which the sprite script now draws as an openness ramp.
Offline pyttsx3 has no waveform, so stale levels hand control back to the timed
loop instead of freezing the mouth mid-syllable.

Also: the pet starts where you left it (ignoring positions on monitors that are
no longer connected, since restoring those faithfully is how it ends up
somewhere unreachable), and `python -m bolt_pet --doctor` is a preflight that
says what to do about each problem rather than only what's wrong.

tests/test_pipeline_smoke.py breaks the pure-logic rule on purpose. Every unit
test passed all week while notifications sat unspoken for minutes, the pet said
things twice and [laughing] got read aloud — each an interaction between two
individually-correct units. It drives whole turns against a real HTTP server on
a loopback port, faking only the mic and the speakers. It found a NameError in
the paint path that would have fired on every repaint while talking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:01:06 -06:00

273 lines
11 KiB
Python

"""`python -m bolt_pet --doctor` — is this install actually going to work?
Written after a week of debugging things a preflight would have shown in one
command: a missing port publish, a wrong reverse-proxy header, a venv whose
python was a zero-byte file, an OCR engine that was never installed. Every one
of those presented as "the pet is being weird" and took a conversation to find.
Each check is independent and reports one of three things — ok, a warning
(works, but degraded), or a failure (this will not do what you expect) — and
says *what to do about it* rather than just what is wrong. Nothing here raises:
a doctor that crashes on a broken install is diagnosing the wrong patient.
Deliberately does not open the mic or call a paid API by default. It checks
that the door is unlocked, not that the room is furnished; `--deep` is there
when you want it to actually knock.
"""
from __future__ import annotations
import importlib
import shutil
import sys
from dataclasses import dataclass
from typing import Callable, Optional
from . import config
OK, WARN, FAIL = "ok", "warn", "fail"
_MARKS = {OK: " ok ", WARN: " warn ", FAIL: " FAIL "}
@dataclass
class Check:
name: str
status: str
detail: str = ""
fix: str = ""
def line(self) -> str:
text = f"[{_MARKS[self.status]}] {self.name:22} {self.detail}"
if self.fix and self.status != OK:
text += f"\n{'':32}{self.fix}"
return text
def _module(name: str) -> bool:
try:
importlib.import_module(name)
return True
except Exception:
return False
# ── the checks ──────────────────────────────────────────────────────────────
def check_config() -> Check:
missing = config.missing_config()
if missing:
return Check("server config", FAIL, f"missing {', '.join(missing)}",
"set them in .env — without these the controller exits at startup")
return Check("server config", OK, f"{config.SERVER_URL} as {config.SESSION_ID}")
def check_server(deep: bool = False) -> Check:
if not config.SERVER_URL:
return Check("server", FAIL, "no BOLT_SERVER_URL",
"cp .env.example .env and set BOLT_SERVER_URL to your Bolt server")
if not deep:
return Check("server", OK, f"{config.SERVER_URL} (not contacted; --deep to try)")
try:
from . import server_client
health = server_client.check_health(timeout=8)
return Check("server", OK, f"reachable — {health}")
except Exception as exc:
return Check("server", FAIL, f"unreachable: {exc}",
"check the URL, the key, and that the container is up")
def check_streaming_endpoint(deep: bool = False) -> Check:
"""The streamed-reply endpoint is newer than some deployed servers."""
if not config.STREAMING_REPLIES:
return Check("streamed replies", WARN, "disabled (STREAMING_REPLIES=false)")
if not deep:
return Check("streamed replies", OK, "enabled (endpoint not probed)")
try:
import requests
response = requests.post(
f"{config.SERVER_URL}/desk/converse_stream",
json={"session_id": config.SESSION_ID, "text": ""},
headers={"X-Desk-Api-Key": config.API_KEY}, timeout=8, stream=True,
)
if response.status_code == 404:
return Check("streamed replies", WARN, "server has no /desk/converse_stream",
"update the server, or set STREAMING_REPLIES=false to skip the probe")
return Check("streamed replies", OK, f"endpoint answered {response.status_code}")
except Exception as exc:
return Check("streamed replies", WARN, f"probe failed: {exc}")
def check_microphone(deep: bool = False) -> Check:
if not _module("sounddevice"):
return Check("microphone", FAIL, "sounddevice is not installed",
"pip install -r requirements.txt")
try:
import sounddevice as sd
devices = [d for d in sd.query_devices() if d.get("max_input_channels", 0) > 0]
if not devices:
return Check("microphone", FAIL, "no input devices",
"on Linux check PipeWire/PulseAudio is running as your user")
chosen = config.MIC_DEVICE or "system default"
if not deep:
return Check("microphone", OK, f"{len(devices)} input device(s), using {chosen}")
with sd.InputStream(samplerate=config.SAMPLE_RATE, channels=1, dtype="int16",
device=config.MIC_DEVICE, blocksize=config.FRAME_LEN):
pass
return Check("microphone", OK, f"opened at {config.SAMPLE_RATE} Hz ({chosen})")
except Exception as exc:
return Check("microphone", FAIL, f"could not open: {exc}",
"don't run the pet as root — PortAudio can't reach your PipeWire socket")
def check_wake_model() -> Check:
from pathlib import Path
path = Path(config.WAKE_MODEL_PATH)
if not path.exists():
return Check("wake word", FAIL, f"{path.name} is missing",
"it ships in the project root; check WAKE_MODEL_FILE")
if not _module("openwakeword"):
return Check("wake word", FAIL, "openwakeword is not installed",
"pip install -r requirements.txt")
return Check("wake word", OK, f"{path.name}, threshold {config.WAKE_WORD_THRESHOLD}")
def check_stt() -> Check:
if not config.DEEPGRAM_API_KEY:
return Check("speech-to-text", FAIL, "no DEEPGRAM_API_KEY",
"set it in .env — nothing you say can be transcribed without it")
if config.STT_STREAMING and not _module("websocket"):
return Check("speech-to-text", WARN, "streaming on, but websocket-client is missing",
"pip install websocket-client — it falls back to one-shot uploads")
mode = "streaming" if config.STT_STREAMING else "one-shot"
return Check("speech-to-text", OK, f"Deepgram {config.DEEPGRAM_MODEL}, {mode}")
def check_tts() -> Check:
if not (config.ELEVENLABS_API_KEY and config.ELEVENLABS_VOICE_ID):
if _module("pyttsx3"):
return Check("text-to-speech", WARN, "no ElevenLabs key/voice — offline voice only",
"set ELEVENLABS_API_KEY and ELEVENLABS_VOICE_ID for the real voice")
return Check("text-to-speech", FAIL, "no ElevenLabs config and no pyttsx3 fallback")
return Check("text-to-speech", OK,
f"ElevenLabs {config.ELEVENLABS_MODEL_ID}, voice …{config.ELEVENLABS_VOICE_ID[-6:]}")
def check_dialogue() -> Check:
if not config.DIALOGUE:
return Check("multi-voice scenes", WARN, "disabled (DIALOGUE=false)")
from . import dialogue
cast = dialogue.parse_voice_map(config.DIALOGUE_VOICES)
if not cast:
return Check("multi-voice scenes", WARN, "no cast configured — only 'self' works",
'set DIALOGUE_VOICES=narrator:<id>,villain:<id>')
return Check("multi-voice scenes", OK, f"{len(cast)} voice(s): {', '.join(sorted(cast))}")
def check_screen_text() -> Check:
if not config.SCREEN_TEXT:
return Check("screen reading", WARN, "disabled (SCREEN_TEXT=false)")
if not _module("mss"):
return Check("screen reading", WARN, "mss is not installed — petctl read will decline",
"pip install mss (and note it cannot capture on Wayland)")
engine = "pytesseract" if _module("pytesseract") else (
"rapidocr" if _module("rapidocr_onnxruntime") else "")
if not engine:
return Check("screen reading", WARN, "no OCR engine",
"pip install pytesseract && apt install tesseract-ocr, "
"or pip install rapidocr-onnxruntime")
if engine == "pytesseract" and not shutil.which("tesseract"):
return Check("screen reading", WARN, "pytesseract is installed but tesseract is not",
"apt install tesseract-ocr")
return Check("screen reading", OK, f"mss + {engine}")
def check_hotkey() -> Check:
if not _module("pynput"):
return Check("push-to-talk", WARN, "pynput is not installed",
"the wake word still works; pip install pynput for the hotkey")
import os
if os.environ.get("WAYLAND_DISPLAY") and not os.environ.get("DISPLAY"):
return Check("push-to-talk", WARN, "Wayland session — global hotkeys usually blocked",
"use the wake word, or click the pet")
return Check("push-to-talk", OK, config.PUSH_TO_TALK_HOTKEY)
def check_sprites() -> Check:
from pathlib import Path
root = Path(__file__).resolve().parent / "assets" / "sprites"
if not root.exists():
return Check("sprites", WARN, "no art — the placeholder blob will be drawn",
"python scripts/generate_bolt_sprites.py")
counts = {d.name: len(list(d.glob("*.png"))) for d in sorted(root.iterdir()) if d.is_dir()}
empty = [name for name, count in counts.items() if count == 0]
if empty:
return Check("sprites", WARN, f"no frames for: {', '.join(empty)}",
"python scripts/generate_bolt_sprites.py")
return Check("sprites", OK, ", ".join(f"{n} {c}" for n, c in counts.items()))
def check_latency() -> Check:
"""The setting most likely to make it feel slow, and the least obvious."""
silence = config.SILENCE_END_SEC
if silence >= 1.5:
return Check("turn latency", WARN, f"VAD_SILENCE_END_SEC={silence:g}s of dead air per turn",
"0.8-1.0 feels markedly snappier; it is pure wait before anything starts")
return Check("turn latency", OK, f"silence timeout {silence:g}s, "
f"streaming {'on' if config.STREAMING_REPLIES else 'off'}")
CHECKS: tuple[tuple[str, Callable], ...] = (
("config", check_config),
("server", check_server),
("stream", check_streaming_endpoint),
("mic", check_microphone),
("wake", check_wake_model),
("stt", check_stt),
("tts", check_tts),
("dialogue", check_dialogue),
("screen", check_screen_text),
("hotkey", check_hotkey),
("sprites", check_sprites),
("latency", check_latency),
)
def run(deep: bool = False) -> list[Check]:
results = []
for _name, check in CHECKS:
try:
try:
results.append(check(deep))
except TypeError:
results.append(check())
except Exception as exc: # a broken check must not hide the others
results.append(Check(_name, FAIL, f"the check itself failed: {exc}"))
return results
def main(argv: Optional[list] = None) -> int:
argv = list(argv if argv is not None else sys.argv[1:])
deep = "--deep" in argv
print(f"Bolt pet preflight{' (deep: contacting the server and opening the mic)' if deep else ''}\n")
results = run(deep=deep)
for check in results:
print(check.line())
failures = [c for c in results if c.status == FAIL]
warnings = [c for c in results if c.status == WARN]
print()
if failures:
print(f"{len(failures)} problem(s) will stop this working. Fix those first.")
elif warnings:
print(f"Ready. {len(warnings)} thing(s) degraded but working.")
else:
print("Everything checks out.")
return 1 if failures else 0