Wake-word barge-in, Gitea auto-updater, hard_reset fix
This commit is contained in:
+86
-3
@@ -19,7 +19,10 @@ from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from . import config, history as history_mod, notifications, pet_actions, quiet, screen_context, server_client, speech_text
|
||||
from . import (
|
||||
config, history as history_mod, notifications, pet_actions, quiet,
|
||||
screen_context, server_client, speech_text, updater,
|
||||
)
|
||||
from .audio import barge_in, mic, stt, tts, wake_word
|
||||
from .state import PetState, PetStateMachine
|
||||
|
||||
@@ -34,6 +37,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
|
||||
restart_requested = Signal(str) # version we just updated to
|
||||
finished = Signal()
|
||||
|
||||
def __init__(self):
|
||||
@@ -59,6 +63,9 @@ class PetController(QObject):
|
||||
self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule
|
||||
self._last_nap_check = 0.0
|
||||
|
||||
self._last_update_check = 0.0
|
||||
self._update_pending = False # applied on disk, waiting for the restart
|
||||
|
||||
self._notification_watcher: Optional[notifications.NotificationWatcher] = None
|
||||
self._notification_gate = notifications.NotificationGate(
|
||||
config.NOTIFICATION_FILTER, config.NOTIFICATION_MIN_INTERVAL_SECONDS
|
||||
@@ -124,7 +131,19 @@ class PetController(QObject):
|
||||
return
|
||||
|
||||
if config.BARGE_IN:
|
||||
self._barge_in = barge_in.BargeInDetector(self._stream)
|
||||
# In wake mode the detector shares the idle listener's model and
|
||||
# its live threshold, so the tray tuner's slider applies to
|
||||
# interrupting as well as waking (unless BARGE_IN_WAKE_THRESHOLD
|
||||
# pins it to a fixed, stricter number).
|
||||
self._barge_in = barge_in.make_detector(
|
||||
self._stream,
|
||||
wake_threshold=(
|
||||
config.BARGE_IN_WAKE_THRESHOLD
|
||||
if config.BARGE_IN_WAKE_THRESHOLD is not None
|
||||
else self.wake_threshold
|
||||
),
|
||||
)
|
||||
self.log.emit(f"Barge-in: {config.BARGE_IN_MODE} mode.")
|
||||
|
||||
with self._stream:
|
||||
try:
|
||||
@@ -251,12 +270,33 @@ class PetController(QObject):
|
||||
on_error=lambda exc: self.log.emit(f"TTS failed: {exc}"),
|
||||
should_stop=should_stop,
|
||||
)
|
||||
if self._barge_in is not None:
|
||||
# Playback fed the pet's own voice into the wake model's rolling
|
||||
# window. Clear it before the idle listener starts scoring again,
|
||||
# or Bolt's last sentence is still in there being re-scored.
|
||||
self._barge_in.reset()
|
||||
if not completed:
|
||||
# You talked over it — take that as the start of the next turn
|
||||
# rather than making you say the wake word again.
|
||||
self.log.emit("Interrupted — listening.")
|
||||
self.log.emit(f"Interrupted — listening. {self._barge_in_detail()}")
|
||||
self._talk_now.set()
|
||||
|
||||
def _barge_in_detail(self) -> str:
|
||||
"""Why the interruption fired, for the log. How far into playback it
|
||||
happened is the tell: frame 1 means the detector was still holding
|
||||
audio from before this reply started, whereas a hit several seconds
|
||||
in is something the mic actually heard."""
|
||||
detector = self._barge_in
|
||||
if isinstance(detector, barge_in.WakeWordBargeIn):
|
||||
return (
|
||||
f"(wake score {detector.last_score:.3f} >= {detector.last_threshold:.2f}, "
|
||||
f"peak {detector.peak_score:.3f}, at frame {detector.frames_checked} / "
|
||||
f"{detector.seconds_checked:.1f}s into playback)"
|
||||
)
|
||||
if isinstance(detector, barge_in.BargeInDetector):
|
||||
return f"(loud frames {detector.loud_frames}, threshold {config.BARGE_IN_RMS_THRESHOLD})"
|
||||
return ""
|
||||
|
||||
# ── quiet hours / do-not-disturb ─────────────────────────────────────
|
||||
|
||||
def _apply_nap_state(self, napping: bool) -> None:
|
||||
@@ -323,10 +363,53 @@ class PetController(QObject):
|
||||
self._speak(reply)
|
||||
self._state.transition(PetState.IDLE)
|
||||
|
||||
# ── auto-update ──────────────────────────────────────────────────────
|
||||
|
||||
def _maybe_update(self) -> None:
|
||||
"""Poll the Gitea releases page and, if there's a newer tag, apply it
|
||||
and ask the UI to restart.
|
||||
|
||||
Only ever runs from the wake-listener's tick, so the pet is IDLE and
|
||||
between turns by construction — an update can't land mid-sentence.
|
||||
Failures are logged and the interval resets, so a server that's down
|
||||
(or a release that rolls back) costs one log line an hour, not a
|
||||
retry storm."""
|
||||
if not config.AUTO_UPDATE or self._update_pending:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now - self._last_update_check < config.UPDATE_CHECK_INTERVAL_SECONDS:
|
||||
return
|
||||
self._last_update_check = now
|
||||
try:
|
||||
release = updater.check_for_update()
|
||||
except updater.UpdateError as exc:
|
||||
self.log.emit(f"Update check failed: {exc}")
|
||||
return
|
||||
if release is None:
|
||||
return
|
||||
|
||||
self.log.emit(f"Update available: {release.tag} — applying.")
|
||||
try:
|
||||
previous = updater.apply_update(release.tag, on_log=self.log.emit)
|
||||
except updater.UpdateError as exc:
|
||||
self.log.emit(f"Update to {release.tag} failed: {exc}")
|
||||
return
|
||||
|
||||
self._update_pending = True
|
||||
self.log.emit(f"Updated {previous} -> {release.tag}; restarting.")
|
||||
if not self._napping:
|
||||
# Napping means no proactive noise, so a silent restart it is.
|
||||
self._speak(f"Updating to {release.tag}. Back in a second.")
|
||||
self._state.transition(PetState.IDLE)
|
||||
self.restart_requested.emit(release.tag)
|
||||
|
||||
# ── heartbeat ────────────────────────────────────────────────────────
|
||||
|
||||
def _maybe_heartbeat(self) -> None:
|
||||
self._refresh_nap_state()
|
||||
self._maybe_update()
|
||||
if self._update_pending:
|
||||
return # on the way out — don't start a conversation now
|
||||
now = time.monotonic()
|
||||
if now - self._last_heartbeat < config.HEARTBEAT_INTERVAL_SECONDS:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user