Add text-to-dialogue, self-restart capability, and misc updates
This commit is contained in:
+211
-6
@@ -20,10 +20,12 @@ from typing import Optional
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from . import (
|
||||
config, file_delivery, file_ops, history as history_mod,
|
||||
monitors as monitors_mod, notifications, pet_actions, quiet,
|
||||
screen_context, screen_text, server_client, speech_text, updater,
|
||||
config, dialogue as dialogue_mod, file_delivery, file_ops,
|
||||
history as history_mod, monitors as monitors_mod, notifications,
|
||||
pet_actions, quiet, screen_context, screen_text, self_restart,
|
||||
server_client, speech_text, updater,
|
||||
)
|
||||
from . import __version__
|
||||
from .audio import barge_in, mic, stt, tts, wake_word
|
||||
from .state import PetState, PetStateMachine
|
||||
|
||||
@@ -38,6 +40,7 @@ class PetController(QObject):
|
||||
log = Signal(str)
|
||||
action = Signal(dict) # parsed petctl action for the UI to perform
|
||||
napping = Signal(bool) # quiet hours / fullscreen do-not-disturb
|
||||
voice_changed = Signal(str) # name of the server-picked voice ("" = default)
|
||||
restart_requested = Signal(str) # version we just updated to
|
||||
finished = Signal()
|
||||
|
||||
@@ -66,6 +69,13 @@ class PetController(QObject):
|
||||
self._wake_threshold = config.WAKE_WORD_THRESHOLD
|
||||
self._near_misses = wake_word.NearMissLog()
|
||||
|
||||
# The voice the server last picked for us with `speak_as` ("" = the
|
||||
# configured default). Held here rather than passed straight through
|
||||
# to one tts.speak() call because it's sticky by default — see
|
||||
# _apply_voice for why.
|
||||
self._voice_id = ""
|
||||
self._voice_name = ""
|
||||
|
||||
self._barge_in: Optional[barge_in.BargeInDetector] = None
|
||||
self._napping = False
|
||||
self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule
|
||||
@@ -80,6 +90,9 @@ class PetController(QObject):
|
||||
|
||||
self._last_update_check = 0.0
|
||||
self._update_pending = False # applied on disk, waiting for the restart
|
||||
# Armed by `petctl self_restart`, fired after the turn it was asked in
|
||||
# (see _arm_self_restart for why it can't happen inline).
|
||||
self._restart_context = None
|
||||
|
||||
self._notification_watcher: Optional[notifications.NotificationWatcher] = None
|
||||
self._notification_gate = notifications.NotificationGate(
|
||||
@@ -117,6 +130,20 @@ class PetController(QObject):
|
||||
def reset_wake_stats(self) -> None:
|
||||
self._near_misses.clear()
|
||||
|
||||
def current_voice(self) -> str:
|
||||
"""Name (or id) of the server-picked voice in use, "" for the default."""
|
||||
return self._voice_name or self._voice_id
|
||||
|
||||
def reset_voice(self) -> None:
|
||||
"""Drop a server-picked voice and go back to Bolt's own. The tray's
|
||||
way out of a voice you didn't want to keep — the server has no way to
|
||||
ask for the default back, since it never learns what it is."""
|
||||
if not self._voice_id:
|
||||
return
|
||||
self._voice_id = self._voice_name = ""
|
||||
self.log.emit("Voice: back to the default.")
|
||||
self.voice_changed.emit("")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
self._talk_now.set() # wake up anything blocked waiting on it
|
||||
@@ -167,6 +194,7 @@ class PetController(QObject):
|
||||
except Exception as exc:
|
||||
self.log.emit(f"Server not reachable yet ({exc}) — will keep trying per-request.")
|
||||
self._start_notification_bridge()
|
||||
self._report_self_restart()
|
||||
self._loop()
|
||||
if self._notification_watcher is not None:
|
||||
self._notification_watcher.stop()
|
||||
@@ -260,8 +288,10 @@ class PetController(QObject):
|
||||
return
|
||||
|
||||
self._check_deliveries()
|
||||
self._speak(reply)
|
||||
self._apply_voice(reply)
|
||||
self._speak(reply.text)
|
||||
self._state.transition(PetState.IDLE)
|
||||
self._maybe_self_restart()
|
||||
|
||||
def _with_context(self, text: str) -> str:
|
||||
"""Everything the server gets alongside what you actually said: the
|
||||
@@ -307,6 +337,18 @@ class PetController(QObject):
|
||||
kind = action["action"]
|
||||
if kind == "monitors":
|
||||
return monitors_mod.describe(self._monitors, self._pet_monitor)
|
||||
if kind == "self_restart":
|
||||
return self._arm_self_restart(action.get("reason") or "")
|
||||
if kind == "voice":
|
||||
# Answered here, not by describe(): the UI has no part in it,
|
||||
# and the server needs to hear whether there was anything to
|
||||
# drop — it can't see which voice we're using.
|
||||
previous = self.current_voice()
|
||||
self.reset_voice()
|
||||
return (
|
||||
f"[pet] back to your own voice (was {previous})" if previous
|
||||
else "[pet] already using your own voice"
|
||||
)
|
||||
if kind == "read":
|
||||
return self._read_screen(action["target"])
|
||||
if kind == "jump":
|
||||
@@ -327,6 +369,14 @@ class PetController(QObject):
|
||||
self.action.emit(action)
|
||||
return pet_actions.describe(action)
|
||||
|
||||
try:
|
||||
scene = dialogue_mod.parse(command)
|
||||
except dialogue_mod.DialogueError as exc:
|
||||
self.log.emit(f"dialoguectl: {exc}")
|
||||
return f"[dialogue] {exc}"
|
||||
if scene is not None:
|
||||
return self._play_dialogue(scene)
|
||||
|
||||
try:
|
||||
file_action = file_ops.parse(command)
|
||||
except file_ops.FileOpError as exc:
|
||||
@@ -365,6 +415,159 @@ class PetController(QObject):
|
||||
self.log.emit(f"Reading monitor {monitor.number} ({monitor.name})…")
|
||||
return screen_text.read_monitor(monitor, limit)
|
||||
|
||||
def _arm_self_restart(self, reason: str) -> str:
|
||||
"""`petctl self_restart` — check the code, then arm a restart.
|
||||
|
||||
Nothing restarts here. The tool result has to get back up the relay
|
||||
before this process can die (otherwise the server waits out its
|
||||
timeout on a turn that will never finish), so the restart is armed and
|
||||
`_maybe_self_restart` fires it once the turn has been spoken. The
|
||||
preflight import runs *now*, in this turn, so a syntax error Bolt just
|
||||
introduced comes back as something he can read and fix rather than as
|
||||
a pet that never comes back."""
|
||||
if not config.SELF_RESTART:
|
||||
return "[pet] self-restart is disabled on this device (SELF_RESTART=false)"
|
||||
if self._restart_context is not None:
|
||||
return "[pet] a restart is already armed for the end of this turn"
|
||||
try:
|
||||
self_restart.check_loop_guard(self_restart.load())
|
||||
self.log.emit("Self-restart requested — checking the code imports first…")
|
||||
self_restart.preflight()
|
||||
except self_restart.RestartError as exc:
|
||||
self.log.emit(f"Self-restart refused: {exc}")
|
||||
return f"[pet] restart refused — {exc}"
|
||||
|
||||
recent = [entry.text[:120] for entry in self.history.entries()[-4:]]
|
||||
self._restart_context = self_restart.arm(
|
||||
reason or "no reason given",
|
||||
verify=reason,
|
||||
version=__version__,
|
||||
session=config.SESSION_ID,
|
||||
recent=recent,
|
||||
)
|
||||
self.log.emit("Self-restart armed; it happens after this turn.")
|
||||
return (
|
||||
"[pet] code imports cleanly; restarting as soon as this turn finishes. "
|
||||
"I'll come back and tell you what version I'm on and what I found — "
|
||||
"wrap up your reply now, the next thing you hear from me is the report."
|
||||
)
|
||||
|
||||
def _maybe_self_restart(self) -> bool:
|
||||
"""Fire an armed restart, once the turn is over and the reply spoken.
|
||||
|
||||
Returns True if a restart was requested, so the caller can stop
|
||||
driving the pipeline — the process is on its way out."""
|
||||
if self._restart_context is None:
|
||||
return False
|
||||
self._update_pending = True # same latch the updater uses: no double restart
|
||||
self.log.emit("Restarting now.")
|
||||
self._state.force(PetState.IDLE)
|
||||
self.restart_requested.emit(f"self-restart: {self._restart_context.reason[:60]}")
|
||||
return True
|
||||
|
||||
def _report_self_restart(self) -> None:
|
||||
"""On the way up: tell the server we're back, and why we left.
|
||||
|
||||
Runs once, before the listen loop starts, and only when a context file
|
||||
was left behind. The report goes through the ordinary conversation
|
||||
path, so Bolt's answer is spoken out loud like any other turn — which
|
||||
is what makes "restart and check the sprites load" finish as a
|
||||
sentence instead of a silence."""
|
||||
context = self_restart.load()
|
||||
if context is None:
|
||||
return
|
||||
self_restart.clear()
|
||||
message = self_restart.report(context, version=__version__)
|
||||
self.log.emit(f"Back from a self-restart ({context.reason[:80]}).")
|
||||
self.history.add(history_mod.SYSTEM, message, time.time())
|
||||
try:
|
||||
reply = server_client.converse(message, on_command=self._handle_command)
|
||||
except server_client.ServerError as exc:
|
||||
# The restart still worked; only the report failed. Say so locally
|
||||
# rather than pretending nothing happened.
|
||||
self.log.emit(f"Couldn't report the restart to the server: {exc}")
|
||||
return
|
||||
self._apply_voice(reply)
|
||||
if reply.text.strip() and not self._napping:
|
||||
self._speak(reply.text)
|
||||
self._state.force(PetState.IDLE)
|
||||
|
||||
def _play_dialogue(self, scene: dict) -> str:
|
||||
"""Play a `dialoguectl` scene and report back up the relay.
|
||||
|
||||
This runs *mid-turn* (the server is still waiting on the tool result),
|
||||
so the pet has to look like it's talking and then go back to waiting —
|
||||
hence the TALKING → THINKING leg rather than the usual return to IDLE.
|
||||
Everything a normal reply gets, a scene gets too: the bubble, the
|
||||
transcript, and barge-in, so a long scene can be talked over exactly
|
||||
like a long answer."""
|
||||
if not config.DIALOGUE:
|
||||
return "[dialogue] disabled on this device (DIALOGUE=false)"
|
||||
try:
|
||||
inputs = dialogue_mod.resolve(
|
||||
scene,
|
||||
voices=dialogue_mod.parse_voice_map(config.DIALOGUE_VOICES),
|
||||
self_voice=self._voice_id or config.ELEVENLABS_VOICE_ID,
|
||||
)
|
||||
except dialogue_mod.DialogueError as exc:
|
||||
self.log.emit(f"dialoguectl: {exc}")
|
||||
return f"[dialogue] {exc}"
|
||||
|
||||
text = dialogue_mod.spoken_text(scene)
|
||||
self.log.emit(f"Dialogue ({len(inputs)} lines): {text[:120]}")
|
||||
try:
|
||||
pcm, sample_rate = tts.synthesize_dialogue(
|
||||
inputs, model_id=scene.get("model"), stability=scene.get("stability")
|
||||
)
|
||||
except tts.TtsError as exc:
|
||||
self.log.emit(f"Dialogue failed: {exc}")
|
||||
# Reported, not raised: the server can read this, shorten the
|
||||
# scene or fix the voice, and try again inside the same turn.
|
||||
return f"[dialogue] couldn't synthesize it: {exc}"
|
||||
|
||||
resume = self._state.state
|
||||
self._state.transition(PetState.TALKING)
|
||||
self.said.emit(speech_text.for_display(text))
|
||||
self.history.add(history_mod.PET, text, time.time())
|
||||
|
||||
should_stop = None
|
||||
if self._barge_in is not None:
|
||||
self._barge_in.reset()
|
||||
should_stop = self._barge_in.check
|
||||
completed = tts.play_pcm(pcm, sample_rate, should_stop=should_stop)
|
||||
if self._barge_in is not None:
|
||||
self._barge_in.reset() # the pet's own voices are in the wake window
|
||||
if resume in (PetState.THINKING, PetState.IDLE):
|
||||
self._state.transition(resume)
|
||||
|
||||
if not completed:
|
||||
return dialogue_mod.describe(scene) + " (interrupted — they talked over it)"
|
||||
return dialogue_mod.describe(scene)
|
||||
|
||||
def _apply_voice(self, reply) -> None:
|
||||
"""Adopt (or drop) the voice the server tagged this reply with.
|
||||
|
||||
The server's `speak_as` marker names an ElevenLabs voice it just
|
||||
picked — and it adds a Voice Library pick to the account first, so by
|
||||
the time the id gets here it's usable for TTS. It tags *one* reply,
|
||||
but the voice sticks by default: the server strips the marker before
|
||||
storing the turn, so it can't recall the id later, and "keep talking
|
||||
like that" would otherwise send it searching for a voice all over
|
||||
again. `VOICE_STICKY=false` makes each pick last exactly one reply.
|
||||
|
||||
Untagged replies never *change* the voice — with stickiness on they
|
||||
just keep whatever's in use, which is what makes the rest of the
|
||||
conversation stay in the requested voice."""
|
||||
voice_id = getattr(reply, "voice_id", "")
|
||||
if voice_id:
|
||||
if voice_id != self._voice_id:
|
||||
self._voice_id = voice_id
|
||||
self._voice_name = getattr(reply, "voice_name", "") or ""
|
||||
self.log.emit(f"Voice: {self.current_voice()}")
|
||||
self.voice_changed.emit(self.current_voice())
|
||||
elif not config.VOICE_STICKY:
|
||||
self.reset_voice()
|
||||
|
||||
def _speak(self, text: str) -> None:
|
||||
self._state.transition(PetState.TALKING)
|
||||
# Bubble gets the markdown stripped but emoji kept (it can't render
|
||||
@@ -382,6 +585,7 @@ class PetController(QObject):
|
||||
text,
|
||||
on_error=lambda exc: self.log.emit(f"TTS failed: {exc}"),
|
||||
should_stop=should_stop,
|
||||
voice_id=self._voice_id or None,
|
||||
)
|
||||
# Read the scoring history *before* resetting, or the log reports the
|
||||
# blank counters instead of what actually fired.
|
||||
@@ -504,8 +708,9 @@ class PetController(QObject):
|
||||
self.log.emit(f"Couldn't forward notification: {exc}")
|
||||
return
|
||||
self._check_deliveries()
|
||||
if reply.strip():
|
||||
self._speak(reply)
|
||||
self._apply_voice(reply)
|
||||
if reply.text.strip():
|
||||
self._speak(reply.text)
|
||||
self._state.transition(PetState.IDLE)
|
||||
|
||||
# ── file delivery ────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user