80bef6f524
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
193 lines
11 KiB
Python
193 lines
11 KiB
Python
"""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
|