Upload
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
"""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
|
||||
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)
|
||||
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)
|
||||
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,
|
||||
)
|
||||
|
||||
def _handle_napping(napping: bool) -> None:
|
||||
window.set_napping(napping)
|
||||
tray.set_napping(napping)
|
||||
|
||||
controller.napping.connect(_handle_napping)
|
||||
|
||||
# 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}")
|
||||
|
||||
def _shutdown() -> None:
|
||||
hotkey.stop()
|
||||
controller.stop()
|
||||
thread.quit()
|
||||
thread.wait(5000)
|
||||
|
||||
app.aboutToQuit.connect(_shutdown)
|
||||
|
||||
thread.start()
|
||||
return app.exec()
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Scrollback for the speech bubble.
|
||||
|
||||
The bubble is transient by design, so anything Bolt said more than a few
|
||||
seconds ago is gone. This is the "wait, what was that path again?" window:
|
||||
the last HISTORY_LIMIT turns, copyable. Opened from the tray.
|
||||
|
||||
Reads a bolt_pet.history.ConversationHistory (pure logic, tested separately);
|
||||
this file is only presentation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication, QDialog, QHBoxLayout, QPlainTextEdit, QPushButton, QVBoxLayout,
|
||||
)
|
||||
|
||||
from ..history import ConversationHistory
|
||||
|
||||
|
||||
def _clock(timestamp: float) -> str:
|
||||
return time.strftime("%H:%M:%S", time.localtime(timestamp))
|
||||
|
||||
|
||||
class HistoryWindow(QDialog):
|
||||
def __init__(self, history: ConversationHistory, on_clear: Optional[Callable[[], None]] = None):
|
||||
super().__init__()
|
||||
self._history = history
|
||||
self._on_clear = on_clear
|
||||
self.setWindowTitle("Bolt — conversation history")
|
||||
self.resize(620, 420)
|
||||
|
||||
self._view = QPlainTextEdit()
|
||||
self._view.setReadOnly(True)
|
||||
self._view.setLineWrapMode(QPlainTextEdit.WidgetWidth)
|
||||
|
||||
copy_button = QPushButton("Copy all")
|
||||
copy_button.clicked.connect(self._copy_all)
|
||||
clear_button = QPushButton("Clear")
|
||||
clear_button.clicked.connect(self._clear)
|
||||
close_button = QPushButton("Close")
|
||||
close_button.clicked.connect(self.close)
|
||||
close_button.setDefault(True)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addWidget(copy_button)
|
||||
buttons.addWidget(clear_button)
|
||||
buttons.addStretch(1)
|
||||
buttons.addWidget(close_button)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self._view)
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def refresh(self) -> None:
|
||||
self._view.setPlainText(self._history.as_text(clock=_clock))
|
||||
# Jump to the newest line — that's what you opened this for.
|
||||
scrollbar = self._view.verticalScrollBar()
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
|
||||
def show_refreshed(self) -> None:
|
||||
self.refresh()
|
||||
self.show()
|
||||
self.raise_()
|
||||
self.activateWindow()
|
||||
|
||||
def _copy_all(self) -> None:
|
||||
QApplication.clipboard().setText(self._history.as_text(clock=_clock))
|
||||
|
||||
def _clear(self) -> None:
|
||||
self._history.clear()
|
||||
if self._on_clear is not None:
|
||||
self._on_clear()
|
||||
self.refresh()
|
||||
|
||||
def keyPressEvent(self, event) -> None:
|
||||
if event.key() == Qt.Key_Escape:
|
||||
self.close()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
@@ -0,0 +1,582 @@
|
||||
"""The pet itself: a frameless, translucent, always-on-top window that
|
||||
renders the current sprite animation, wanders the desktop on its own while
|
||||
idle, walks/emotes on command from the server (see pet_actions.py), can be
|
||||
dragged around, dims when napping, and turns a plain (non-drag) click into a
|
||||
"talk now" request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QPoint, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import (
|
||||
QColor, QCursor, QFont, QFontMetrics, QPainter, QPainterPath, QPixmap, QRegion, QTransform,
|
||||
)
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from .. import config
|
||||
from ..state import PetState
|
||||
from .sprite import SpriteSet
|
||||
|
||||
_DRAG_THRESHOLD_PX = 4
|
||||
# Movement runs on its own ~30fps timer, independent of the (slower) sprite
|
||||
# animation timer, so a stroll looks smooth even at IDLE_ANIMATION_FPS=6.
|
||||
_WANDER_TICK_MS = 33
|
||||
_EMOTE_TICKS = 36 # ~1.2s per emote at the tick rate above
|
||||
_NAP_OPACITY = 0.35
|
||||
|
||||
|
||||
def emote_transform(emote: str, progress: float) -> tuple[float, float, float, float]:
|
||||
"""(dx, dy, rotation_degrees, scale) for an emote at *progress* 0..1.
|
||||
|
||||
Pure maths, deliberately separate from paintEvent so the motion curves can
|
||||
be unit tested (and so adding an emote doesn't mean touching painting
|
||||
code). Every emote must return to (0, 0, 0, 1) at progress 1.0, otherwise
|
||||
the pet ends up permanently askew.
|
||||
"""
|
||||
progress = min(max(progress, 0.0), 1.0)
|
||||
fade = math.sin(math.pi * progress) # 0 -> 1 -> 0, so it always lands home
|
||||
tau = 2 * math.pi
|
||||
if emote == "wave":
|
||||
return 0.0, 0.0, 14.0 * fade * math.sin(tau * 2 * progress), 1.0
|
||||
if emote in ("hop", "bounce"):
|
||||
hops = 2 if emote == "hop" else 3
|
||||
return 0.0, -22.0 * fade * abs(math.sin(math.pi * hops * progress)), 0.0, 1.0
|
||||
if emote == "spin":
|
||||
return 0.0, 0.0, 360.0 * progress % 360.0, 1.0
|
||||
if emote == "nod":
|
||||
return 0.0, 10.0 * fade * math.sin(tau * 2 * progress), 0.0, 1.0 - 0.05 * fade
|
||||
if emote in ("shake", "wiggle"):
|
||||
return 14.0 * fade * math.sin(tau * 3 * progress), 0.0, 0.0, 1.0
|
||||
return 0.0, 0.0, 0.0, 1.0
|
||||
|
||||
|
||||
class SpeechBubble(QWidget):
|
||||
"""Small translucent word-bubble shown above the pet while it talks."""
|
||||
|
||||
_MAX_WIDTH = 260
|
||||
_PADDING = 10
|
||||
|
||||
copied = Signal(str)
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent, Qt.FramelessWindowHint | Qt.Tool)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground)
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||
self._text = ""
|
||||
self._font = QFont()
|
||||
self._font.setPointSize(10)
|
||||
self._flash = "" # transient overlay ("Copied") drawn over the text
|
||||
self._hide_timer = QTimer(self)
|
||||
self._hide_timer.setSingleShot(True)
|
||||
self._hide_timer.timeout.connect(self.hide)
|
||||
self._flash_timer = QTimer(self)
|
||||
self._flash_timer.setSingleShot(True)
|
||||
self._flash_timer.timeout.connect(self._clear_flash)
|
||||
self.setToolTip("Click to copy")
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
self.hide()
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self._text
|
||||
|
||||
def show_text(self, text: str, duration_ms: int = 6000) -> None:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
self.hide()
|
||||
return
|
||||
self._text = text
|
||||
self._relayout()
|
||||
self.show()
|
||||
self.raise_()
|
||||
self._hide_timer.start(duration_ms)
|
||||
|
||||
# ── click to copy ────────────────────────────────────────────────────
|
||||
# The bubble hides itself after a few seconds, which is fine for chat and
|
||||
# awful for anything you needed to keep (a path, a number, a command's
|
||||
# output). One click puts it on the clipboard; the tray's History window
|
||||
# has the rest.
|
||||
|
||||
def mousePressEvent(self, event) -> None:
|
||||
if event.button() != Qt.LeftButton or not self._text:
|
||||
return
|
||||
QApplication.clipboard().setText(self._text)
|
||||
self.copied.emit(self._text)
|
||||
self._flash = "Copied to clipboard"
|
||||
self.update()
|
||||
self._flash_timer.start(900)
|
||||
self._hide_timer.start(2500) # linger a moment so the flash is visible
|
||||
|
||||
def _clear_flash(self) -> None:
|
||||
self._flash = ""
|
||||
self.update()
|
||||
|
||||
def _wrapped_lines(self) -> list[str]:
|
||||
metrics = QFontMetrics(self._font)
|
||||
words = self._text.split()
|
||||
lines: list[str] = []
|
||||
current = ""
|
||||
max_text_width = self._MAX_WIDTH - 2 * self._PADDING
|
||||
for word in words:
|
||||
candidate = f"{current} {word}".strip()
|
||||
if metrics.horizontalAdvance(candidate) <= max_text_width or not current:
|
||||
current = candidate
|
||||
else:
|
||||
lines.append(current)
|
||||
current = word
|
||||
if current:
|
||||
lines.append(current)
|
||||
return lines[:6] # don't let a huge reply turn into a wall of bubble
|
||||
|
||||
def _relayout(self) -> None:
|
||||
metrics = QFontMetrics(self._font)
|
||||
lines = self._wrapped_lines()
|
||||
text_width = max((metrics.horizontalAdvance(line) for line in lines), default=0)
|
||||
width = min(self._MAX_WIDTH, text_width + 2 * self._PADDING)
|
||||
height = metrics.height() * len(lines) + 2 * self._PADDING
|
||||
self.resize(width, height)
|
||||
|
||||
def paintEvent(self, _event) -> None:
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(0, 0, self.width(), self.height(), 10, 10)
|
||||
painter.fillPath(path, QColor(30, 30, 35, 230))
|
||||
painter.setFont(self._font)
|
||||
metrics = QFontMetrics(self._font)
|
||||
if self._flash:
|
||||
painter.setPen(QColor(150, 230, 170))
|
||||
painter.drawText(self.rect(), Qt.AlignCenter, self._flash)
|
||||
return
|
||||
painter.setPen(QColor(240, 240, 245))
|
||||
y = self._PADDING + metrics.ascent()
|
||||
for line in self._wrapped_lines():
|
||||
painter.drawText(self._PADDING, y, line)
|
||||
y += metrics.height()
|
||||
|
||||
|
||||
class PetWindow(QWidget):
|
||||
talk_requested = Signal()
|
||||
copied = Signal(str) # bubble text the user just put on the clipboard
|
||||
|
||||
def __init__(self, sprite_dir: Optional[Path] = None, size: Optional[int] = None):
|
||||
super().__init__()
|
||||
flags = Qt.FramelessWindowHint | Qt.Tool
|
||||
if config.PET_ALWAYS_ON_TOP:
|
||||
flags |= Qt.WindowStaysOnTopHint
|
||||
self.setWindowFlags(flags)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground)
|
||||
|
||||
self.sprites = SpriteSet(sprite_dir or (Path(__file__).resolve().parent.parent / "assets" / "sprites"),
|
||||
size or config.PET_SIZE)
|
||||
self.resize(self.sprites.size, self.sprites.size)
|
||||
self._current_state = PetState.IDLE
|
||||
|
||||
self._drag_offset: Optional[QPoint] = None
|
||||
self._press_pos: Optional[QPoint] = None
|
||||
self._dragged = False
|
||||
|
||||
self._bubble = SpeechBubble()
|
||||
self._bubble.copied.connect(self.copied)
|
||||
|
||||
self._napping = False
|
||||
self._emote: Optional[str] = None
|
||||
self._emote_tick = 0
|
||||
self._mask_key = None
|
||||
|
||||
self._anim_timer = QTimer(self)
|
||||
self._anim_timer.timeout.connect(self._advance_frame)
|
||||
fps = max(1.0, config.IDLE_ANIMATION_FPS)
|
||||
self._anim_timer.start(int(1000 / fps))
|
||||
|
||||
self._wander_enabled = config.PET_WANDER
|
||||
self._wander_target: Optional[QPoint] = None
|
||||
self._commanded_move = False # a petctl move — happens even mid-conversation
|
||||
self._next_wander_at = 0.0
|
||||
self._bob_offset = 0
|
||||
self._bob_phase = 0.0
|
||||
self._schedule_next_wander()
|
||||
self._wander_timer = QTimer(self)
|
||||
self._wander_timer.timeout.connect(self._movement_tick)
|
||||
self._wander_timer.start(_WANDER_TICK_MS)
|
||||
|
||||
self._click_through = False
|
||||
self.set_click_through(config.PET_CLICK_THROUGH)
|
||||
self._place_start_position()
|
||||
|
||||
# ── placement ────────────────────────────────────────────────────────
|
||||
|
||||
def _place_start_position(self) -> None:
|
||||
screen = QApplication.primaryScreen()
|
||||
geo = screen.availableGeometry() if screen else None
|
||||
try:
|
||||
x = int(config.PET_START_X) if config.PET_START_X else None
|
||||
y = int(config.PET_START_Y) if config.PET_START_Y else None
|
||||
except ValueError:
|
||||
x = y = None
|
||||
if geo is not None:
|
||||
x = geo.right() - self.width() - 40 if x is None else x
|
||||
y = geo.bottom() - self.height() - 60 if y is None else y
|
||||
self.move(x or 0, y or 0)
|
||||
self._reposition_bubble()
|
||||
|
||||
def _reposition_bubble(self) -> None:
|
||||
top_left = self.geometry().topLeft()
|
||||
self._bubble.move(
|
||||
top_left.x() + self.width() // 2 - self._bubble.width() // 2,
|
||||
top_left.y() - self._bubble.height() - 8,
|
||||
)
|
||||
|
||||
# ── server-driven actions (petctl) ───────────────────────────────────
|
||||
|
||||
def apply_action(self, action: dict) -> None:
|
||||
"""Perform one parsed petctl action (see pet_actions.py). Called on
|
||||
the UI thread via a queued signal from the controller."""
|
||||
kind = action.get("action")
|
||||
if kind == "move":
|
||||
target = self._resolve_move_target(action)
|
||||
if target is not None:
|
||||
self._wander_target = target
|
||||
self._commanded_move = True # overrides the idle-only rule
|
||||
elif kind == "emote":
|
||||
self.start_emote(action["emote"])
|
||||
elif kind == "say":
|
||||
self.say(action["text"])
|
||||
elif kind == "wander":
|
||||
self.set_wander_enabled(bool(action["enabled"]))
|
||||
elif kind == "nap":
|
||||
self.set_napping(bool(action["enabled"]))
|
||||
|
||||
def _resolve_move_target(self, action: dict) -> Optional[QPoint]:
|
||||
geo = self._screen_geometry()
|
||||
if "x" in action and "y" in action:
|
||||
point = QPoint(int(action["x"]), int(action["y"]))
|
||||
return self._clamp_to_screen(point, geo)
|
||||
anchor = action.get("anchor")
|
||||
if anchor == "cursor":
|
||||
cursor = QCursor.pos()
|
||||
return self._clamp_to_screen(
|
||||
QPoint(cursor.x() - self.width() // 2, cursor.y() - self.height() // 2), geo
|
||||
)
|
||||
if geo is None:
|
||||
return None
|
||||
if anchor == "random":
|
||||
return self._pick_wander_target()
|
||||
margin = config.PET_WANDER_MARGIN
|
||||
left, right = geo.left() + margin, geo.right() - self.width() - margin
|
||||
top, bottom = geo.top() + margin, geo.bottom() - self.height() - margin
|
||||
middle_x = geo.left() + (geo.width() - self.width()) // 2
|
||||
middle_y = geo.top() + (geo.height() - self.height()) // 2
|
||||
positions = {
|
||||
"top-left": (left, top), "top": (middle_x, top), "top-right": (right, top),
|
||||
"left": (left, middle_y), "center": (middle_x, middle_y), "right": (right, middle_y),
|
||||
"bottom-left": (left, bottom), "bottom": (middle_x, bottom), "bottom-right": (right, bottom),
|
||||
}
|
||||
if anchor not in positions:
|
||||
return None
|
||||
return QPoint(*positions[anchor])
|
||||
|
||||
def _clamp_to_screen(self, point: QPoint, geo) -> QPoint:
|
||||
if geo is None:
|
||||
return point
|
||||
x = min(max(point.x(), geo.left()), max(geo.left(), geo.right() - self.width()))
|
||||
y = min(max(point.y(), geo.top()), max(geo.top(), geo.bottom() - self.height()))
|
||||
return QPoint(x, y)
|
||||
|
||||
# ── emotes ───────────────────────────────────────────────────────────
|
||||
|
||||
def start_emote(self, emote: str) -> None:
|
||||
self._emote = emote
|
||||
self._emote_tick = 0
|
||||
self.update()
|
||||
|
||||
def _advance_emote(self) -> None:
|
||||
if self._emote is None:
|
||||
return
|
||||
self._emote_tick += 1
|
||||
if self._emote_tick > _EMOTE_TICKS:
|
||||
self._emote = None
|
||||
self._emote_tick = 0
|
||||
self.update()
|
||||
|
||||
def _emote_transform(self) -> tuple[float, float, float, float]:
|
||||
if self._emote is None:
|
||||
return 0.0, 0.0, 0.0, 1.0
|
||||
return emote_transform(self._emote, self._emote_tick / _EMOTE_TICKS)
|
||||
|
||||
# ── napping (quiet hours / do-not-disturb) ───────────────────────────
|
||||
|
||||
def set_napping(self, napping: bool) -> None:
|
||||
"""Dim and stand still. Purely cosmetic here — the controller is what
|
||||
actually suppresses proactive speech."""
|
||||
if napping == self._napping:
|
||||
return
|
||||
self._napping = napping
|
||||
self.setWindowOpacity(_NAP_OPACITY if napping else 1.0)
|
||||
if napping:
|
||||
self._stop_walking()
|
||||
self.update()
|
||||
|
||||
@property
|
||||
def napping(self) -> bool:
|
||||
return self._napping
|
||||
|
||||
# ── mouse transparency ───────────────────────────────────────────────
|
||||
|
||||
def set_click_through(self, enabled: bool) -> None:
|
||||
"""When on, the pet ignores the mouse entirely (tray-only control) —
|
||||
for when it's parked over something you need to click a lot."""
|
||||
self._click_through = enabled
|
||||
self.setAttribute(Qt.WA_TransparentForMouseEvents, enabled)
|
||||
if enabled:
|
||||
self.clearMask()
|
||||
self._mask_key = None
|
||||
else:
|
||||
self._mask_key = None # force the shaped mask to be rebuilt
|
||||
|
||||
@property
|
||||
def click_through(self) -> bool:
|
||||
return self._click_through
|
||||
|
||||
def _apply_input_mask(self, pixmap: Optional[QPixmap], x: int, y: int) -> None:
|
||||
"""Restrict the window to the sprite's opaque pixels, so the square
|
||||
window's transparent corners stop swallowing clicks meant for what's
|
||||
underneath. Rebuilt only when the frame actually changes — the mask
|
||||
is derived from the pixmap's alpha, which isn't free."""
|
||||
if self._click_through or not config.PET_SHAPED_INPUT:
|
||||
return
|
||||
if pixmap is None:
|
||||
if self._mask_key is not None:
|
||||
self.clearMask()
|
||||
self._mask_key = None
|
||||
return
|
||||
key = (pixmap.cacheKey(), x, y)
|
||||
if key == self._mask_key:
|
||||
return
|
||||
self._mask_key = key
|
||||
try:
|
||||
region = QRegion(pixmap.mask())
|
||||
region.translate(x, y)
|
||||
self.setMask(region)
|
||||
except Exception:
|
||||
self.clearMask() # a sprite without an alpha channel — never mind
|
||||
|
||||
# ── wandering ────────────────────────────────────────────────────────
|
||||
|
||||
def set_wander_enabled(self, enabled: bool) -> None:
|
||||
self._wander_enabled = enabled
|
||||
if not enabled:
|
||||
self._stop_walking()
|
||||
|
||||
def wander_now(self) -> None:
|
||||
"""Stroll immediately (tray menu / anything that wants a nudge)."""
|
||||
self._next_wander_at = 0.0
|
||||
|
||||
def _screen_geometry(self):
|
||||
# screenAt() so a multi-monitor setup keeps the pet on the screen
|
||||
# it's currently standing on rather than yanking it to the primary.
|
||||
screen = QApplication.screenAt(self.frameGeometry().center()) or QApplication.primaryScreen()
|
||||
return screen.availableGeometry() if screen else None
|
||||
|
||||
def _schedule_next_wander(self) -> None:
|
||||
base = max(1.0, config.PET_WANDER_INTERVAL_SECONDS)
|
||||
self._next_wander_at = time.monotonic() + random.uniform(0.5 * base, 1.5 * base)
|
||||
|
||||
def _stop_walking(self) -> None:
|
||||
if self._wander_target is None and not self._bob_offset:
|
||||
return
|
||||
self._wander_target = None
|
||||
self._commanded_move = False
|
||||
self._bob_phase = 0.0
|
||||
self._bob_offset = 0
|
||||
self.update()
|
||||
|
||||
def snap_to_edge(self) -> bool:
|
||||
"""If the pet has come to rest near a screen edge, tuck it flush
|
||||
against it — a desktop pet parked 11px off the taskbar looks like a
|
||||
bug. Returns True if it moved."""
|
||||
geo = self._screen_geometry()
|
||||
if geo is None or not config.PET_EDGE_SNAP:
|
||||
return False
|
||||
margin = config.PET_SNAP_MARGIN
|
||||
here = self.pos()
|
||||
x, y = here.x(), here.y()
|
||||
if abs(x - geo.left()) <= margin:
|
||||
x = geo.left()
|
||||
elif abs(geo.right() - (x + self.width())) <= margin:
|
||||
x = geo.right() - self.width() + 1
|
||||
if abs(y - geo.top()) <= margin:
|
||||
y = geo.top()
|
||||
elif abs(geo.bottom() - (y + self.height())) <= margin:
|
||||
y = geo.bottom() - self.height() + 1
|
||||
if (x, y) == (here.x(), here.y()):
|
||||
return False
|
||||
self.move(x, y)
|
||||
self._reposition_bubble()
|
||||
return True
|
||||
|
||||
def _pick_wander_target(self) -> Optional[QPoint]:
|
||||
geo = self._screen_geometry()
|
||||
if geo is None:
|
||||
return None
|
||||
margin = config.PET_WANDER_MARGIN
|
||||
min_x, max_x = geo.left() + margin, geo.right() - self.width() - margin
|
||||
min_y, max_y = geo.top() + margin, geo.bottom() - self.height() - margin
|
||||
if max_x <= min_x or max_y <= min_y: # pet bigger than the screen
|
||||
return None
|
||||
here = self.pos()
|
||||
target = QPoint(random.randint(min_x, max_x), random.randint(min_y, max_y))
|
||||
dx, dy = target.x() - here.x(), target.y() - here.y()
|
||||
distance = math.hypot(dx, dy)
|
||||
limit = max(1.0, config.PET_WANDER_MAX_DISTANCE)
|
||||
if distance > limit: # shorten the trip rather than sprinting the diagonal
|
||||
scale = limit / distance
|
||||
target = QPoint(round(here.x() + dx * scale), round(here.y() + dy * scale))
|
||||
elif distance < 8: # already there — not worth a stroll
|
||||
return None
|
||||
return target
|
||||
|
||||
def _movement_tick(self) -> None:
|
||||
"""One timer, two jobs — emotes play whatever the pet is doing, while
|
||||
wandering only happens when it's otherwise unoccupied."""
|
||||
self._advance_emote()
|
||||
self._wander_tick()
|
||||
|
||||
def _wander_tick(self) -> None:
|
||||
# Only stroll while genuinely idle: not mid-drag, not napping, not
|
||||
# talking/listening, and not while a speech bubble is up (it would walk
|
||||
# out from under it). A commanded `petctl move` ignores all of that
|
||||
# except the drag — if Bolt says go, it goes.
|
||||
busy = (
|
||||
not self._wander_enabled
|
||||
or self._napping
|
||||
or self._current_state != PetState.IDLE
|
||||
or self._bubble.isVisible()
|
||||
)
|
||||
if self._drag_offset is not None or (busy and not self._commanded_move):
|
||||
self._stop_walking()
|
||||
self._schedule_next_wander() # settle first, then wander
|
||||
return
|
||||
|
||||
if self._wander_target is None:
|
||||
if time.monotonic() < self._next_wander_at:
|
||||
return
|
||||
self._wander_target = self._pick_wander_target()
|
||||
if self._wander_target is None:
|
||||
self._schedule_next_wander()
|
||||
return
|
||||
|
||||
here = self.pos()
|
||||
dx = self._wander_target.x() - here.x()
|
||||
dy = self._wander_target.y() - here.y()
|
||||
distance = math.hypot(dx, dy)
|
||||
step = max(1.0, config.PET_WANDER_SPEED * _WANDER_TICK_MS / 1000.0)
|
||||
if distance <= step:
|
||||
self.move(self._wander_target)
|
||||
self._stop_walking()
|
||||
self._schedule_next_wander()
|
||||
self.snap_to_edge()
|
||||
else:
|
||||
self.move(round(here.x() + dx / distance * step), round(here.y() + dy / distance * step))
|
||||
self._bob_phase += 0.45 # little walk-cycle hop
|
||||
self._bob_offset = int(round(-2.5 * abs(math.sin(self._bob_phase))))
|
||||
self.update()
|
||||
self._reposition_bubble()
|
||||
|
||||
# ── state / speech ──────────────────────────────────────────────────
|
||||
|
||||
def set_state(self, state: PetState) -> None:
|
||||
if state == self._current_state:
|
||||
return
|
||||
self._current_state = state
|
||||
self.sprites.get(state).reset()
|
||||
if state != PetState.IDLE and not self._commanded_move:
|
||||
# Stand still while listening/thinking/talking — but not if Bolt
|
||||
# just told it to walk somewhere: that command arrives mid-turn,
|
||||
# and the reply (-> TALKING) lands a moment later.
|
||||
self._stop_walking()
|
||||
self.update()
|
||||
|
||||
def say(self, text: str, duration_ms: int = 6000) -> None:
|
||||
self._reposition_bubble()
|
||||
self._bubble.show_text(text, duration_ms)
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
self._bubble.close()
|
||||
super().closeEvent(event)
|
||||
|
||||
# ── animation ────────────────────────────────────────────────────────
|
||||
|
||||
def _advance_frame(self) -> None:
|
||||
self.sprites.get(self._current_state).advance()
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, _event) -> None:
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
painter.setRenderHint(QPainter.SmoothPixmapTransform)
|
||||
pixmap: Optional[QPixmap] = self.sprites.get(self._current_state).current()
|
||||
if pixmap is None:
|
||||
self._apply_input_mask(None, 0, 0)
|
||||
return
|
||||
# Non-square source art (e.g. the Kenney robot sprites) keeps its
|
||||
# aspect ratio when scaled in SpriteSet, so it may be narrower or
|
||||
# shorter than the (square) window — center it either way.
|
||||
x = (self.width() - pixmap.width()) // 2
|
||||
y = (self.height() - pixmap.height()) // 2 + self._bob_offset
|
||||
# The input mask tracks the resting position, not the emote/bob
|
||||
# offset: rebuilding it every frame of a spin would be both expensive
|
||||
# and visibly janky, and the offsets are only a few pixels.
|
||||
self._apply_input_mask(pixmap, x, (self.height() - pixmap.height()) // 2)
|
||||
|
||||
dx, dy, angle, scale = self._emote_transform()
|
||||
if (dx, dy, angle, scale) == (0.0, 0.0, 0.0, 1.0):
|
||||
painter.drawPixmap(x, y, pixmap)
|
||||
return
|
||||
# Rotate/scale about the sprite's own center so a spin doesn't orbit
|
||||
# the window's corner.
|
||||
center_x = x + pixmap.width() / 2
|
||||
center_y = y + pixmap.height() / 2
|
||||
transform = QTransform()
|
||||
transform.translate(center_x + dx, center_y + dy)
|
||||
transform.rotate(angle)
|
||||
transform.scale(scale, scale)
|
||||
transform.translate(-pixmap.width() / 2, -pixmap.height() / 2)
|
||||
painter.setTransform(transform)
|
||||
painter.drawPixmap(0, 0, pixmap)
|
||||
|
||||
# ── drag / click-to-talk ─────────────────────────────────────────────
|
||||
|
||||
def mousePressEvent(self, event) -> None:
|
||||
if event.button() == Qt.LeftButton:
|
||||
global_pos = event.globalPosition().toPoint()
|
||||
self._drag_offset = global_pos - self.frameGeometry().topLeft()
|
||||
self._press_pos = global_pos
|
||||
self._dragged = False
|
||||
self._stop_walking() # grabbing it interrupts a stroll at once
|
||||
|
||||
def mouseMoveEvent(self, event) -> None:
|
||||
if self._drag_offset is None:
|
||||
return
|
||||
global_pos = event.globalPosition().toPoint()
|
||||
self.move(global_pos - self._drag_offset)
|
||||
self._reposition_bubble()
|
||||
if (global_pos - self._press_pos).manhattanLength() > _DRAG_THRESHOLD_PX:
|
||||
self._dragged = True
|
||||
|
||||
def mouseReleaseEvent(self, event) -> None:
|
||||
if event.button() != Qt.LeftButton:
|
||||
return
|
||||
was_click = not self._dragged
|
||||
self._drag_offset = None
|
||||
self._press_pos = None
|
||||
if was_click:
|
||||
self.talk_requested.emit()
|
||||
else:
|
||||
self.snap_to_edge() # dropped near an edge -> tuck it flush
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Sprite loading + frame animation.
|
||||
|
||||
Convention: assets/sprites/<state>/*.png, frames played in filename-sorted
|
||||
order (e.g. frame_00.png, frame_01.png, ...), looping. <state> matches
|
||||
bolt_pet.state.PetState values: idle, listening, thinking, talking.
|
||||
|
||||
If a state's directory has no frames (real art not dropped in yet), falls
|
||||
back to a small procedurally-drawn placeholder blob so the app still runs
|
||||
end-to-end. Swap in real sprite sheets by pointing SPRITE_DIR at your own
|
||||
folder (see assets/sprites/README.md) — no code changes needed as long as
|
||||
the same per-state-subfolder-of-PNGs convention is followed. If your sheets
|
||||
use a different layout (single grid image, etc.), tell me the format and
|
||||
this loader can be adapted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QColor, QPainter, QPixmap
|
||||
|
||||
from ..state import PetState
|
||||
|
||||
DEFAULT_SPRITE_DIR = Path(__file__).resolve().parent.parent / "assets" / "sprites"
|
||||
|
||||
# Placeholder palette per state, used only when no frames are found.
|
||||
_PLACEHOLDER_COLORS = {
|
||||
PetState.IDLE: QColor(120, 170, 240),
|
||||
PetState.LISTENING: QColor(120, 220, 160),
|
||||
PetState.THINKING: QColor(230, 190, 90),
|
||||
PetState.TALKING: QColor(240, 130, 150),
|
||||
PetState.ERROR: QColor(220, 90, 90),
|
||||
}
|
||||
|
||||
|
||||
def _placeholder_frames(state: PetState, size: int) -> list[QPixmap]:
|
||||
"""A tiny 2-frame "breathing" blob so idle/listening/etc. are visually
|
||||
distinguishable even before real art exists."""
|
||||
color = _PLACEHOLDER_COLORS.get(state, QColor(150, 150, 150))
|
||||
frames = []
|
||||
for scale in (1.0, 0.92):
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
painter.setBrush(color)
|
||||
painter.setPen(Qt.NoPen)
|
||||
margin = size * (1 - scale) / 2
|
||||
painter.drawEllipse(int(margin), int(margin), int(size * scale), int(size * scale))
|
||||
# simple eyes so it reads as a face, not just a circle
|
||||
eye_r = max(2, size // 16)
|
||||
eye_y = int(size * 0.42)
|
||||
painter.setBrush(QColor(30, 30, 40))
|
||||
painter.drawEllipse(int(size * 0.36) - eye_r, eye_y - eye_r, eye_r * 2, eye_r * 2)
|
||||
painter.drawEllipse(int(size * 0.64) - eye_r, eye_y - eye_r, eye_r * 2, eye_r * 2)
|
||||
painter.end()
|
||||
frames.append(pixmap)
|
||||
return frames
|
||||
|
||||
|
||||
class SpriteAnimation:
|
||||
"""One state's frame sequence + current playback position."""
|
||||
|
||||
def __init__(self, frames: list[QPixmap]):
|
||||
self.frames = frames or []
|
||||
self._index = 0
|
||||
|
||||
def advance(self) -> None:
|
||||
if self.frames:
|
||||
self._index = (self._index + 1) % len(self.frames)
|
||||
|
||||
def current(self) -> Optional[QPixmap]:
|
||||
if not self.frames:
|
||||
return None
|
||||
return self.frames[self._index]
|
||||
|
||||
def reset(self) -> None:
|
||||
self._index = 0
|
||||
|
||||
|
||||
def _load_frames_from_dir(directory: Path, size: int) -> list[QPixmap]:
|
||||
if not directory.is_dir():
|
||||
return []
|
||||
paths = sorted(directory.glob("*.png")) + sorted(directory.glob("*.PNG"))
|
||||
frames = []
|
||||
for path in paths:
|
||||
pixmap = QPixmap(str(path))
|
||||
if pixmap.isNull():
|
||||
continue
|
||||
if pixmap.size() != QSize(size, size):
|
||||
pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
frames.append(pixmap)
|
||||
return frames
|
||||
|
||||
|
||||
class SpriteSet:
|
||||
"""All animations for every PetState, loaded from *sprite_dir*."""
|
||||
|
||||
def __init__(self, sprite_dir: Path = DEFAULT_SPRITE_DIR, size: int = 160):
|
||||
self.size = size
|
||||
self._animations: dict[PetState, SpriteAnimation] = {}
|
||||
for state in PetState:
|
||||
frames = _load_frames_from_dir(sprite_dir / state.value, size)
|
||||
if not frames:
|
||||
frames = _placeholder_frames(state, size)
|
||||
self._animations[state] = SpriteAnimation(frames)
|
||||
|
||||
def get(self, state: PetState) -> SpriteAnimation:
|
||||
return self._animations[state]
|
||||
@@ -0,0 +1,133 @@
|
||||
"""System tray icon — the pet window is frameless with no taskbar entry, so
|
||||
this menu is the only always-available way to control or exit it: talk now,
|
||||
mute, wander, click-through, nap, history, wake-word tuning, quit.
|
||||
|
||||
Every entry is a plain callback passed in by ui/app.py; this file knows
|
||||
nothing about the controller or the pet window.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QAction, QColor, QIcon, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import QMenu, QSystemTrayIcon
|
||||
|
||||
|
||||
def _make_icon(muted: bool, napping: bool = False) -> QIcon:
|
||||
pixmap = QPixmap(32, 32)
|
||||
pixmap.fill(Qt.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
if muted:
|
||||
color = QColor(200, 60, 60)
|
||||
elif napping:
|
||||
color = QColor(120, 120, 140)
|
||||
else:
|
||||
color = QColor(120, 170, 240)
|
||||
painter.setBrush(color)
|
||||
painter.setPen(Qt.NoPen)
|
||||
painter.drawEllipse(2, 2, 28, 28)
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
class PetTray(QSystemTrayIcon):
|
||||
def __init__(
|
||||
self,
|
||||
on_talk_now: Callable[[], None],
|
||||
on_toggle_mute: Callable[[], bool],
|
||||
on_quit: Callable[[], None],
|
||||
on_set_wander: Optional[Callable[[bool], None]] = None,
|
||||
wander_enabled: bool = True,
|
||||
on_set_click_through: Optional[Callable[[bool], None]] = None,
|
||||
click_through_enabled: bool = False,
|
||||
on_set_nap: Optional[Callable[[bool], None]] = None,
|
||||
on_show_history: Optional[Callable[[], None]] = None,
|
||||
on_show_wake_tuner: Optional[Callable[[], None]] = None,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(_make_icon(muted=False), parent)
|
||||
self._on_toggle_mute = on_toggle_mute
|
||||
self._muted = False
|
||||
self._napping = False
|
||||
self.setToolTip("Bolt")
|
||||
|
||||
menu = QMenu()
|
||||
self._talk_action = QAction("Talk now", menu)
|
||||
self._talk_action.triggered.connect(on_talk_now)
|
||||
menu.addAction(self._talk_action)
|
||||
|
||||
self._mute_action = QAction("Mute mic", menu)
|
||||
self._mute_action.setCheckable(True)
|
||||
self._mute_action.triggered.connect(self._handle_toggle_mute)
|
||||
menu.addAction(self._mute_action)
|
||||
|
||||
self._nap_action = None
|
||||
if on_set_nap is not None:
|
||||
self._nap_action = QAction("Nap (no proactive noise)", menu)
|
||||
self._nap_action.setCheckable(True)
|
||||
self._nap_action.triggered.connect(lambda checked: on_set_nap(checked))
|
||||
menu.addAction(self._nap_action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
if on_set_wander is not None:
|
||||
self._wander_action = QAction("Wander around", menu)
|
||||
self._wander_action.setCheckable(True)
|
||||
self._wander_action.setChecked(wander_enabled)
|
||||
self._wander_action.triggered.connect(lambda checked: on_set_wander(checked))
|
||||
menu.addAction(self._wander_action)
|
||||
|
||||
if on_set_click_through is not None:
|
||||
self._click_through_action = QAction("Click through the pet", menu)
|
||||
self._click_through_action.setCheckable(True)
|
||||
self._click_through_action.setChecked(click_through_enabled)
|
||||
self._click_through_action.setToolTip(
|
||||
"Ignore the mouse entirely — control it from this menu instead."
|
||||
)
|
||||
self._click_through_action.triggered.connect(lambda checked: on_set_click_through(checked))
|
||||
menu.addAction(self._click_through_action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
if on_show_history is not None:
|
||||
history_action = QAction("History…", menu)
|
||||
history_action.triggered.connect(on_show_history)
|
||||
menu.addAction(history_action)
|
||||
|
||||
if on_show_wake_tuner is not None:
|
||||
tuner_action = QAction("Wake word tuning…", menu)
|
||||
tuner_action.triggered.connect(on_show_wake_tuner)
|
||||
menu.addAction(tuner_action)
|
||||
|
||||
menu.addSeparator()
|
||||
quit_action = QAction("Quit", menu)
|
||||
quit_action.triggered.connect(on_quit)
|
||||
menu.addAction(quit_action)
|
||||
|
||||
self.setContextMenu(menu)
|
||||
self.show()
|
||||
|
||||
def _handle_toggle_mute(self) -> None:
|
||||
self._muted = self._on_toggle_mute()
|
||||
self._mute_action.setChecked(self._muted)
|
||||
self._refresh_icon()
|
||||
|
||||
def set_napping(self, napping: bool) -> None:
|
||||
"""Reflect a nap the *controller* decided on (quiet hours, fullscreen,
|
||||
or a petctl command) — not just ones clicked here."""
|
||||
self._napping = napping
|
||||
if self._nap_action is not None:
|
||||
self._nap_action.setChecked(napping)
|
||||
self._refresh_icon()
|
||||
|
||||
def _refresh_icon(self) -> None:
|
||||
self.setIcon(_make_icon(self._muted, self._napping))
|
||||
if self._muted:
|
||||
self.setToolTip("Bolt (muted)")
|
||||
elif self._napping:
|
||||
self.setToolTip("Bolt (napping)")
|
||||
else:
|
||||
self.setToolTip("Bolt")
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Wake-word sensitivity tuner.
|
||||
|
||||
WAKE_WORD_THRESHOLD is otherwise a number you guess at in .env, restart, and
|
||||
then test by saying "thunderbolt" at your computer repeatedly. This window
|
||||
makes it evidence-based: a live peak-score readout while you talk, a rolling
|
||||
list of near misses (frames that scored just under the threshold — i.e. the
|
||||
times it *nearly* heard you), and a slider that takes effect immediately,
|
||||
mid-listen, without a restart.
|
||||
|
||||
The controller owns the threshold; this window is a view over it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QHBoxLayout, QLabel, QListWidget, QPushButton, QSlider, QVBoxLayout,
|
||||
)
|
||||
|
||||
_SLIDER_SCALE = 100 # QSlider is integer-only; threshold is 0.00-1.00
|
||||
|
||||
|
||||
class WakeTunerWindow(QDialog):
|
||||
def __init__(
|
||||
self,
|
||||
get_threshold: Callable[[], float],
|
||||
set_threshold: Callable[[float], None],
|
||||
get_stats: Callable[[], dict],
|
||||
on_reset: Callable[[], None],
|
||||
):
|
||||
super().__init__()
|
||||
self._get_threshold = get_threshold
|
||||
self._set_threshold = set_threshold
|
||||
self._get_stats = get_stats
|
||||
self._on_reset = on_reset
|
||||
|
||||
self.setWindowTitle("Bolt — wake word tuning")
|
||||
self.resize(460, 380)
|
||||
|
||||
self._threshold_label = QLabel()
|
||||
self._slider = QSlider(Qt.Horizontal)
|
||||
self._slider.setRange(5, 99)
|
||||
self._slider.setValue(int(round(get_threshold() * _SLIDER_SCALE)))
|
||||
self._slider.valueChanged.connect(self._threshold_changed)
|
||||
|
||||
self._peak_label = QLabel("Peak score since reset: —")
|
||||
self._peak_label.setToolTip(
|
||||
'Say "thunderbolt" a few times and watch this. Set the threshold '
|
||||
"just below the peak you can hit reliably."
|
||||
)
|
||||
|
||||
self._misses = QListWidget()
|
||||
|
||||
reset_button = QPushButton("Reset stats")
|
||||
reset_button.clicked.connect(self._reset)
|
||||
close_button = QPushButton("Close")
|
||||
close_button.clicked.connect(self.close)
|
||||
close_button.setDefault(True)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addWidget(reset_button)
|
||||
buttons.addStretch(1)
|
||||
buttons.addWidget(close_button)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self._threshold_label)
|
||||
layout.addWidget(self._slider)
|
||||
layout.addWidget(self._peak_label)
|
||||
layout.addWidget(QLabel("Near misses (heard something, didn't quite fire):"))
|
||||
layout.addWidget(self._misses)
|
||||
layout.addLayout(buttons)
|
||||
|
||||
# Polled rather than signal-driven: scores arrive ~12x/second on the
|
||||
# audio thread, and a queued signal per frame to repaint a label is
|
||||
# more traffic than this is worth.
|
||||
self._timer = QTimer(self)
|
||||
self._timer.timeout.connect(self.refresh)
|
||||
self._update_threshold_label()
|
||||
|
||||
def _threshold_changed(self, value: int) -> None:
|
||||
self._set_threshold(value / _SLIDER_SCALE)
|
||||
self._update_threshold_label()
|
||||
|
||||
def _update_threshold_label(self) -> None:
|
||||
threshold = self._slider.value() / _SLIDER_SCALE
|
||||
self._threshold_label.setText(
|
||||
f"Threshold: {threshold:.2f} (lower = more sensitive, more false triggers)"
|
||||
)
|
||||
|
||||
def _reset(self) -> None:
|
||||
self._on_reset()
|
||||
self.refresh()
|
||||
|
||||
def refresh(self) -> None:
|
||||
stats = self._get_stats() or {}
|
||||
peak = stats.get("peak", 0.0)
|
||||
self._peak_label.setText(f"Peak score since reset: {peak:.3f}")
|
||||
self._misses.clear()
|
||||
for timestamp, score, threshold in reversed(stats.get("near_misses", [])):
|
||||
when = time.strftime("%H:%M:%S", time.localtime(timestamp))
|
||||
self._misses.addItem(f"{when} scored {score:.3f} (threshold {threshold:.2f})")
|
||||
if self._misses.count() == 0:
|
||||
self._misses.addItem("Nothing yet — say the wake phrase a few times.")
|
||||
|
||||
def show_refreshed(self) -> None:
|
||||
self._slider.setValue(int(round(self._get_threshold() * _SLIDER_SCALE)))
|
||||
self.refresh()
|
||||
self.show()
|
||||
self.raise_()
|
||||
self.activateWindow()
|
||||
self._timer.start(500)
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
self._timer.stop()
|
||||
super().closeEvent(event)
|
||||
Reference in New Issue
Block a user