Streaming replies and STT, amplitude lip-sync, one place for speaking
Latency: replies are spoken sentence-by-sentence off the desk API's NDJSON endpoint, so the wait is time-to-first-sentence rather than the whole model call, and Deepgram's live websocket transcribes while you're still talking instead of uploading the WAV afterwards. Both fall back invisibly — a stream that fails before anything was said drops to converse(), and a socket that never opens just means the old one-shot path. Speaking lived in four near-copies in the controller (a reply, a holding line, a streamed sentence, a dialogue scene) that had already drifted: one didn't arm barge-in, another skipped the follow-up rule. It's now speech.Speaker plus an Utterance describing the policy differences, with collaborators injected so the whole of it tests without Qt or audio. The mouth follows the audio rather than a timer: tts.level_of reduces each PCM frame to a 0..1 loudness on a sqrt curve (speech sits well below peak, and a linear map leaves the mouth barely open during normal talking) and that indexes the talking frames, which the sprite script now draws as an openness ramp. Offline pyttsx3 has no waveform, so stale levels hand control back to the timed loop instead of freezing the mouth mid-syllable. Also: the pet starts where you left it (ignoring positions on monitors that are no longer connected, since restoring those faithfully is how it ends up somewhere unreachable), and `python -m bolt_pet --doctor` is a preflight that says what to do about each problem rather than only what's wrong. tests/test_pipeline_smoke.py breaks the pure-logic rule on purpose. Every unit test passed all week while notifications sat unspoken for minutes, the pet said things twice and [laughing] got read aloud — each an interaction between two individually-correct units. It drives whole turns against a real HTTP server on a loopback port, faking only the mic and the speakers. It found a NameError in the paint path that would have fired on every repaint while talking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"""Where the pet was left, so it starts there next time.
|
||||
|
||||
Listed in the README as a known limitation: drag it somewhere deliberate, and
|
||||
the next launch puts it back in the bottom-right corner. For something that
|
||||
lives on your desktop all day that is a small daily annoyance, and it is a
|
||||
config write on drag-end.
|
||||
|
||||
Lives in the cache dir rather than the repo, next to the restart context: it
|
||||
is per-machine state about this install, not something that belongs in a git
|
||||
diff. Every function swallows its own errors — a corrupt or unwritable state
|
||||
file must never stop the pet from starting, it just means the default corner.
|
||||
|
||||
Positions are validated against the *current* screen layout on load, because
|
||||
the common case for a stale position is exactly the case where it is
|
||||
dangerous: the pet was last on a monitor that is now unplugged, and restoring
|
||||
it faithfully would put it somewhere you cannot see or reach.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("bolt_pet.window_state")
|
||||
|
||||
DEFAULT_PATH = Path.home() / ".cache" / "bolt-pet" / "window.json"
|
||||
|
||||
|
||||
def load(path: Optional[Path] = None) -> Optional[tuple[int, int]]:
|
||||
"""The saved position, or None if there isn't a usable one."""
|
||||
try:
|
||||
data = json.loads(Path(path or DEFAULT_PATH).read_text(encoding="utf-8"))
|
||||
return int(data["x"]), int(data["y"])
|
||||
except (FileNotFoundError, json.JSONDecodeError, KeyError, TypeError, ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def save(x: int, y: int, path: Optional[Path] = None) -> None:
|
||||
"""Remember where it is now. Atomic, so a crash mid-write can't leave a
|
||||
half-file that makes the next start fall back to the corner."""
|
||||
target = Path(path or DEFAULT_PATH)
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temp_path = tempfile.mkstemp(dir=target.parent, prefix=".window_", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||
json.dump({"x": int(x), "y": int(y)}, handle)
|
||||
os.replace(temp_path, target)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except Exception:
|
||||
logger.debug("Could not save the window position", exc_info=True)
|
||||
|
||||
|
||||
def is_visible_on(x: int, y: int, size: int, rectangles) -> bool:
|
||||
"""Whether that position still lands on a screen that exists.
|
||||
|
||||
*rectangles* are (left, top, right, bottom) tuples — the caller's job,
|
||||
because this module has no business importing Qt. Requires a real overlap
|
||||
rather than a touching edge, so a pet saved flush against the boundary of a
|
||||
monitor that has since been unplugged is not counted as reachable."""
|
||||
for left, top, right, bottom in rectangles:
|
||||
overlap_x = min(x + size, right) - max(x, left)
|
||||
overlap_y = min(y + size, bottom) - max(y, top)
|
||||
if overlap_x > size * 0.25 and overlap_y > size * 0.25:
|
||||
return True
|
||||
return False
|
||||
Reference in New Issue
Block a user