3a0959f55d
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>
138 lines
4.9 KiB
Python
138 lines
4.9 KiB
Python
"""Wires everything together: QApplication, the pet window, the tray icon,
|
|
the history / wake-tuner windows, the global push-to-talk hotkey, and the
|
|
background PetController thread that owns the mic/wake/server/TTS pipeline.
|
|
|
|
Everything the controller wants the UI to do arrives as a Qt signal, so the
|
|
worker thread never touches a widget.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
|
|
from PySide6.QtCore import QThread
|
|
from PySide6.QtWidgets import QApplication
|
|
|
|
from .. import config, updater
|
|
from ..controller import PetController
|
|
from ..hotkey import GlobalHotkey
|
|
from ..state import PetState
|
|
from .history_window import HistoryWindow
|
|
from .pet_window import PetWindow
|
|
from .tray import PetTray
|
|
from .wake_tuner import WakeTunerWindow
|
|
|
|
|
|
def _log(message: str) -> None:
|
|
print(message, flush=True)
|
|
|
|
|
|
def run() -> int:
|
|
app = QApplication(sys.argv)
|
|
app.setQuitOnLastWindowClosed(False) # tray-driven app; closing the pet isn't "quit"
|
|
|
|
window = PetWindow()
|
|
window.show()
|
|
|
|
controller = PetController()
|
|
thread = QThread()
|
|
controller.moveToThread(thread)
|
|
|
|
thread.started.connect(controller.run)
|
|
controller.state_changed.connect(lambda value: window.set_state(PetState(value)))
|
|
controller.said.connect(window.say)
|
|
# Lip-sync: the loudness of the audio actually going to the speakers.
|
|
controller.mouth.connect(window.set_mouth)
|
|
controller.log.connect(_log)
|
|
controller.action.connect(window.apply_action) # petctl move/emote/say/...
|
|
controller.finished.connect(thread.quit)
|
|
|
|
window.talk_requested.connect(controller.request_talk_now)
|
|
# The window owns the screen list and tells the controller about it, so
|
|
# both ends agree on what "monitor 2" means (see monitors.py).
|
|
window.monitors_changed.connect(controller.set_monitors)
|
|
window.pet_monitor_changed.connect(controller.set_pet_monitor)
|
|
# PetWindow publishes once in its constructor, which ran before those
|
|
# connections existed — so say it again now that anyone is listening.
|
|
window.publish_monitors()
|
|
window.copied.connect(lambda text: _log(f"Copied to clipboard: {text[:60]}"))
|
|
|
|
history_window = HistoryWindow(controller.history)
|
|
tuner_window = WakeTunerWindow(
|
|
get_threshold=controller.wake_threshold,
|
|
set_threshold=controller.set_wake_threshold,
|
|
get_stats=controller.wake_stats,
|
|
on_reset=controller.reset_wake_stats,
|
|
)
|
|
|
|
def _set_nap(napping: bool) -> None:
|
|
# Clicking the tray item pins the state; the schedule takes over again
|
|
# only after a restart or a `petctl nap off`.
|
|
controller.set_napping(napping)
|
|
window.set_napping(napping)
|
|
|
|
tray = PetTray(
|
|
on_talk_now=controller.request_talk_now,
|
|
on_toggle_mute=controller.toggle_mute,
|
|
on_quit=app.quit,
|
|
on_set_wander=window.set_wander_enabled,
|
|
wander_enabled=config.PET_WANDER,
|
|
on_set_click_through=window.set_click_through,
|
|
click_through_enabled=config.PET_CLICK_THROUGH,
|
|
on_set_nap=_set_nap,
|
|
on_show_history=history_window.show_refreshed,
|
|
on_show_wake_tuner=tuner_window.show_refreshed,
|
|
on_reset_voice=controller.reset_voice,
|
|
)
|
|
|
|
def _handle_napping(napping: bool) -> None:
|
|
window.set_napping(napping)
|
|
tray.set_napping(napping)
|
|
|
|
controller.napping.connect(_handle_napping)
|
|
# The server can hand Bolt a different voice mid-conversation (speak_as);
|
|
# the tray is where you get his own back.
|
|
controller.voice_changed.connect(tray.set_voice)
|
|
|
|
# Push-to-talk: a global hook, because the pet window never has focus.
|
|
# request_talk_now() only sets a threading.Event, so it's safe to call
|
|
# from pynput's listener thread.
|
|
hotkey = GlobalHotkey(config.PUSH_TO_TALK_HOTKEY, controller.request_talk_now)
|
|
problem = hotkey.start()
|
|
if problem:
|
|
_log(problem)
|
|
elif hotkey.running:
|
|
_log(f"Push-to-talk: {config.PUSH_TO_TALK_HOTKEY}")
|
|
|
|
# The updater has already moved the checkout by the time this fires; all
|
|
# that's left is to let Qt tear down cleanly (so the mic and the tray
|
|
# icon are released) and then exec the new code. Doing the exec after
|
|
# app.exec() returns, rather than from the controller thread, is what
|
|
# guarantees the audio device is free before the new process opens it.
|
|
pending_restart = {"tag": None}
|
|
|
|
def _handle_restart(tag: str) -> None:
|
|
pending_restart["tag"] = tag
|
|
app.quit()
|
|
|
|
controller.restart_requested.connect(_handle_restart)
|
|
|
|
def _shutdown() -> None:
|
|
hotkey.stop()
|
|
controller.stop()
|
|
thread.quit()
|
|
thread.wait(5000)
|
|
|
|
app.aboutToQuit.connect(_shutdown)
|
|
|
|
thread.start()
|
|
status = app.exec()
|
|
|
|
if pending_restart["tag"]:
|
|
_log(f"Restarting into {pending_restart['tag']}…")
|
|
try:
|
|
updater.restart() # never returns
|
|
except Exception as exc:
|
|
_log(f"Couldn't restart automatically ({exc}) — start the pet again by hand.")
|
|
return status
|