Upload
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,8 @@
|
||||
"""Entry point: python -m bolt_pet"""
|
||||
|
||||
import sys
|
||||
|
||||
from .ui.app import run
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run())
|
||||
@@ -0,0 +1,45 @@
|
||||
# Sprite assets
|
||||
|
||||
Art: [Kenney's Robot Pack](https://kenney.nl/assets/robot-pack) (CC0 — no
|
||||
attribution required, credited here anyway), the green side-view robot.
|
||||
Source pack lives at `~/Documents/kenney_robot-pack`; only the frames listed
|
||||
below were copied in.
|
||||
|
||||
Convention the loader (`bolt_pet/ui/sprite.py`) expects:
|
||||
|
||||
```
|
||||
assets/sprites/
|
||||
idle/ frame_00.png robot_greenBody (standing)
|
||||
listening/ frame_00.png, frame_01.png robot_greenDrive1/2 (tracks rolling — "leaning in")
|
||||
thinking/ frame_00.png, frame_01.png robot_greenDamage1/2 (flicker — "processing")
|
||||
talking/ frame_00.png, frame_01.png robot_greenBody, robot_greenJump (bounce)
|
||||
error/ frame_00.png robot_greenHurt
|
||||
```
|
||||
|
||||
- One subfolder per pet state (matches `bolt_pet.state.PetState`).
|
||||
- Any `*.png` filenames work — they're played back in alphabetical-sort
|
||||
order, looping, at `IDLE_ANIMATION_FPS` (see `.env`).
|
||||
- Frames are scaled to fit within `PET_SIZE` (default 160px), keeping aspect
|
||||
ratio, and centered in the (square) pet window — the source art here isn't
|
||||
square, so don't assume it fills the frame edge-to-edge.
|
||||
- A state directory with no frames in it falls back to a small
|
||||
procedurally-drawn placeholder blob (see `_placeholder_frames` in
|
||||
`sprite.py`).
|
||||
|
||||
## Swapping in different art
|
||||
|
||||
Replace any state's PNGs (same alphabetical-order-loops convention) to
|
||||
change its look — no code changes needed. If your source is a single grid
|
||||
spritesheet (rows/cols of frames in one PNG) rather than one-file-per-frame,
|
||||
use `scripts/slice_spritesheet.py` to cut it into this folder-of-frames
|
||||
convention:
|
||||
|
||||
```bash
|
||||
python scripts/slice_spritesheet.py path/to/idle_sheet.png assets/sprites/idle \
|
||||
--cols 6 --rows 1
|
||||
```
|
||||
|
||||
If your format is something else entirely (a single animated GIF/APNG, a
|
||||
Spine/DragonBones skeletal export, an Aseprite `.json` atlas, etc.) — tell me
|
||||
the format and I'll adapt `sprite.py`'s loader rather than making you convert
|
||||
by hand.
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,62 @@
|
||||
"""Barge-in: notice that the user started talking *while the pet is talking*
|
||||
so playback can be cut short mid-sentence.
|
||||
|
||||
Deliberately dumber than the utterance VAD in mic.py. The mic hears the pet's
|
||||
own voice coming back out of the speakers, so a single loud frame proves
|
||||
nothing — this requires several consecutive frames well above the normal
|
||||
speech threshold (BARGE_IN_RMS_THRESHOLD defaults to 4x VAD_RMS_THRESHOLD).
|
||||
Takes the same injectable stream shape as mic.record_utterance, so tests feed
|
||||
it fake frames instead of real audio hardware.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .. import config
|
||||
from .mic import AudioStream, rms
|
||||
|
||||
|
||||
class BargeInDetector:
|
||||
"""Poll-driven: call check() repeatedly while audio plays. Each call
|
||||
consumes exactly one mic frame (80ms at the default frame length), which
|
||||
is also what paces the playback loop's polling."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stream: AudioStream,
|
||||
threshold: int = None,
|
||||
required_frames: int = None,
|
||||
frame_len: int = config.FRAME_LEN,
|
||||
):
|
||||
self._stream = stream
|
||||
self._threshold = config.BARGE_IN_RMS_THRESHOLD if threshold is None else threshold
|
||||
self._required = max(1, config.BARGE_IN_FRAMES if required_frames is None else required_frames)
|
||||
self._frame_len = frame_len
|
||||
self._loud_frames = 0
|
||||
|
||||
@property
|
||||
def loud_frames(self) -> int:
|
||||
return self._loud_frames
|
||||
|
||||
def reset(self) -> None:
|
||||
self._loud_frames = 0
|
||||
|
||||
def check(self) -> bool:
|
||||
"""True once the user has been loud for long enough to count as an
|
||||
interruption. Never raises: a mic hiccup mid-playback should not kill
|
||||
the reply, it should just mean "no barge-in this frame"."""
|
||||
try:
|
||||
chunk, _ = self._stream.read(self._frame_len)
|
||||
except Exception:
|
||||
return False
|
||||
frame = np.asarray(chunk)
|
||||
if frame.ndim > 1:
|
||||
frame = frame[:, 0]
|
||||
if frame.size == 0:
|
||||
return False
|
||||
if rms(frame) >= self._threshold:
|
||||
self._loud_frames += 1
|
||||
else:
|
||||
self._loud_frames = 0 # a single thump/cough shouldn't count
|
||||
return self._loud_frames >= self._required
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Mic capture + simple energy-based VAD utterance recording.
|
||||
|
||||
Ported from desk_client/bolt_desk.py's record_utterance() — same tuning
|
||||
knobs, same behavior. Kept independent of any UI/threading model so it can
|
||||
be unit tested by feeding it a fake "stream" object.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import wave
|
||||
from typing import Optional, Protocol
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
class AudioStream(Protocol):
|
||||
"""Minimal shape of the object record_utterance() needs — matches
|
||||
sounddevice.InputStream's .read(frames) -> (data, overflowed)."""
|
||||
|
||||
def read(self, frames: int): ...
|
||||
|
||||
|
||||
def pcm_to_wav_bytes(pcm: np.ndarray, sample_rate: int = config.SAMPLE_RATE) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(pcm.tobytes())
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def rms(frame: np.ndarray) -> float:
|
||||
return float(np.sqrt(np.mean(frame.astype(np.float64) ** 2)))
|
||||
|
||||
|
||||
def record_utterance(
|
||||
stream: AudioStream,
|
||||
should_continue=lambda: True,
|
||||
rms_threshold: int = None,
|
||||
silence_end_sec: float = None,
|
||||
max_utterance_s: float = None,
|
||||
min_utterance_s: float = None,
|
||||
frame_len: int = config.FRAME_LEN,
|
||||
sample_rate: int = config.SAMPLE_RATE,
|
||||
) -> Optional[np.ndarray]:
|
||||
"""Capture one utterance from *stream*: wait for speech to start, stop
|
||||
after trailing silence. Returns None if nothing usable was heard.
|
||||
|
||||
*should_continue* is polled each frame so a caller can cancel recording
|
||||
(e.g. the pet window was closed) without needing threading primitives
|
||||
baked into this function.
|
||||
"""
|
||||
rms_threshold = config.RMS_THRESHOLD if rms_threshold is None else rms_threshold
|
||||
silence_end_sec = config.SILENCE_END_SEC if silence_end_sec is None else silence_end_sec
|
||||
max_utterance_s = config.MAX_UTTERANCE_S if max_utterance_s is None else max_utterance_s
|
||||
min_utterance_s = config.MIN_UTTERANCE_S if min_utterance_s is None else min_utterance_s
|
||||
|
||||
frames: list[np.ndarray] = []
|
||||
started = False
|
||||
silence_frames = 0
|
||||
silence_limit = int(silence_end_sec * sample_rate / frame_len)
|
||||
max_frames = int(max_utterance_s * sample_rate / frame_len)
|
||||
grace_frames = int(4.0 * sample_rate / frame_len) # wait up to 4s for speech to begin
|
||||
waited = 0
|
||||
|
||||
while should_continue():
|
||||
chunk, _ = stream.read(frame_len)
|
||||
frame = np.asarray(chunk)[:, 0].copy()
|
||||
frame_rms = rms(frame)
|
||||
if not started:
|
||||
waited += 1
|
||||
if frame_rms >= rms_threshold:
|
||||
started = True
|
||||
frames.append(frame)
|
||||
elif waited > grace_frames:
|
||||
return None # woke it up but said nothing
|
||||
continue
|
||||
frames.append(frame)
|
||||
if frame_rms < rms_threshold:
|
||||
silence_frames += 1
|
||||
if silence_frames >= silence_limit:
|
||||
break
|
||||
else:
|
||||
silence_frames = 0
|
||||
if len(frames) >= max_frames:
|
||||
break
|
||||
|
||||
if not frames:
|
||||
return None
|
||||
pcm = np.concatenate(frames)
|
||||
if len(pcm) < min_utterance_s * sample_rate:
|
||||
return None
|
||||
return pcm
|
||||
|
||||
|
||||
def open_input_stream():
|
||||
"""Real sounddevice input stream, imported lazily so pure-logic tests
|
||||
(record_utterance with a fake stream) don't need PortAudio installed."""
|
||||
import sounddevice as sd
|
||||
|
||||
return sd.InputStream(
|
||||
samplerate=config.SAMPLE_RATE,
|
||||
channels=1,
|
||||
dtype="int16",
|
||||
blocksize=config.FRAME_LEN,
|
||||
device=config.MIC_DEVICE,
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Speech-to-text for the actual query, after the wake word fires.
|
||||
|
||||
Deepgram, same as desk_client/bolt_desk.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import requests
|
||||
|
||||
from .. import config
|
||||
from .mic import pcm_to_wav_bytes
|
||||
|
||||
|
||||
class SttError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def transcribe(pcm) -> str:
|
||||
if not config.DEEPGRAM_API_KEY:
|
||||
raise SttError("DEEPGRAM_API_KEY is not set")
|
||||
try:
|
||||
response = requests.post(
|
||||
"https://api.deepgram.com/v1/listen",
|
||||
params={"model": config.DEEPGRAM_MODEL, "language": "en", "smart_format": "true"},
|
||||
headers={
|
||||
"Authorization": f"Token {config.DEEPGRAM_API_KEY}",
|
||||
"Content-Type": "audio/wav",
|
||||
},
|
||||
data=pcm_to_wav_bytes(pcm),
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception as exc:
|
||||
raise SttError(f"transcription request failed: {exc}") from exc
|
||||
try:
|
||||
return (
|
||||
response.json()
|
||||
.get("results", {}).get("channels", [{}])[0]
|
||||
.get("alternatives", [{}])[0].get("transcript", "")
|
||||
).strip()
|
||||
except Exception as exc:
|
||||
raise SttError(f"couldn't parse transcription response: {exc}") from exc
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Text-to-speech: ElevenLabs, requested as raw PCM so playback is just
|
||||
sounddevice — no external player binary (mpv/ffplay), unlike
|
||||
desk_client/bolt_desk.py which shells out because it only targets Linux.
|
||||
|
||||
Falls back to pyttsx3 (offline, cross-platform: SAPI5 on Windows, NSSpeech
|
||||
on macOS, espeak on Linux) if ElevenLabs isn't configured or the request
|
||||
fails, so the pet can still talk with zero cloud config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Iterator
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
from .. import config, speech_text
|
||||
|
||||
|
||||
class TtsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def synthesize_pcm(text: str) -> tuple[np.ndarray, int]:
|
||||
"""Returns (pcm_int16_mono, sample_rate). Raises TtsError on failure —
|
||||
callers should fall back to speak_offline() rather than treating this
|
||||
as fatal."""
|
||||
if not (config.ELEVENLABS_API_KEY and config.ELEVENLABS_VOICE_ID):
|
||||
raise TtsError("ELEVENLABS_API_KEY / ELEVENLABS_VOICE_ID not set")
|
||||
try:
|
||||
response = requests.post(
|
||||
f"https://api.elevenlabs.io/v1/text-to-speech/{config.ELEVENLABS_VOICE_ID}",
|
||||
headers={"xi-api-key": config.ELEVENLABS_API_KEY},
|
||||
params={"output_format": f"pcm_{config.TTS_SAMPLE_RATE}"},
|
||||
json={"text": text, "model_id": config.ELEVENLABS_MODEL_ID},
|
||||
timeout=60,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception as exc:
|
||||
raise TtsError(f"ElevenLabs request failed: {exc}") from exc
|
||||
pcm = np.frombuffer(response.content, dtype=np.int16)
|
||||
if pcm.size == 0:
|
||||
raise TtsError("ElevenLabs returned no audio")
|
||||
return pcm, config.TTS_SAMPLE_RATE
|
||||
|
||||
|
||||
def stream_pcm(text: str, chunk_bytes: int = 4096) -> Iterator[np.ndarray]:
|
||||
"""Same audio as synthesize_pcm(), but yielded as it arrives from
|
||||
ElevenLabs' /stream endpoint so playback can start on the first chunk
|
||||
(~300ms) instead of after the whole clip is synthesized. Raises TtsError
|
||||
before yielding anything if the request itself fails, so callers can fall
|
||||
back cleanly; a mid-stream failure just ends the generator."""
|
||||
if not (config.ELEVENLABS_API_KEY and config.ELEVENLABS_VOICE_ID):
|
||||
raise TtsError("ELEVENLABS_API_KEY / ELEVENLABS_VOICE_ID not set")
|
||||
try:
|
||||
response = requests.post(
|
||||
f"https://api.elevenlabs.io/v1/text-to-speech/{config.ELEVENLABS_VOICE_ID}/stream",
|
||||
headers={"xi-api-key": config.ELEVENLABS_API_KEY},
|
||||
params={"output_format": f"pcm_{config.TTS_SAMPLE_RATE}"},
|
||||
json={"text": text, "model_id": config.ELEVENLABS_MODEL_ID},
|
||||
timeout=60,
|
||||
stream=True,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception as exc:
|
||||
raise TtsError(f"ElevenLabs stream request failed: {exc}") from exc
|
||||
return chunks_to_int16(response.iter_content(chunk_size=chunk_bytes))
|
||||
|
||||
|
||||
def chunks_to_int16(byte_chunks: Iterable[bytes]) -> Iterator[np.ndarray]:
|
||||
"""Reassemble a byte stream into int16 frames. HTTP chunk boundaries fall
|
||||
wherever they like, including *inside* a 16-bit sample, so a trailing odd
|
||||
byte has to be carried into the next chunk — otherwise every chunk after
|
||||
the first is shifted by one byte and plays as static."""
|
||||
carry = b""
|
||||
for chunk in byte_chunks:
|
||||
if not chunk:
|
||||
continue
|
||||
data = carry + chunk
|
||||
usable = len(data) - (len(data) % 2)
|
||||
carry = data[usable:]
|
||||
if usable:
|
||||
yield np.frombuffer(data[:usable], dtype=np.int16)
|
||||
|
||||
|
||||
def play_pcm(pcm: np.ndarray, sample_rate: int, blocking: bool = True, should_stop=None) -> bool:
|
||||
"""Play a whole clip. Returns True if it finished, False if *should_stop*
|
||||
(barge-in) cut it short. *should_stop* is polled while audio plays — each
|
||||
poll consumes one mic frame, which is what paces this loop."""
|
||||
import sounddevice as sd
|
||||
|
||||
sd.play(pcm, samplerate=sample_rate, device=config.SPEAKER_DEVICE)
|
||||
if not blocking:
|
||||
return True
|
||||
if should_stop is None:
|
||||
sd.wait()
|
||||
return True
|
||||
while True:
|
||||
try:
|
||||
if not sd.get_stream().active:
|
||||
break
|
||||
except Exception:
|
||||
break # stream already torn down — playback is over
|
||||
if should_stop():
|
||||
sd.stop()
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def play_stream(chunks: Iterable[np.ndarray], sample_rate: int, should_stop=None) -> bool:
|
||||
"""Play int16 chunks as they arrive. Returns False if interrupted."""
|
||||
import sounddevice as sd
|
||||
|
||||
with sd.OutputStream(
|
||||
samplerate=sample_rate, channels=1, dtype="int16", device=config.SPEAKER_DEVICE
|
||||
) as out:
|
||||
for chunk in chunks:
|
||||
if should_stop is not None and should_stop():
|
||||
# abort() rather than draining: barge-in should stop the voice
|
||||
# now, not at the end of the buffered chunk.
|
||||
out.abort()
|
||||
return False
|
||||
out.write(chunk)
|
||||
return True
|
||||
|
||||
|
||||
def speak_offline(text: str) -> None:
|
||||
try:
|
||||
import pyttsx3
|
||||
except ImportError:
|
||||
return # no TTS available at all — caller already logs the text
|
||||
engine = pyttsx3.init()
|
||||
engine.say(text)
|
||||
engine.runAndWait()
|
||||
|
||||
|
||||
def speak(text: str, on_error=None, should_stop=None) -> bool:
|
||||
"""Speak *text*, preferring streaming ElevenLabs, then whole-clip
|
||||
ElevenLabs, then offline TTS. *on_error*, if given, is called with the
|
||||
exception when ElevenLabs fails (useful for logging) — a fallback still
|
||||
runs either way. Returns False if barge-in interrupted playback.
|
||||
|
||||
The text is sanitized first (speech_text.for_speech): server replies are
|
||||
written for a chat window, and a voice reads markdown/emoji literally
|
||||
("asterisk asterisk"). Sanitizing here rather than at the call sites means
|
||||
every path to the speakers — reply, heartbeat announcement — is covered."""
|
||||
text = speech_text.for_speech(text)
|
||||
if not text:
|
||||
return True
|
||||
if config.TTS_STREAMING:
|
||||
try:
|
||||
return play_stream(stream_pcm(text), config.TTS_SAMPLE_RATE, should_stop=should_stop)
|
||||
except TtsError as exc:
|
||||
if on_error is not None:
|
||||
on_error(exc)
|
||||
try:
|
||||
pcm, sample_rate = synthesize_pcm(text)
|
||||
return play_pcm(pcm, sample_rate, should_stop=should_stop)
|
||||
except TtsError as exc:
|
||||
if on_error is not None:
|
||||
on_error(exc)
|
||||
speak_offline(text)
|
||||
return True
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Wake-word detection via a custom-trained openWakeWord model.
|
||||
|
||||
Uses `thunderbolt.onnx` — trained specifically for "thunderbolt", the same
|
||||
way the main repo's `desk_client/bolt_desk.py` uses `bolt.onnx` for "hey
|
||||
bolt". Same runtime (openWakeWord, ONNX inference), same per-frame
|
||||
predict()/reset() pattern; the only difference is the model file
|
||||
(WAKE_MODEL_FILE) and threshold (WAKE_WORD_THRESHOLD), both configurable via
|
||||
.env if a differently-trained model is swapped in later.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from typing import Callable, Optional, Protocol, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
class WakeModel(Protocol):
|
||||
def predict(self, frame: np.ndarray) -> dict: ...
|
||||
def reset(self) -> None: ...
|
||||
|
||||
|
||||
class NearMissLog:
|
||||
"""Rolling record of frames that *almost* fired the wake word.
|
||||
|
||||
WAKE_WORD_THRESHOLD is otherwise tuned by guessing at a number in .env
|
||||
and seeing whether the pet ignores you. Keeping the near misses (scores
|
||||
within WAKE_NEAR_MISS_MARGIN below the threshold) turns that into
|
||||
evidence: the tray's wake-word tuner shows what your actual "thunderbolt"
|
||||
scores, so you can set the threshold just under it.
|
||||
|
||||
Pure bookkeeping — the caller supplies timestamps, so it's testable.
|
||||
"""
|
||||
|
||||
def __init__(self, limit: int = None, margin: float = None):
|
||||
self._entries: deque[tuple[float, float, float]] = deque( # (timestamp, score, threshold)
|
||||
maxlen=max(1, config.WAKE_NEAR_MISS_LIMIT if limit is None else limit)
|
||||
)
|
||||
self._margin = config.WAKE_NEAR_MISS_MARGIN if margin is None else margin
|
||||
self._peak = 0.0
|
||||
|
||||
@property
|
||||
def peak(self) -> float:
|
||||
"""Highest score seen since the last reset — the "how close did I
|
||||
get?" readout while you test the wake phrase."""
|
||||
return self._peak
|
||||
|
||||
def observe(self, score: float, threshold: float, timestamp: float) -> bool:
|
||||
"""Record *score*; returns True if it counted as a near miss."""
|
||||
self._peak = max(self._peak, score)
|
||||
if score >= threshold or score < threshold - self._margin:
|
||||
return False
|
||||
self._entries.append((timestamp, score, threshold))
|
||||
return True
|
||||
|
||||
def entries(self) -> list[tuple[float, float, float]]:
|
||||
return list(self._entries)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._entries.clear()
|
||||
self._peak = 0.0
|
||||
|
||||
|
||||
def _construct_model(model_cls, model_path: str):
|
||||
"""openwakeword's Model() constructor keyword has drifted across
|
||||
releases (wakeword_models -> wakeword_model_paths) and some builds
|
||||
reject inference_framework entirely — the main repo's ai/wake_word.py
|
||||
hit the same drift and works around it the same way: try each known
|
||||
calling convention in turn."""
|
||||
attempts = [
|
||||
lambda: model_cls(wakeword_model_paths=[model_path], inference_framework="onnx"),
|
||||
lambda: model_cls(wakeword_model_paths=[model_path]),
|
||||
lambda: model_cls(wakeword_models=[model_path], inference_framework="onnx"),
|
||||
lambda: model_cls(wakeword_models=[model_path]),
|
||||
lambda: model_cls([model_path]),
|
||||
]
|
||||
last_exc: Optional[TypeError] = None
|
||||
for attempt in attempts:
|
||||
try:
|
||||
return attempt()
|
||||
except TypeError as exc:
|
||||
last_exc = exc
|
||||
raise RuntimeError(
|
||||
f"Could not construct openwakeword.Model with any known calling convention "
|
||||
f"(last error: {last_exc})"
|
||||
)
|
||||
|
||||
|
||||
class _OpenWakeWordModel:
|
||||
"""Lazily loads the ONNX model on first use so importing this module
|
||||
(and unit-testing listen_for_wake_word with a fake model) never requires
|
||||
onnxruntime/openwakeword or the model file to be present."""
|
||||
|
||||
def __init__(self):
|
||||
self._model = None
|
||||
|
||||
def _ensure_model(self):
|
||||
if self._model is None:
|
||||
from openwakeword.model import Model
|
||||
# from openwakeword.utils import download_models
|
||||
|
||||
# # The pip package doesn't bundle its melspectrogram/embedding
|
||||
# # feature-extraction sub-models — fetch them once on first use
|
||||
# # (no-op if already cached in openwakeword's own resources dir).
|
||||
# # A non-empty, non-matching model_names list keeps this from
|
||||
# # also pulling every official pretrained wakeword model.
|
||||
# download_models(model_names=["thunderbolt"])
|
||||
|
||||
self._model = _construct_model(Model, config.WAKE_MODEL_PATH)
|
||||
return self._model
|
||||
|
||||
def predict(self, frame: np.ndarray) -> dict:
|
||||
return self._ensure_model().predict(frame)
|
||||
|
||||
def reset(self) -> None:
|
||||
if self._model is not None:
|
||||
self._model.reset()
|
||||
|
||||
|
||||
_default_model = _OpenWakeWordModel()
|
||||
|
||||
|
||||
def listen_for_wake_word(
|
||||
stream,
|
||||
should_continue=lambda: True,
|
||||
model: Optional[WakeModel] = None,
|
||||
threshold: Union[float, Callable[[], float], None] = None,
|
||||
on_tick=None,
|
||||
on_score: Optional[Callable[[float, float], None]] = None,
|
||||
) -> bool:
|
||||
"""Block until the wake word fires (returns True) or *should_continue*
|
||||
goes false (returns False).
|
||||
|
||||
Feeds every frame to *model* (the thunderbolt openWakeWord model by
|
||||
default) and treats any class score >= *threshold* as a detection,
|
||||
resetting the model's internal state afterward so the next call starts
|
||||
clean — same pattern as desk_client/bolt_desk.py's main loop.
|
||||
|
||||
*threshold* may be a number or a zero-argument callable. The callable
|
||||
form exists because this function blocks for minutes at a time: the
|
||||
tray's wake-word tuner slider has to be able to change sensitivity
|
||||
*during* a listen, not only at the start of the next one.
|
||||
|
||||
*on_tick*, if given, is called once per ``WAKE_CHECK_INTERVAL_SECONDS``
|
||||
(not every frame — prediction is cheap enough to run on every frame, but
|
||||
this is the only point control returns to the caller while otherwise
|
||||
blocked here for a possibly long time, so it's how a caller drives
|
||||
periodic work, e.g. the heartbeat/announcement poll in controller.py,
|
||||
during quiet stretches with no wake word).
|
||||
|
||||
*on_score*, if given, gets ``(best_score, threshold)`` every frame — used
|
||||
to log near misses for threshold tuning.
|
||||
"""
|
||||
model = model or _default_model
|
||||
if threshold is None:
|
||||
threshold = config.WAKE_WORD_THRESHOLD
|
||||
resolve_threshold = threshold if callable(threshold) else (lambda: threshold)
|
||||
|
||||
frame_len = config.FRAME_LEN
|
||||
check_every_frames = max(1, int(config.WAKE_CHECK_INTERVAL_SECONDS * config.SAMPLE_RATE / frame_len))
|
||||
frames_since_tick = 0
|
||||
|
||||
while should_continue():
|
||||
chunk, _ = stream.read(frame_len)
|
||||
frame = np.asarray(chunk)[:, 0]
|
||||
scores = model.predict(frame)
|
||||
current_threshold = resolve_threshold()
|
||||
best = max(scores.values()) if scores else 0.0
|
||||
|
||||
frames_since_tick += 1
|
||||
if frames_since_tick >= check_every_frames:
|
||||
frames_since_tick = 0
|
||||
if on_tick is not None:
|
||||
on_tick()
|
||||
|
||||
if on_score is not None:
|
||||
on_score(best, current_threshold)
|
||||
|
||||
if scores and best >= current_threshold:
|
||||
model.reset()
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Environment configuration for the Bolt desktop pet.
|
||||
|
||||
Same lightweight ".env next to the script" pattern as desk_client/bolt_desk.py
|
||||
in the main tmn-api repo, so this project can be copied anywhere (it does not
|
||||
import anything from that repo) and configured the same way.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent.parent # project root (one above bolt_pet/)
|
||||
|
||||
|
||||
def _load_env() -> None:
|
||||
env_path = HERE / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
|
||||
_load_env()
|
||||
|
||||
|
||||
def _node_name() -> str:
|
||||
try:
|
||||
return platform.node() or "desktop"
|
||||
except Exception:
|
||||
return "desktop"
|
||||
|
||||
|
||||
# ── server / identity ───────────────────────────────────────────────────────
|
||||
|
||||
SERVER_URL = os.environ.get("BOLT_SERVER_URL", "").rstrip("/")
|
||||
API_KEY = os.environ.get("DESK_API_KEY", "")
|
||||
SESSION_ID = os.environ.get("DESK_SESSION_ID", "pet-" + _node_name())
|
||||
|
||||
# ── wake word ────────────────────────────────────────────────────────────────
|
||||
# openWakeWord model trained specifically for "thunderbolt" — same pattern as
|
||||
# desk_client/bolt_desk.py's WAKE_MODEL_FILE (bolt.onnx / "hey bolt") in the
|
||||
# main repo, resolved relative to the project root so it sits next to .env.
|
||||
|
||||
WAKE_MODEL_PATH = str(HERE / os.environ.get("WAKE_MODEL_FILE", "thunderbolt.onnx"))
|
||||
WAKE_WORD_THRESHOLD = float(os.environ.get("WAKE_WORD_THRESHOLD", "0.5"))
|
||||
# How often (independent of prediction, which runs every frame) the wake
|
||||
# listener yields control back to its caller via on_tick — e.g. the
|
||||
# heartbeat poll in controller.py during quiet stretches with no wake word.
|
||||
WAKE_CHECK_INTERVAL_SECONDS = float(os.environ.get("WAKE_CHECK_INTERVAL_SECONDS", "1.2"))
|
||||
|
||||
# ── STT (Deepgram, same as bolt_desk.py) ────────────────────────────────────
|
||||
|
||||
DEEPGRAM_API_KEY = os.environ.get("DEEPGRAM_API_KEY", "")
|
||||
DEEPGRAM_MODEL = os.environ.get("DEEPGRAM_MODEL", "nova-3")
|
||||
|
||||
# ── TTS (ElevenLabs, requested as raw PCM so playback needs no external
|
||||
# player binary — cross-platform via sounddevice instead of shelling out to
|
||||
# mpv/ffplay like bolt_desk.py does on Linux) ───────────────────────────────
|
||||
|
||||
ELEVENLABS_API_KEY = os.environ.get("ELEVENLABS_API_KEY", "")
|
||||
ELEVENLABS_VOICE_ID = os.environ.get("ELEVENLABS_VOICE_ID", "")
|
||||
ELEVENLABS_MODEL_ID = os.environ.get("ELEVENLABS_MODEL_ID", "eleven_flash_v2")
|
||||
# ElevenLabs PCM output formats are named pcm_<sample_rate>.
|
||||
TTS_SAMPLE_RATE = int(os.environ.get("TTS_SAMPLE_RATE", "24000"))
|
||||
|
||||
# ── mic / VAD (same tuning knobs as bolt_desk.py) ───────────────────────────
|
||||
|
||||
MIC_DEVICE = os.environ.get("MIC_DEVICE", "") or None # sounddevice name/index
|
||||
SPEAKER_DEVICE = os.environ.get("SPEAKER_DEVICE", "") or None
|
||||
|
||||
RMS_THRESHOLD = int(os.environ.get("VAD_RMS_THRESHOLD", "300"))
|
||||
SILENCE_END_SEC = float(os.environ.get("VAD_SILENCE_END_SEC", "1.2"))
|
||||
MAX_UTTERANCE_S = float(os.environ.get("VAD_MAX_UTTERANCE_SECONDS", "15"))
|
||||
MIN_UTTERANCE_S = float(os.environ.get("VAD_MIN_UTTERANCE_SECONDS", "0.4"))
|
||||
|
||||
COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "30"))
|
||||
HEARTBEAT_INTERVAL_SECONDS = float(os.environ.get("HEARTBEAT_INTERVAL_SECONDS", "60"))
|
||||
|
||||
# ── barge-in (interrupt playback by talking over it) ────────────────────────
|
||||
# The mic stays live while the pet talks; sustained loud frames cut playback
|
||||
# short. The threshold is deliberately well above VAD_RMS_THRESHOLD because
|
||||
# the mic also hears the pet's own voice through the speakers — raise it
|
||||
# further (or set BARGE_IN=false) if playback keeps interrupting itself.
|
||||
|
||||
BARGE_IN = os.environ.get("BARGE_IN", "true").lower() in ("1", "true", "yes", "on")
|
||||
BARGE_IN_RMS_THRESHOLD = int(os.environ.get("BARGE_IN_RMS_THRESHOLD", str(RMS_THRESHOLD * 4)))
|
||||
BARGE_IN_FRAMES = int(os.environ.get("BARGE_IN_FRAMES", "4")) # consecutive loud frames (80ms each)
|
||||
|
||||
# ── streaming TTS ───────────────────────────────────────────────────────────
|
||||
# ElevenLabs' /stream endpoint + chunked playback: the pet starts talking
|
||||
# after the first PCM chunk instead of after the whole clip is synthesized.
|
||||
|
||||
TTS_STREAMING = os.environ.get("TTS_STREAMING", "true").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
# ── screen context ──────────────────────────────────────────────────────────
|
||||
# Appends the active window's title to what you say, so "what's this error?"
|
||||
# has a referent. The desk API takes text only, so this is a text annotation
|
||||
# (no screenshot upload).
|
||||
|
||||
SCREEN_CONTEXT = os.environ.get("SCREEN_CONTEXT", "true").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
# ── quiet hours / do-not-disturb ────────────────────────────────────────────
|
||||
# Comma-separated HH:MM-HH:MM ranges (wrapping midnight is fine). While
|
||||
# napping the pet dims, stops wandering, and makes no proactive noise —
|
||||
# wake word and click-to-talk still work.
|
||||
|
||||
QUIET_HOURS = os.environ.get("QUIET_HOURS", "")
|
||||
DND_ON_FULLSCREEN = os.environ.get("DND_ON_FULLSCREEN", "true").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
# ── desktop notification bridge ─────────────────────────────────────────────
|
||||
# Mirrors desktop notifications to the server so Bolt can react to them.
|
||||
# Off by default: every forwarded notification costs a converse() round trip.
|
||||
|
||||
NOTIFICATION_BRIDGE = os.environ.get("NOTIFICATION_BRIDGE", "false").lower() in ("1", "true", "yes", "on")
|
||||
# Regex matched against "<app>: <summary> <body>"; empty means "everything".
|
||||
NOTIFICATION_FILTER = os.environ.get("NOTIFICATION_FILTER", "")
|
||||
NOTIFICATION_MIN_INTERVAL_SECONDS = float(os.environ.get("NOTIFICATION_MIN_INTERVAL_SECONDS", "60"))
|
||||
|
||||
# ── conversation history ────────────────────────────────────────────────────
|
||||
|
||||
HISTORY_LIMIT = int(os.environ.get("HISTORY_LIMIT", "100"))
|
||||
|
||||
# ── push-to-talk ────────────────────────────────────────────────────────────
|
||||
# Global hotkey (needs `pynput`; unavailable on most Wayland sessions, in
|
||||
# which case it logs once and the wake word / tray still work). Empty to
|
||||
# disable.
|
||||
|
||||
PUSH_TO_TALK_HOTKEY = os.environ.get("PUSH_TO_TALK_HOTKEY", "ctrl+alt+space")
|
||||
|
||||
# ── wake-word tuning ────────────────────────────────────────────────────────
|
||||
# Scores this far below the threshold are recorded as "near misses" and shown
|
||||
# in the tray's wake-word tuner, so the threshold can be set from evidence.
|
||||
|
||||
WAKE_NEAR_MISS_MARGIN = float(os.environ.get("WAKE_NEAR_MISS_MARGIN", "0.2"))
|
||||
WAKE_NEAR_MISS_LIMIT = int(os.environ.get("WAKE_NEAR_MISS_LIMIT", "40"))
|
||||
|
||||
SAMPLE_RATE = 16000 # mic capture / STT rate
|
||||
FRAME_LEN = 1280 # 80ms @ 16kHz — matches bolt_desk.py's chunking
|
||||
|
||||
# ── pet window ───────────────────────────────────────────────────────────────
|
||||
|
||||
PET_SIZE = int(os.environ.get("PET_SIZE", "160")) # on-screen pixel size (square)
|
||||
PET_START_X = os.environ.get("PET_START_X", "") # blank = bottom-right of primary screen
|
||||
PET_START_Y = os.environ.get("PET_START_Y", "")
|
||||
PET_ALWAYS_ON_TOP = os.environ.get("PET_ALWAYS_ON_TOP", "true").lower() in ("1", "true", "yes", "on")
|
||||
IDLE_ANIMATION_FPS = float(os.environ.get("IDLE_ANIMATION_FPS", "6"))
|
||||
|
||||
# ── wandering ────────────────────────────────────────────────────────────────
|
||||
# The pet strolls to a random spot on its own while idle. Only ever moves when
|
||||
# it's IDLE and not speaking/being dragged, so it never walks out from under a
|
||||
# speech bubble mid-sentence.
|
||||
|
||||
PET_WANDER = os.environ.get("PET_WANDER", "true").lower() in ("1", "true", "yes", "on")
|
||||
# Average seconds of standing still between strolls (each wait is randomized
|
||||
# to 0.5x–1.5x this, so the pet doesn't move on an obvious metronome).
|
||||
PET_WANDER_INTERVAL_SECONDS = float(os.environ.get("PET_WANDER_INTERVAL_SECONDS", "45"))
|
||||
PET_WANDER_SPEED = float(os.environ.get("PET_WANDER_SPEED", "90")) # pixels/second
|
||||
# Cap on how far one stroll can be, so it doesn't teleport across a 4K screen.
|
||||
PET_WANDER_MAX_DISTANCE = float(os.environ.get("PET_WANDER_MAX_DISTANCE", "600"))
|
||||
PET_WANDER_MARGIN = int(os.environ.get("PET_WANDER_MARGIN", "20")) # keep off screen edges
|
||||
|
||||
# ── mouse behaviour ─────────────────────────────────────────────────────────
|
||||
# The sprite is drawn in a square translucent window, so its transparent
|
||||
# corners would otherwise swallow clicks meant for whatever is underneath.
|
||||
# PET_SHAPED_INPUT masks the window's input region to the sprite's own opaque
|
||||
# pixels; PET_CLICK_THROUGH goes further and makes the whole pet ignore the
|
||||
# mouse (tray-only control until you turn it back off).
|
||||
|
||||
PET_SHAPED_INPUT = os.environ.get("PET_SHAPED_INPUT", "true").lower() in ("1", "true", "yes", "on")
|
||||
PET_CLICK_THROUGH = os.environ.get("PET_CLICK_THROUGH", "false").lower() in ("1", "true", "yes", "on")
|
||||
# Snap flush to a screen edge when dropped/parked within this many pixels of it.
|
||||
PET_EDGE_SNAP = os.environ.get("PET_EDGE_SNAP", "true").lower() in ("1", "true", "yes", "on")
|
||||
PET_SNAP_MARGIN = int(os.environ.get("PET_SNAP_MARGIN", "48"))
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
return bool(SERVER_URL and API_KEY)
|
||||
|
||||
|
||||
def missing_config() -> list[str]:
|
||||
missing = []
|
||||
if not SERVER_URL:
|
||||
missing.append("BOLT_SERVER_URL")
|
||||
if not API_KEY:
|
||||
missing.append("DESK_API_KEY")
|
||||
return missing
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Orchestrates the pet's mic -> wake word -> STT -> server -> TTS pipeline.
|
||||
|
||||
Runs on a background QThread (see ui/app.py) so the Qt event loop / window
|
||||
painting is never blocked by audio I/O or network calls. Talks to the UI
|
||||
only through Qt signals (state_changed / said / log / action / napping),
|
||||
which Qt marshals safely across threads — this class never touches a QWidget
|
||||
directly.
|
||||
|
||||
Beyond the core loop it owns the side channels that let the pet act on its
|
||||
own: the heartbeat (proactive announcements), the desktop notification
|
||||
bridge, quiet hours, barge-in, and the live wake-word threshold.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
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 .audio import barge_in, mic, stt, tts, wake_word
|
||||
from .state import PetState, PetStateMachine
|
||||
|
||||
# How often to re-check whether the pet should be napping. The fullscreen
|
||||
# probe shells out to xprop, so this deliberately isn't every heartbeat tick.
|
||||
_NAP_CHECK_INTERVAL_SECONDS = 10.0
|
||||
|
||||
|
||||
class PetController(QObject):
|
||||
state_changed = Signal(str) # PetState.value
|
||||
said = Signal(str) # text now showing in the speech bubble
|
||||
log = Signal(str)
|
||||
action = Signal(dict) # parsed petctl action for the UI to perform
|
||||
napping = Signal(bool) # quiet hours / fullscreen do-not-disturb
|
||||
finished = Signal()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._running = True
|
||||
self._muted = False
|
||||
self._talk_now = threading.Event()
|
||||
self._stream = None
|
||||
self._last_heartbeat = 0.0
|
||||
self._state = PetStateMachine(on_change=self._handle_state_change)
|
||||
|
||||
# Conversation scrollback, shared read-only with the UI's History
|
||||
# window. Append-only from this thread; the UI only ever snapshots it.
|
||||
self.history = history_mod.ConversationHistory(limit=config.HISTORY_LIMIT)
|
||||
|
||||
# Wake-word sensitivity is live-tunable (tray tuner), so it's read
|
||||
# through a callable on every frame rather than captured per listen.
|
||||
self._wake_threshold = config.WAKE_WORD_THRESHOLD
|
||||
self._near_misses = wake_word.NearMissLog()
|
||||
|
||||
self._barge_in: Optional[barge_in.BargeInDetector] = None
|
||||
self._napping = False
|
||||
self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule
|
||||
self._last_nap_check = 0.0
|
||||
|
||||
self._notification_watcher: Optional[notifications.NotificationWatcher] = None
|
||||
self._notification_gate = notifications.NotificationGate(
|
||||
config.NOTIFICATION_FILTER, config.NOTIFICATION_MIN_INTERVAL_SECONDS
|
||||
)
|
||||
self._pending_notifications: list[notifications.Notification] = []
|
||||
self._notification_lock = threading.Lock()
|
||||
|
||||
# ── external controls (safe to call from the Qt/UI thread) ─────────
|
||||
|
||||
def request_talk_now(self) -> None:
|
||||
self._talk_now.set()
|
||||
|
||||
def toggle_mute(self) -> bool:
|
||||
self._muted = not self._muted
|
||||
self.log.emit("Muted." if self._muted else "Unmuted.")
|
||||
return self._muted
|
||||
|
||||
def set_napping(self, napping: Optional[bool]) -> None:
|
||||
"""Force the nap state on/off, or pass None to hand control back to
|
||||
the quiet-hours schedule."""
|
||||
self._nap_forced = napping
|
||||
if napping is not None:
|
||||
self._apply_nap_state(napping)
|
||||
|
||||
def wake_threshold(self) -> float:
|
||||
return self._wake_threshold
|
||||
|
||||
def set_wake_threshold(self, value: float) -> None:
|
||||
self._wake_threshold = min(max(float(value), 0.01), 0.99)
|
||||
|
||||
def wake_stats(self) -> dict:
|
||||
return {"peak": self._near_misses.peak, "near_misses": self._near_misses.entries()}
|
||||
|
||||
def reset_wake_stats(self) -> None:
|
||||
self._near_misses.clear()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
self._talk_now.set() # wake up anything blocked waiting on it
|
||||
if self._notification_watcher is not None:
|
||||
self._notification_watcher.stop()
|
||||
|
||||
# ── internal ─────────────────────────────────────────────────────────
|
||||
|
||||
def _handle_state_change(self, _old: PetState, new: PetState) -> None:
|
||||
self.state_changed.emit(new.value)
|
||||
|
||||
def _should_continue(self) -> bool:
|
||||
return self._running
|
||||
|
||||
def run(self) -> None:
|
||||
"""Thread entry point (connected to QThread.started)."""
|
||||
missing = config.missing_config()
|
||||
if missing:
|
||||
self.log.emit(f"Missing config: {', '.join(missing)} — set them in .env and restart.")
|
||||
self.finished.emit()
|
||||
return
|
||||
try:
|
||||
self._stream = mic.open_input_stream()
|
||||
except Exception as exc:
|
||||
self.log.emit(f"Could not open microphone: {exc}")
|
||||
self.finished.emit()
|
||||
return
|
||||
|
||||
if config.BARGE_IN:
|
||||
self._barge_in = barge_in.BargeInDetector(self._stream)
|
||||
|
||||
with self._stream:
|
||||
try:
|
||||
health = server_client.check_health()
|
||||
self.log.emit(f"Connected to server: {health}")
|
||||
except Exception as exc:
|
||||
self.log.emit(f"Server not reachable yet ({exc}) — will keep trying per-request.")
|
||||
self._start_notification_bridge()
|
||||
self._loop()
|
||||
if self._notification_watcher is not None:
|
||||
self._notification_watcher.stop()
|
||||
self.finished.emit()
|
||||
|
||||
def _loop(self) -> None:
|
||||
while self._running:
|
||||
if self._muted:
|
||||
triggered = self._talk_now.wait(timeout=0.5)
|
||||
if not self._running:
|
||||
return
|
||||
if not triggered:
|
||||
continue
|
||||
self._talk_now.clear()
|
||||
else:
|
||||
if not self._wait_for_wake_or_click():
|
||||
if not self._running:
|
||||
return
|
||||
continue
|
||||
self._handle_conversation_turn()
|
||||
|
||||
def _wait_for_wake_or_click(self) -> bool:
|
||||
"""True once either the wake phrase was heard or a click-to-talk
|
||||
request came in; False on a spurious wakeup (loop again)."""
|
||||
def should_continue() -> bool:
|
||||
return self._running and not self._talk_now.is_set()
|
||||
|
||||
detected = wake_word.listen_for_wake_word(
|
||||
self._stream,
|
||||
should_continue=should_continue,
|
||||
threshold=self.wake_threshold, # callable: the tuner slider is live
|
||||
on_tick=self._maybe_heartbeat,
|
||||
on_score=self._observe_wake_score,
|
||||
)
|
||||
if not self._running:
|
||||
return False
|
||||
if detected:
|
||||
self._talk_now.clear() # in case both fired around the same time
|
||||
return True
|
||||
if self._talk_now.is_set():
|
||||
self._talk_now.clear()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _observe_wake_score(self, score: float, threshold: float) -> None:
|
||||
self._near_misses.observe(score, threshold, time.time())
|
||||
|
||||
def _handle_conversation_turn(self) -> None:
|
||||
self._state.transition(PetState.LISTENING)
|
||||
pcm = mic.record_utterance(self._stream, should_continue=self._should_continue)
|
||||
if pcm is None:
|
||||
self._state.transition(PetState.IDLE)
|
||||
return
|
||||
|
||||
self._state.transition(PetState.THINKING)
|
||||
try:
|
||||
text = stt.transcribe(pcm)
|
||||
except stt.SttError as exc:
|
||||
self.log.emit(f"STT failed: {exc}")
|
||||
self._state.transition(PetState.ERROR)
|
||||
self._state.transition(PetState.IDLE)
|
||||
return
|
||||
if not text:
|
||||
self._state.transition(PetState.IDLE)
|
||||
return
|
||||
self.log.emit(f"You: {text}")
|
||||
self.history.add(history_mod.USER, text, time.time())
|
||||
|
||||
try:
|
||||
# What's focused right now rides along, so "what's this error?"
|
||||
# has a referent without you having to describe the window.
|
||||
reply = server_client.converse(
|
||||
screen_context.context_for(text), on_command=self._handle_command
|
||||
)
|
||||
except server_client.ServerError as exc:
|
||||
self.log.emit(f"Server error: {exc}")
|
||||
self._state.transition(PetState.ERROR)
|
||||
self._state.transition(PetState.IDLE)
|
||||
return
|
||||
|
||||
self._speak(reply)
|
||||
self._state.transition(PetState.IDLE)
|
||||
|
||||
def _handle_command(self, command: str) -> str:
|
||||
"""Server-relayed command. `petctl ...` drives the pet's body and
|
||||
never reaches a shell; everything else is a real command, exactly as
|
||||
before (see the security notes in the README)."""
|
||||
try:
|
||||
action = pet_actions.parse(command)
|
||||
except pet_actions.ActionError as exc:
|
||||
self.log.emit(f"petctl: {exc}")
|
||||
return f"[pet] {exc}"
|
||||
if action is None:
|
||||
return server_client.run_local_command(command)
|
||||
self.log.emit(f"Pet action: {action}")
|
||||
if action["action"] == "nap":
|
||||
self.set_napping(bool(action["enabled"]))
|
||||
self.action.emit(action)
|
||||
return pet_actions.describe(action)
|
||||
|
||||
def _speak(self, text: str) -> None:
|
||||
self._state.transition(PetState.TALKING)
|
||||
# Bubble gets the markdown stripped but emoji kept (it can't render
|
||||
# **bold** but draws emoji fine); tts.speak() does its own, stricter
|
||||
# sanitizing for the voice.
|
||||
self.said.emit(speech_text.for_display(text))
|
||||
self.log.emit(f"Bolt: {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.speak(
|
||||
text,
|
||||
on_error=lambda exc: self.log.emit(f"TTS failed: {exc}"),
|
||||
should_stop=should_stop,
|
||||
)
|
||||
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._talk_now.set()
|
||||
|
||||
# ── quiet hours / do-not-disturb ─────────────────────────────────────
|
||||
|
||||
def _apply_nap_state(self, napping: bool) -> None:
|
||||
if napping == self._napping:
|
||||
return
|
||||
self._napping = napping
|
||||
self.log.emit("Napping — no proactive noise." if napping else "Awake.")
|
||||
self.napping.emit(napping)
|
||||
|
||||
def _refresh_nap_state(self) -> None:
|
||||
if self._nap_forced is not None:
|
||||
self._apply_nap_state(self._nap_forced)
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now - self._last_nap_check < _NAP_CHECK_INTERVAL_SECONDS:
|
||||
return
|
||||
self._last_nap_check = now
|
||||
napping = quiet.is_quiet(
|
||||
config.QUIET_HOURS,
|
||||
on_error=lambda exc: self.log.emit(f"QUIET_HOURS is malformed ({exc}) — ignoring it."),
|
||||
)
|
||||
if not napping and config.DND_ON_FULLSCREEN:
|
||||
napping = screen_context.is_fullscreen_active()
|
||||
self._apply_nap_state(napping)
|
||||
|
||||
# ── desktop notification bridge ──────────────────────────────────────
|
||||
|
||||
def _start_notification_bridge(self) -> None:
|
||||
if not config.NOTIFICATION_BRIDGE:
|
||||
return
|
||||
watcher = notifications.NotificationWatcher(self._queue_notification)
|
||||
problem = watcher.start()
|
||||
if problem:
|
||||
self.log.emit(problem)
|
||||
return
|
||||
self._notification_watcher = watcher
|
||||
self.log.emit("Notification bridge on.")
|
||||
|
||||
def _queue_notification(self, notification: notifications.Notification) -> None:
|
||||
"""Called on the watcher thread — just queue it; forwarding happens on
|
||||
the pipeline thread where it can't collide with a live conversation."""
|
||||
if not self._notification_gate.should_forward(notification, time.monotonic()):
|
||||
return
|
||||
with self._notification_lock:
|
||||
self._pending_notifications.append(notification)
|
||||
|
||||
def _drain_notifications(self) -> None:
|
||||
with self._notification_lock:
|
||||
pending, self._pending_notifications = self._pending_notifications, []
|
||||
for notification in pending:
|
||||
if not self._running or self._napping:
|
||||
return
|
||||
self.log.emit(f"Notification: {notification.as_text()}")
|
||||
self.history.add(history_mod.SYSTEM, notification.as_text(), time.time())
|
||||
try:
|
||||
reply = server_client.converse(
|
||||
f"[desktop notification] {notification.as_text()}",
|
||||
on_command=self._handle_command,
|
||||
)
|
||||
except server_client.ServerError as exc:
|
||||
self.log.emit(f"Couldn't forward notification: {exc}")
|
||||
return
|
||||
if reply.strip():
|
||||
self._speak(reply)
|
||||
self._state.transition(PetState.IDLE)
|
||||
|
||||
# ── heartbeat ────────────────────────────────────────────────────────
|
||||
|
||||
def _maybe_heartbeat(self) -> None:
|
||||
self._refresh_nap_state()
|
||||
now = time.monotonic()
|
||||
if now - self._last_heartbeat < config.HEARTBEAT_INTERVAL_SECONDS:
|
||||
return
|
||||
self._last_heartbeat = now
|
||||
if self._state.state != PetState.IDLE:
|
||||
return
|
||||
if self._napping:
|
||||
return # quiet hours: still answers when spoken to, just doesn't start
|
||||
self._drain_notifications()
|
||||
if self._state.state != PetState.IDLE:
|
||||
return
|
||||
try:
|
||||
announcement = server_client.report_status()
|
||||
except server_client.ServerError as exc:
|
||||
self.log.emit(f"Heartbeat failed: {exc}")
|
||||
return
|
||||
if announcement:
|
||||
self._speak(announcement)
|
||||
self._state.transition(PetState.IDLE)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Rolling transcript of the conversation.
|
||||
|
||||
The speech bubble hides itself after a few seconds, which is fine for chat
|
||||
but bad for anything you actually needed to read (a command's output, a
|
||||
number, a URL). This keeps the last HISTORY_LIMIT turns so the tray's
|
||||
History window — and click-to-copy on the bubble — have something to show.
|
||||
|
||||
Pure logic, no Qt: the UI half is ui/history_window.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Optional
|
||||
|
||||
USER = "you"
|
||||
PET = "bolt"
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Entry:
|
||||
role: str
|
||||
text: str
|
||||
timestamp: Optional[float] = None # time.time(); None when not recorded
|
||||
|
||||
def formatted(self, clock=None) -> str:
|
||||
label = {USER: "You", PET: "Bolt", SYSTEM: "—"}.get(self.role, self.role)
|
||||
stamp = clock(self.timestamp) if (clock and self.timestamp) else None
|
||||
return f"[{stamp}] {label}: {self.text}" if stamp else f"{label}: {self.text}"
|
||||
|
||||
|
||||
class ConversationHistory:
|
||||
def __init__(self, limit: int = 100):
|
||||
self._entries: deque[Entry] = deque(maxlen=max(1, limit))
|
||||
|
||||
def add(self, role: str, text: str, timestamp: Optional[float] = None) -> Optional[Entry]:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
entry = Entry(role=role, text=text, timestamp=timestamp)
|
||||
self._entries.append(entry)
|
||||
return entry
|
||||
|
||||
def entries(self) -> list[Entry]:
|
||||
return list(self._entries)
|
||||
|
||||
def last(self, role: Optional[str] = None) -> Optional[Entry]:
|
||||
for entry in reversed(self._entries):
|
||||
if role is None or entry.role == role:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def clear(self) -> None:
|
||||
self._entries.clear()
|
||||
|
||||
def as_text(self, clock=None, entries: Optional[Iterable[Entry]] = None) -> str:
|
||||
return "\n".join(e.formatted(clock) for e in (entries if entries is not None else self._entries))
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._entries)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Global push-to-talk hotkey.
|
||||
|
||||
The wake word is the primary trigger, but it misfires in a noisy room and
|
||||
won't fire at all if you're on a call — so there's a keyboard fallback that
|
||||
works even when the pet has no focus (it's a frameless Qt.Tool window with no
|
||||
taskbar entry, so an ordinary QShortcut would never see the key).
|
||||
|
||||
Needs `pynput`, which needs an X11/Win32/macOS input hook: on most Wayland
|
||||
sessions it can't grab global keys, and on macOS it needs Accessibility
|
||||
permission. All of that is a soft failure — start() reports the reason and
|
||||
the wake word / tray keep working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
# Aliases for the names people actually type in a .env file.
|
||||
_ALIASES = {
|
||||
"control": "ctrl",
|
||||
"ctl": "ctrl",
|
||||
"option": "alt",
|
||||
"opt": "alt",
|
||||
"win": "cmd",
|
||||
"windows": "cmd",
|
||||
"super": "cmd",
|
||||
"meta": "cmd",
|
||||
"command": "cmd",
|
||||
"return": "enter",
|
||||
"escape": "esc",
|
||||
"del": "delete",
|
||||
"ins": "insert",
|
||||
"pgup": "page_up",
|
||||
"pgdn": "page_down",
|
||||
}
|
||||
|
||||
|
||||
class HotkeyError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def to_pynput_spec(spec: str) -> str:
|
||||
""""ctrl+alt+space" -> "<ctrl>+<alt>+<space>" (pynput's GlobalHotKeys
|
||||
syntax: named keys in angle brackets, literal characters bare)."""
|
||||
tokens = [t.strip().lower() for t in (spec or "").split("+")]
|
||||
tokens = [t for t in tokens if t]
|
||||
if not tokens:
|
||||
raise HotkeyError("empty hotkey")
|
||||
parts = []
|
||||
for token in tokens:
|
||||
token = _ALIASES.get(token, token)
|
||||
parts.append(token if len(token) == 1 else f"<{token}>")
|
||||
return "+".join(parts)
|
||||
|
||||
|
||||
class GlobalHotkey:
|
||||
"""Fires *callback* whenever the hotkey is pressed, anywhere. Safe to
|
||||
construct unconditionally — nothing happens until start(), and start()
|
||||
reports failure instead of raising into the UI thread."""
|
||||
|
||||
def __init__(self, spec: str, callback: Callable[[], None]):
|
||||
self.spec = spec
|
||||
self._callback = callback
|
||||
self._listener = None
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._listener is not None
|
||||
|
||||
def start(self) -> Optional[str]:
|
||||
"""None on success, otherwise a human-readable reason it's off."""
|
||||
if not (self.spec or "").strip():
|
||||
return None # explicitly disabled — not an error worth reporting
|
||||
try:
|
||||
pynput_spec = to_pynput_spec(self.spec)
|
||||
except HotkeyError as exc:
|
||||
return f"push-to-talk hotkey {self.spec!r} is invalid: {exc}"
|
||||
try:
|
||||
from pynput import keyboard
|
||||
except Exception as exc: # ImportError, or a backend that won't load
|
||||
return f"push-to-talk needs pynput ({exc}) — wake word still works"
|
||||
try:
|
||||
listener = keyboard.GlobalHotKeys({pynput_spec: self._safe_callback})
|
||||
listener.daemon = True
|
||||
listener.start()
|
||||
except Exception as exc:
|
||||
return f"push-to-talk unavailable on this session ({exc}) — wake word still works"
|
||||
self._listener = listener
|
||||
return None
|
||||
|
||||
def _safe_callback(self) -> None:
|
||||
# This runs on pynput's listener thread; an exception there would
|
||||
# silently kill the listener for the rest of the session.
|
||||
try:
|
||||
self._callback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._listener is not None:
|
||||
try:
|
||||
self._listener.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._listener = None
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Desktop notification bridge (Linux/D-Bus).
|
||||
|
||||
Lets Bolt react to things that happen without you: a build finishing, a
|
||||
calendar alert, a message arriving. Notifications are tailed from
|
||||
`dbus-monitor`, filtered, rate-limited, and handed to the controller, which
|
||||
forwards them through the normal converse() path — so the pet can say
|
||||
"your deploy just went green" instead of only ever answering questions.
|
||||
|
||||
Off by default (NOTIFICATION_BRIDGE): every forwarded notification is a
|
||||
round trip to the server, and an unfiltered desktop can be very chatty.
|
||||
NOTIFICATION_FILTER (a regex) is the main knob for keeping it useful.
|
||||
|
||||
The dbus-monitor *parsing* is pure and unit tested; only the subprocess
|
||||
plumbing needs a real session bus.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Iterable, Iterator, Optional
|
||||
|
||||
_STRING_LINE = re.compile(r'^\s*string\s+"(.*)"\s*$')
|
||||
_BLOCK_START = re.compile(r"^(method call|signal|method return|error)\b")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Notification:
|
||||
app: str
|
||||
summary: str
|
||||
body: str
|
||||
|
||||
def as_text(self) -> str:
|
||||
parts = [p for p in (self.summary, self.body) if p]
|
||||
joined = " — ".join(parts)
|
||||
return f"{self.app}: {joined}" if self.app else joined
|
||||
|
||||
|
||||
def iter_notifications(lines: Iterable[str]) -> Iterator[Notification]:
|
||||
"""Pull Notification records out of a `dbus-monitor` line stream.
|
||||
|
||||
A Notify call prints its arguments one per line after the header; the
|
||||
string arguments arrive in the order app_name, app_icon, summary, body
|
||||
(replaces_id is a uint32, so it isn't in the string list). Anything
|
||||
that doesn't look like that is skipped rather than guessed at.
|
||||
"""
|
||||
collecting = False
|
||||
strings: list[str] = []
|
||||
|
||||
def _emit() -> Optional[Notification]:
|
||||
if len(strings) < 3:
|
||||
return None
|
||||
return Notification(app=strings[0].strip(), summary=strings[2].strip(),
|
||||
body=(strings[3].strip() if len(strings) > 3 else ""))
|
||||
|
||||
for line in lines:
|
||||
if _BLOCK_START.match(line):
|
||||
if collecting:
|
||||
notification = _emit()
|
||||
if notification is not None:
|
||||
yield notification
|
||||
collecting = "member=Notify" in line
|
||||
strings = []
|
||||
continue
|
||||
if not collecting:
|
||||
continue
|
||||
match = _STRING_LINE.match(line)
|
||||
if match:
|
||||
strings.append(match.group(1))
|
||||
if collecting:
|
||||
notification = _emit()
|
||||
if notification is not None:
|
||||
yield notification
|
||||
|
||||
|
||||
class NotificationGate:
|
||||
"""Filter + rate limit. Clock is passed in (monotonic seconds) so the
|
||||
rate limiting is testable without sleeping."""
|
||||
|
||||
def __init__(self, pattern: str = "", min_interval: float = 60.0):
|
||||
self._min_interval = max(0.0, min_interval)
|
||||
self._last_forwarded = None
|
||||
self._pattern = None
|
||||
if (pattern or "").strip():
|
||||
try:
|
||||
self._pattern = re.compile(pattern, re.IGNORECASE)
|
||||
except re.error:
|
||||
self._pattern = None # a broken regex shouldn't mute everything
|
||||
|
||||
def matches(self, notification: Notification) -> bool:
|
||||
if self._pattern is None:
|
||||
return True
|
||||
return bool(self._pattern.search(notification.as_text()))
|
||||
|
||||
def should_forward(self, notification: Notification, now: float) -> bool:
|
||||
if not notification.as_text().strip():
|
||||
return False
|
||||
if not self.matches(notification):
|
||||
return False
|
||||
if self._last_forwarded is not None and now - self._last_forwarded < self._min_interval:
|
||||
return False
|
||||
self._last_forwarded = now
|
||||
return True
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
return platform.system() == "Linux" and shutil.which("dbus-monitor") is not None
|
||||
|
||||
|
||||
class NotificationWatcher:
|
||||
"""Tails dbus-monitor on a daemon thread, calling *callback* per
|
||||
notification. Best-effort: if the session bus isn't reachable it reports
|
||||
why via start() and stays off."""
|
||||
|
||||
_ARGS = [
|
||||
"dbus-monitor", "--session",
|
||||
"interface='org.freedesktop.Notifications',member='Notify'",
|
||||
]
|
||||
|
||||
def __init__(self, callback: Callable[[Notification], None]):
|
||||
self._callback = callback
|
||||
self._process: Optional[subprocess.Popen] = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._running = False
|
||||
|
||||
def start(self) -> Optional[str]:
|
||||
"""None on success, otherwise the reason the bridge is off."""
|
||||
if not available():
|
||||
return "notification bridge needs Linux + dbus-monitor — skipping"
|
||||
try:
|
||||
self._process = subprocess.Popen(
|
||||
self._ARGS, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
text=True, bufsize=1,
|
||||
)
|
||||
except Exception as exc:
|
||||
return f"couldn't start dbus-monitor ({exc}) — notification bridge off"
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._pump, name="notification-bridge", daemon=True)
|
||||
self._thread.start()
|
||||
return None
|
||||
|
||||
def _pump(self) -> None:
|
||||
assert self._process is not None and self._process.stdout is not None
|
||||
try:
|
||||
for notification in iter_notifications(self._process.stdout):
|
||||
if not self._running:
|
||||
return
|
||||
try:
|
||||
self._callback(notification)
|
||||
except Exception:
|
||||
pass # one bad notification shouldn't end the bridge
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._process is not None:
|
||||
try:
|
||||
self._process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
self._process = None
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Commands that drive the pet's *body* instead of the shell.
|
||||
|
||||
The server relays shell commands to this machine (see server_client.
|
||||
run_local_command). Rather than inventing a new payload type the desk API
|
||||
doesn't speak — this client can't change the server — a small `petctl`
|
||||
pseudo-command is intercepted before it ever reaches `subprocess`: if Bolt
|
||||
emits `petctl move top-left` or `petctl emote wave`, the pet does it and
|
||||
returns a normal-looking command output string, so from the server's side
|
||||
it's just another tool call that worked.
|
||||
|
||||
Pure parsing logic — no Qt, no subprocess — so it's cheap to unit test. The
|
||||
UI half lives in ui/pet_window.py (apply_action).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
from typing import Optional
|
||||
|
||||
# What Bolt is allowed to type. Anything else falls through to a real shell.
|
||||
_PREFIXES = ("petctl", "bolt-pet", "pet")
|
||||
|
||||
ANCHORS = (
|
||||
"top-left", "top", "top-right",
|
||||
"left", "center", "right",
|
||||
"bottom-left", "bottom", "bottom-right",
|
||||
"cursor", "random",
|
||||
)
|
||||
|
||||
EMOTES = ("wave", "hop", "spin", "nod", "shake", "bounce", "wiggle")
|
||||
|
||||
HELP = (
|
||||
"petctl move <x> <y> | <" + "|".join(ANCHORS) + ">\n"
|
||||
"petctl emote <" + "|".join(EMOTES) + ">\n"
|
||||
"petctl say <text>\n"
|
||||
"petctl wander on|off\n"
|
||||
"petctl nap on|off"
|
||||
)
|
||||
|
||||
|
||||
class ActionError(Exception):
|
||||
"""Bad petctl syntax — reported back to the server as command output."""
|
||||
|
||||
|
||||
def is_pet_command(command: str) -> bool:
|
||||
parts = (command or "").strip().split()
|
||||
return bool(parts) and parts[0].lower() in _PREFIXES
|
||||
|
||||
|
||||
def _bool_arg(value: str) -> bool:
|
||||
value = value.lower()
|
||||
if value in ("on", "true", "yes", "1", "start", "enable"):
|
||||
return True
|
||||
if value in ("off", "false", "no", "0", "stop", "disable"):
|
||||
return False
|
||||
raise ActionError(f"expected on/off, got {value!r}")
|
||||
|
||||
|
||||
def parse(command: str) -> Optional[dict]:
|
||||
"""Parse a `petctl ...` string into an action dict, or None if this isn't
|
||||
a pet command at all (caller should run it as a real shell command).
|
||||
Raises ActionError on a pet command that doesn't make sense."""
|
||||
if not is_pet_command(command):
|
||||
return None
|
||||
try:
|
||||
parts = shlex.split(command.strip())
|
||||
except ValueError as exc: # unbalanced quotes
|
||||
raise ActionError(f"couldn't parse arguments: {exc}") from exc
|
||||
verb = (parts[1].lower() if len(parts) > 1 else "help")
|
||||
args = parts[2:]
|
||||
|
||||
if verb in ("help", "-h", "--help"):
|
||||
return {"action": "help"}
|
||||
|
||||
if verb in ("move", "goto", "walk"):
|
||||
if not args:
|
||||
raise ActionError("move needs a target: " + ", ".join(ANCHORS) + ", or x y")
|
||||
if len(args) >= 2 and _looks_numeric(args[0]) and _looks_numeric(args[1]):
|
||||
return {"action": "move", "x": int(float(args[0])), "y": int(float(args[1]))}
|
||||
anchor = args[0].lower().replace("_", "-")
|
||||
if anchor not in ANCHORS:
|
||||
raise ActionError(f"unknown position {args[0]!r}; try one of: " + ", ".join(ANCHORS))
|
||||
return {"action": "move", "anchor": anchor}
|
||||
|
||||
if verb in ("emote", "do"):
|
||||
if not args:
|
||||
raise ActionError("emote needs a name: " + ", ".join(EMOTES))
|
||||
emote = args[0].lower()
|
||||
if emote not in EMOTES:
|
||||
raise ActionError(f"unknown emote {args[0]!r}; try one of: " + ", ".join(EMOTES))
|
||||
return {"action": "emote", "emote": emote}
|
||||
|
||||
if verb == "say":
|
||||
text = " ".join(args).strip()
|
||||
if not text:
|
||||
raise ActionError("say needs something to say")
|
||||
return {"action": "say", "text": text}
|
||||
|
||||
if verb == "wander":
|
||||
if not args:
|
||||
raise ActionError("wander needs on or off")
|
||||
return {"action": "wander", "enabled": _bool_arg(args[0])}
|
||||
|
||||
if verb in ("nap", "sleep", "dnd"):
|
||||
if not args:
|
||||
raise ActionError("nap needs on or off")
|
||||
return {"action": "nap", "enabled": _bool_arg(args[0])}
|
||||
|
||||
raise ActionError(f"unknown petctl verb {verb!r}\n{HELP}")
|
||||
|
||||
|
||||
def _looks_numeric(value: str) -> bool:
|
||||
try:
|
||||
float(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def describe(action: dict) -> str:
|
||||
"""The text handed back to the server as this "command"'s output. Phrased
|
||||
as a completed fact so the model doesn't narrate the mechanics of it."""
|
||||
kind = action.get("action")
|
||||
if kind == "move":
|
||||
where = action.get("anchor") or f"({action.get('x')}, {action.get('y')})"
|
||||
return f"[pet] walking to {where}"
|
||||
if kind == "emote":
|
||||
return f"[pet] {action['emote']}"
|
||||
if kind == "say":
|
||||
return "[pet] showing that in the speech bubble"
|
||||
if kind == "wander":
|
||||
return "[pet] wandering " + ("enabled" if action["enabled"] else "disabled")
|
||||
if kind == "nap":
|
||||
return "[pet] " + ("napping" if action["enabled"] else "awake")
|
||||
if kind == "help":
|
||||
return HELP
|
||||
return "[pet] ok"
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Quiet hours — when the pet is allowed to make noise on its own.
|
||||
|
||||
Napping only ever suppresses *proactive* noise (heartbeat announcements,
|
||||
forwarded notifications) and wandering. The wake word, click-to-talk and
|
||||
push-to-talk still work: telling it to be quiet shouldn't mean it stops
|
||||
answering when spoken to.
|
||||
|
||||
Pure logic (parsing + a time comparison) so it's testable without waiting
|
||||
for 11pm.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import time as dtime
|
||||
from typing import Iterable, Optional
|
||||
|
||||
|
||||
class QuietHoursError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _parse_clock(value: str) -> dtime:
|
||||
parts = value.strip().split(":")
|
||||
if len(parts) != 2:
|
||||
raise QuietHoursError(f"expected HH:MM, got {value!r}")
|
||||
try:
|
||||
hour, minute = int(parts[0]), int(parts[1])
|
||||
except ValueError as exc:
|
||||
raise QuietHoursError(f"expected HH:MM, got {value!r}") from exc
|
||||
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
||||
raise QuietHoursError(f"{value!r} is not a real time of day")
|
||||
return dtime(hour, minute)
|
||||
|
||||
|
||||
def parse_ranges(spec: str) -> list[tuple[dtime, dtime]]:
|
||||
""""23:00-08:00, 13:00-14:00" -> [(23:00, 08:00), (13:00, 14:00)].
|
||||
Empty/blank spec means "no quiet hours"."""
|
||||
ranges: list[tuple[dtime, dtime]] = []
|
||||
for chunk in (spec or "").split(","):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
start, sep, end = chunk.partition("-")
|
||||
if not sep:
|
||||
raise QuietHoursError(f"expected HH:MM-HH:MM, got {chunk!r}")
|
||||
ranges.append((_parse_clock(start), _parse_clock(end)))
|
||||
return ranges
|
||||
|
||||
|
||||
def in_ranges(now: dtime, ranges: Iterable[tuple[dtime, dtime]]) -> bool:
|
||||
for start, end in ranges:
|
||||
if start == end:
|
||||
continue # a zero-length range is a typo, not "all day"
|
||||
if start < end:
|
||||
if start <= now < end:
|
||||
return True
|
||||
elif now >= start or now < end: # wraps past midnight
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_quiet(spec: str, now: Optional[dtime] = None, on_error=None) -> bool:
|
||||
"""True if *now* (defaults to the local wall clock) falls inside *spec*.
|
||||
A malformed spec is reported via *on_error* and treated as "not quiet" —
|
||||
a config typo shouldn't silently mute the pet forever."""
|
||||
if not (spec or "").strip():
|
||||
return False
|
||||
try:
|
||||
ranges = parse_ranges(spec)
|
||||
except QuietHoursError as exc:
|
||||
if on_error is not None:
|
||||
on_error(exc)
|
||||
return False
|
||||
if now is None:
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now().time()
|
||||
return in_ranges(now, ranges)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""What's on screen right now — the active window's title, and whether
|
||||
something is running fullscreen.
|
||||
|
||||
Two consumers:
|
||||
* annotate() tacks the focused window's title onto what you said, so
|
||||
"what's this error?" has a referent without you describing it (the desk
|
||||
API takes text only, so this is a text annotation — no screenshot upload).
|
||||
* is_fullscreen_active() feeds do-not-disturb: the pet shouldn't announce
|
||||
anything over a call or a fullscreen game.
|
||||
|
||||
Everything here is best-effort and must never raise: on a locked-down Wayland
|
||||
session none of it is available, and the correct behaviour is simply "no
|
||||
context", not a crashed pipeline. The subprocess *parsing* is split into pure
|
||||
functions so it can be tested without a display server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
_TIMEOUT = 2.0
|
||||
_MAX_TITLE_CHARS = 160
|
||||
|
||||
# Titles that are just the desktop itself — annotating with these is noise.
|
||||
_BORING_TITLES = {"", "desktop", "@!0,0;bdib", "plasmashell", "gnome-shell", "xfdesktop"}
|
||||
|
||||
|
||||
def _run(args: list[str]) -> Optional[str]:
|
||||
try:
|
||||
completed = subprocess.run(args, capture_output=True, text=True, timeout=_TIMEOUT)
|
||||
except Exception:
|
||||
return None
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
return completed.stdout
|
||||
|
||||
|
||||
# ── pure parsing helpers (unit tested; no display server needed) ─────────────
|
||||
|
||||
def parse_xprop_window_id(output: str) -> Optional[str]:
|
||||
"""`xprop -root _NET_ACTIVE_WINDOW` -> '_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3c00007'"""
|
||||
match = re.search(r"(0x[0-9a-fA-F]+)", output or "")
|
||||
if not match or int(match.group(1), 16) == 0:
|
||||
return None
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def parse_xprop_window_name(output: str) -> Optional[str]:
|
||||
"""`xprop -id <id> _NET_WM_NAME` -> '_NET_WM_NAME(UTF8_STRING) = "Firefox"'"""
|
||||
match = re.search(r'=\s*"(.*)"\s*$', (output or "").strip(), re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1).strip()
|
||||
|
||||
|
||||
def parse_xprop_fullscreen(output: str) -> bool:
|
||||
return "_NET_WM_STATE_FULLSCREEN" in (output or "")
|
||||
|
||||
|
||||
def clean_title(title: Optional[str]) -> Optional[str]:
|
||||
title = (title or "").strip().replace("\n", " ")
|
||||
if title.lower() in _BORING_TITLES:
|
||||
return None
|
||||
if len(title) > _MAX_TITLE_CHARS:
|
||||
title = title[: _MAX_TITLE_CHARS - 1].rstrip() + "…"
|
||||
return title or None
|
||||
|
||||
|
||||
def annotate(text: str, title: Optional[str]) -> str:
|
||||
"""Attach the window title as an explicit aside rather than splicing it
|
||||
into the sentence, so the model can ignore it when it's irrelevant."""
|
||||
text = (text or "").strip()
|
||||
title = clean_title(title)
|
||||
if not text or not title:
|
||||
return text
|
||||
return f"{text}\n\n[on screen right now: {title}]"
|
||||
|
||||
|
||||
# ── platform probes ──────────────────────────────────────────────────────────
|
||||
|
||||
def _linux_active_window_id() -> Optional[str]:
|
||||
if not shutil.which("xprop"):
|
||||
return None
|
||||
output = _run(["xprop", "-root", "_NET_ACTIVE_WINDOW"])
|
||||
return parse_xprop_window_id(output or "")
|
||||
|
||||
|
||||
def _linux_title() -> Optional[str]:
|
||||
if shutil.which("xdotool"):
|
||||
output = _run(["xdotool", "getactivewindow", "getwindowname"])
|
||||
if output and output.strip():
|
||||
return output.strip()
|
||||
window_id = _linux_active_window_id()
|
||||
if window_id is None:
|
||||
return None
|
||||
for prop in ("_NET_WM_NAME", "WM_NAME"):
|
||||
output = _run(["xprop", "-id", window_id, prop])
|
||||
title = parse_xprop_window_name(output or "")
|
||||
if title:
|
||||
return title
|
||||
return None
|
||||
|
||||
|
||||
def _windows_title() -> Optional[str]:
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
handle = user32.GetForegroundWindow()
|
||||
if not handle:
|
||||
return None
|
||||
length = user32.GetWindowTextLengthW(handle)
|
||||
buffer = ctypes.create_unicode_buffer(length + 1)
|
||||
user32.GetWindowTextW(handle, buffer, length + 1)
|
||||
return buffer.value or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _macos_title() -> Optional[str]:
|
||||
script = (
|
||||
'tell application "System Events" to get name of first application process '
|
||||
"whose frontmost is true"
|
||||
)
|
||||
output = _run(["osascript", "-e", script])
|
||||
return (output or "").strip() or None
|
||||
|
||||
|
||||
def active_window_title() -> Optional[str]:
|
||||
"""Focused window's title, or None if the platform won't tell us."""
|
||||
try:
|
||||
system = platform.system()
|
||||
if system == "Linux":
|
||||
return clean_title(_linux_title())
|
||||
if system == "Windows":
|
||||
return clean_title(_windows_title())
|
||||
if system == "Darwin":
|
||||
return clean_title(_macos_title())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def is_fullscreen_active() -> bool:
|
||||
"""True when the focused window is fullscreen (call, game, presentation).
|
||||
False whenever we can't tell — do-not-disturb should be something you opt
|
||||
into, not something a failed probe turns on."""
|
||||
try:
|
||||
system = platform.system()
|
||||
if system == "Linux":
|
||||
window_id = _linux_active_window_id()
|
||||
if window_id is None:
|
||||
return False
|
||||
return parse_xprop_fullscreen(_run(["xprop", "-id", window_id, "_NET_WM_STATE"]) or "")
|
||||
if system == "Windows":
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
handle = user32.GetForegroundWindow()
|
||||
if not handle:
|
||||
return False
|
||||
rect = wintypes.RECT()
|
||||
user32.GetWindowRect(handle, ctypes.byref(rect))
|
||||
screen_w = user32.GetSystemMetrics(0)
|
||||
screen_h = user32.GetSystemMetrics(1)
|
||||
return (rect.right - rect.left) >= screen_w and (rect.bottom - rect.top) >= screen_h
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def context_for(text: str) -> str:
|
||||
"""What the controller sends: *text* plus the active window title, when
|
||||
SCREEN_CONTEXT is on and there's a title worth mentioning."""
|
||||
from . import config
|
||||
|
||||
if not config.SCREEN_CONTEXT:
|
||||
return text
|
||||
return annotate(text, active_window_title())
|
||||
@@ -0,0 +1,125 @@
|
||||
"""HTTP client for Bolt's desk API (ai/desk_api.py on the server).
|
||||
|
||||
Protocol is identical to desk_client/bolt_desk.py in the main tmn-api repo —
|
||||
this pet is just another desk client, so it gets the exact same brain,
|
||||
memory, tools, and persona as Discord chat and the Linux voice client:
|
||||
|
||||
text -> POST /desk/converse
|
||||
[server may relay a shell command back to run on THIS machine]
|
||||
... -> POST /desk/tool_result (repeat until the server sends a reply)
|
||||
reply <- returned to caller
|
||||
|
||||
Kept dependency-free beyond `requests` so it's easy to unit test with mocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from . import config
|
||||
|
||||
_MAX_RELAY_HOPS = 16
|
||||
|
||||
|
||||
class ServerError(Exception):
|
||||
"""Raised when the server responds with an error payload or unreachable."""
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
return {"X-Desk-Api-Key": config.API_KEY}
|
||||
|
||||
|
||||
def check_health(timeout: float = 10.0) -> dict:
|
||||
response = requests.get(f"{config.SERVER_URL}/desk/health", headers=_headers(), timeout=timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def run_local_command(command: str, timeout: int = None) -> str:
|
||||
"""Execute a command relayed by the server, exactly as bolt_desk.py does —
|
||||
"full desktop control" for things like "open firefox" or "how full is my
|
||||
disk". Runs as the current desktop user. See README security notes."""
|
||||
timeout = timeout or config.COMMAND_TIMEOUT_SECONDS
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command, shell=True, capture_output=True, text=True,
|
||||
timeout=timeout, cwd=str(Path.home()),
|
||||
)
|
||||
output = (completed.stdout or "") + (completed.stderr or "")
|
||||
return f"[exit {completed.returncode}]\n{output}"[:6000]
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"[command timed out after {timeout}s]"
|
||||
except Exception as exc:
|
||||
return f"[command failed: {exc}]"
|
||||
|
||||
|
||||
def converse(
|
||||
text: str,
|
||||
on_command: Callable[[str], str] = run_local_command,
|
||||
timeout: float = 120.0,
|
||||
) -> str:
|
||||
"""Send one turn of conversation to the desk API, relaying any commands
|
||||
the server sends back until it produces a final reply.
|
||||
|
||||
*on_command* is injectable for tests; defaults to actually running the
|
||||
command locally (matching bolt_desk.py's behavior).
|
||||
"""
|
||||
headers = _headers()
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{config.SERVER_URL}/desk/converse",
|
||||
json={"session_id": config.SESSION_ID, "text": text},
|
||||
headers=headers, timeout=timeout,
|
||||
)
|
||||
payload = response.json()
|
||||
except Exception as exc:
|
||||
raise ServerError(f"couldn't reach the server: {exc}") from exc
|
||||
|
||||
for _ in range(_MAX_RELAY_HOPS):
|
||||
if payload.get("type") != "command":
|
||||
break
|
||||
output = on_command(str(payload.get("command") or ""))
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{config.SERVER_URL}/desk/tool_result",
|
||||
json={
|
||||
"session_id": config.SESSION_ID,
|
||||
"token": payload.get("token"),
|
||||
"output": output,
|
||||
},
|
||||
headers=headers, timeout=180,
|
||||
)
|
||||
payload = response.json()
|
||||
except Exception as exc:
|
||||
raise ServerError(f"couldn't reach the server during tool relay: {exc}") from exc
|
||||
|
||||
if payload.get("type") == "reply":
|
||||
return str(payload.get("text") or "")
|
||||
raise ServerError(str(payload.get("error") or "unknown server response"))
|
||||
|
||||
|
||||
def report_status(timeout: float = 15.0) -> Optional[str]:
|
||||
"""Heartbeat — lets the desk API attach a pending spoken announcement
|
||||
(proactive nudges, reminders fired since the last heartbeat) that the pet
|
||||
can speak unprompted, exactly like the phone/desk clients. A pet has no
|
||||
battery/GPS to report, so the device-status fields
|
||||
(battery/is_charging/latitude/longitude/address, all optional server-side)
|
||||
are simply omitted.
|
||||
|
||||
Returns the announcement text to speak, or None if there's nothing pending.
|
||||
"""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{config.SERVER_URL}/desk/report_status",
|
||||
json={"session_id": config.SESSION_ID}, headers=_headers(), timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception as exc:
|
||||
raise ServerError(f"heartbeat failed: {exc}") from exc
|
||||
reply = data.get("reply")
|
||||
return str(reply) if reply else None
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Turn a server reply into something worth *hearing*.
|
||||
|
||||
The server's persona writes for a chat window: markdown emphasis, bullet
|
||||
lists, emoji, bare URLs. A TTS voice reads those literally ("asterisk
|
||||
asterisk OS colon", "https colon slash slash..."), so everything spoken goes
|
||||
through for_speech() first. Pure string logic, no Qt/audio imports — cheap to
|
||||
unit test (see tests/test_speech_text.py).
|
||||
|
||||
for_display() is the lighter sibling used for the speech bubble: it drops the
|
||||
markdown *syntax* but keeps emoji and punctuation, since those render fine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Pictographs, symbols, flags, dingbats, arrows, box drawing, variation
|
||||
# selectors, ZWJ — anything a voice would either skip or read as a name
|
||||
# ("black right-pointing triangle").
|
||||
_EMOJI = re.compile(
|
||||
"["
|
||||
"\U0001F000-\U0001FAFF" # emoji / pictographs / symbols blocks
|
||||
"\U00002190-\U000021FF" # arrows
|
||||
"\U00002300-\U000023FF" # misc technical (⌘ ⏱ …)
|
||||
"\U000025A0-\U000027BF" # geometric shapes, misc symbols, dingbats
|
||||
"\U00002B00-\U00002BFF" # extra arrows / shapes
|
||||
"\U0000FE00-\U0000FE0F" # variation selectors
|
||||
"\U0001F1E6-\U0001F1FF" # regional indicators (flags)
|
||||
"\U0000200D" # zero-width joiner
|
||||
"]+",
|
||||
flags=re.UNICODE,
|
||||
)
|
||||
|
||||
# Characters that are markup or decoration rather than speech. Kept out of
|
||||
# the spoken text entirely; ordinary punctuation (. , ! ? ; : ' " ( ) -) is
|
||||
# preserved because it shapes prosody.
|
||||
_UNSPEAKABLE = re.compile(r"[*_#`~^|<>\\{}\[\]/=+@©®™•·–—]+")
|
||||
|
||||
_FENCED_CODE = re.compile(r"```.*?```", re.DOTALL)
|
||||
_INLINE_CODE = re.compile(r"`([^`]*)`")
|
||||
_MD_IMAGE = re.compile(r"!\[([^\]]*)\]\([^)]*\)")
|
||||
_MD_LINK = re.compile(r"\[([^\]]+)\]\([^)]*\)")
|
||||
_URL = re.compile(r"\b(?:https?://|www\.)\S+")
|
||||
_HEADING = re.compile(r"^\s{0,3}#{1,6}\s*", re.MULTILINE)
|
||||
_BLOCKQUOTE = re.compile(r"^\s{0,3}>\s?", re.MULTILINE)
|
||||
_RULE = re.compile(r"^\s*([-*_=])(?:\s*\1){2,}\s*$", re.MULTILINE)
|
||||
_BULLET = re.compile(r"^\s*(?:[-*+•·]|\d+[.)])\s+", re.MULTILINE)
|
||||
_EMPHASIS = re.compile(r"(\*{1,3}|_{1,3})(\S(?:.*?\S)?)\1", re.DOTALL)
|
||||
_TABLE_PIPE = re.compile(r"[ \t]*\|[ \t]*")
|
||||
|
||||
# Symbols worth saying out loud rather than dropping — a bare "&" read as
|
||||
# nothing turns "R&D" into "RD". Split in two because the non-ASCII ones sit
|
||||
# inside the arrow/symbol blocks _EMOJI strips, so they have to be worded
|
||||
# before that pass; the ASCII ones must wait until *after* markdown parsing
|
||||
# (an "=" turned into " equals " would stop a "====" rule matching _RULE).
|
||||
_PRE_SPOKEN_SYMBOLS = {
|
||||
"→": " to ",
|
||||
"×": " by ",
|
||||
"°": " degrees ",
|
||||
"≈": " about ",
|
||||
}
|
||||
_SPOKEN_SYMBOLS = {
|
||||
"&": " and ",
|
||||
"%": " percent ",
|
||||
"@": " at ",
|
||||
"+": " plus ",
|
||||
"=": " equals ",
|
||||
}
|
||||
|
||||
_MULTI_SPACE = re.compile(r"[ \t]+")
|
||||
_MULTI_PUNCT = re.compile(r"(?:\s*\.){2,}")
|
||||
|
||||
|
||||
def _strip_markdown(text: str, *, keep_emoji: bool) -> str:
|
||||
text = _FENCED_CODE.sub(" (code) ", text)
|
||||
text = _INLINE_CODE.sub(r"\1", text)
|
||||
text = _MD_IMAGE.sub(r"\1", text)
|
||||
text = _MD_LINK.sub(r"\1", text)
|
||||
text = _RULE.sub("", text)
|
||||
text = _HEADING.sub("", text)
|
||||
text = _BLOCKQUOTE.sub("", text)
|
||||
text = _EMPHASIS.sub(r"\2", text)
|
||||
if not keep_emoji:
|
||||
text = _EMOJI.sub(" ", text)
|
||||
return text
|
||||
|
||||
|
||||
def _bullets_to_sentences(text: str) -> str:
|
||||
"""A read-aloud list needs pauses where the eye would see line breaks,
|
||||
otherwise "OS Linux Uptime 1 day CPU load moderate" runs together."""
|
||||
lines = [_BULLET.sub("", line).strip() for line in text.splitlines()]
|
||||
lines = [line for line in lines if line]
|
||||
if len(lines) < 2:
|
||||
return lines[0] if lines else ""
|
||||
# A bullet like "OS: Linux" ends mid-thought — give the voice a full stop
|
||||
# so consecutive items don't slur into one run-on sentence.
|
||||
return " ".join(line if line[-1] in ".!?:,;" else line + "." for line in lines)
|
||||
|
||||
|
||||
def for_speech(text: str) -> str:
|
||||
"""Plain prose for the TTS engine: no markdown, no emoji, no bare URLs,
|
||||
no stray symbols that would be read out character by character."""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
for symbol, spoken in _PRE_SPOKEN_SYMBOLS.items():
|
||||
text = text.replace(symbol, spoken)
|
||||
text = _strip_markdown(text, keep_emoji=False)
|
||||
text = _URL.sub(" link ", text)
|
||||
text = _TABLE_PIPE.sub(", ", text)
|
||||
text = _bullets_to_sentences(text)
|
||||
for symbol, spoken in _SPOKEN_SYMBOLS.items():
|
||||
text = text.replace(symbol, spoken)
|
||||
text = _UNSPEAKABLE.sub(" ", text)
|
||||
text = _MULTI_PUNCT.sub(".", text)
|
||||
text = _MULTI_SPACE.sub(" ", text)
|
||||
text = re.sub(r"\s+([.,!?;:])", r"\1", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def for_display(text: str) -> str:
|
||||
"""What the speech bubble shows: markdown syntax removed (the bubble
|
||||
can't render it) but emoji and layout-ish punctuation left alone."""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
text = _strip_markdown(text, keep_emoji=True)
|
||||
lines = [_BULLET.sub("• ", line).strip() for line in text.splitlines()]
|
||||
text = " ".join(line for line in lines if line)
|
||||
return _MULTI_SPACE.sub(" ", text).strip()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Pet state machine — pure logic, no Qt/audio dependencies, so it's cheap
|
||||
to unit test. The UI layer (ui/pet_window.py) reacts to state changes by
|
||||
swapping the active sprite animation; the worker thread (ui/app.py) drives
|
||||
transitions as the mic/wake/converse/tts pipeline progresses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
||||
class PetState(str, Enum):
|
||||
IDLE = "idle" # waiting for the wake phrase (or a click)
|
||||
LISTENING = "listening" # actively recording an utterance
|
||||
THINKING = "thinking" # waiting on the server (STT done, converse in flight)
|
||||
TALKING = "talking" # playing back the TTS reply
|
||||
ERROR = "error" # brief flash state on failure, then back to idle
|
||||
|
||||
|
||||
# States it's valid to move to from each state. Keeps ad-hoc bugs (e.g.
|
||||
# firing TALKING before a reply exists) from silently passing through.
|
||||
#
|
||||
# IDLE -> TALKING is legal (not just IDLE -> LISTENING) because of proactive
|
||||
# announcements: the heartbeat poll (controller.py's _maybe_heartbeat) can
|
||||
# make the pet speak unprompted — a reminder firing, a nudge from the server
|
||||
# — without the user having said anything first, so there's no preceding
|
||||
# LISTENING/THINKING leg for that turn.
|
||||
_TRANSITIONS: dict[PetState, set[PetState]] = {
|
||||
PetState.IDLE: {PetState.LISTENING, PetState.TALKING, PetState.ERROR},
|
||||
PetState.LISTENING: {PetState.THINKING, PetState.IDLE, PetState.ERROR},
|
||||
PetState.THINKING: {PetState.TALKING, PetState.IDLE, PetState.ERROR},
|
||||
PetState.TALKING: {PetState.IDLE, PetState.ERROR},
|
||||
PetState.ERROR: {PetState.IDLE},
|
||||
}
|
||||
|
||||
|
||||
class InvalidTransition(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PetStateMachine:
|
||||
def __init__(self, on_change: Optional[Callable[[PetState, PetState], None]] = None):
|
||||
self._state = PetState.IDLE
|
||||
self._on_change = on_change
|
||||
|
||||
@property
|
||||
def state(self) -> PetState:
|
||||
return self._state
|
||||
|
||||
def transition(self, new_state: PetState) -> None:
|
||||
if new_state == self._state:
|
||||
return
|
||||
allowed = _TRANSITIONS.get(self._state, set())
|
||||
if new_state not in allowed:
|
||||
raise InvalidTransition(f"{self._state} -> {new_state} is not allowed")
|
||||
old_state = self._state
|
||||
self._state = new_state
|
||||
if self._on_change is not None:
|
||||
self._on_change(old_state, new_state)
|
||||
|
||||
def force(self, new_state: PetState) -> None:
|
||||
"""Bypass the transition table — used only for recovering to IDLE
|
||||
from an unexpected/edge-case state (e.g. after an exception mid
|
||||
pipeline). Prefer transition() everywhere else."""
|
||||
old_state = self._state
|
||||
if new_state == old_state:
|
||||
return
|
||||
self._state = new_state
|
||||
if self._on_change is not None:
|
||||
self._on_change(old_state, new_state)
|
||||
@@ -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)
|
||||