"""Smart Assistant Pi client entry point. CRITICAL: set PA_ALSA_PLUGHW BEFORE importing sounddevice (see findings.md ยง1). """ import os os.environ.setdefault("PA_ALSA_PLUGHW", "1") import asyncio import queue import signal import sys import threading from pathlib import Path import numpy as np import sounddevice as sd # safe to import now from client.audio import ( AudioStreams, FRAME_SAMPLES, resample_24k_to_16k, ) from client.config import ConfigMissingError, load_config from client.log import get_logger from client.playback import PlaybackWorker from client.session import Session from client.state import StateMachine from client.wakeword import WakewordDetector _log = get_logger("main") ASSISTANT_STATE_DIR = Path(os.environ["HOME"]) / "assistant" / "state" WAKE_WAV = ASSISTANT_STATE_DIR / "wake.wav" SLEEP_WAV = ASSISTANT_STATE_DIR / "sleep.wav" def _build_bridge_thread( mic_queue: queue.Queue, sm: StateMachine, wakeword: WakewordDetector, session: Session, playback: PlaybackWorker, stop_event: threading.Event, ) -> threading.Thread: def run(): while not stop_event.is_set(): try: pcm16_bytes = mic_queue.get(timeout=0.25) except queue.Empty: continue if sm.wakeword_enabled: frame = np.frombuffer(pcm16_bytes, dtype=np.int16) if frame.size != FRAME_SAMPLES: continue feed = resample_24k_to_16k(frame) if wakeword.predict(feed): if sm.wake_fired(): if WAKE_WAV.exists(): playback.enqueue_wav(WAKE_WAV, "wake") else: # Even with no wav, fire the done marker so the # playback_done(for=wake) envelope is sent. playback.enqueue_done_marker("wake") session.call_soon_threadsafe_send_wake() elif sm.uplink_enabled: session.call_soon_threadsafe_send_binary(pcm16_bytes) # else: ASSISTANT_SPEAKING / WAKE_PENDING / IDLE_PENDING -> drop t = threading.Thread(target=run, name="bridge", daemon=True) return t def _make_callbacks(sm: StateMachine, playback: PlaybackWorker): class _CB: def on_session_started(self, conversation_id: str) -> None: _log.info("session_started conversation_id=%s", conversation_id) sm.session_started() def on_assistant_audio(self, pcm16: bytes) -> None: sm.assistant_audio_arrived() playback.enqueue_audio(pcm16) def on_assistant_done(self) -> None: _log.info("assistant_done") sm.assistant_done() def on_session_ended(self, reason: str) -> None: _log.info("session_ended reason=%s", reason) sm.session_ended() if SLEEP_WAV.exists(): playback.enqueue_wav(SLEEP_WAV, "sleep") else: playback.enqueue_done_marker("sleep") return _CB() def main() -> None: try: cfg = load_config() except ConfigMissingError as exc: raise SystemExit(str(exc)) from exc _log.info("device_id=%s backend=%s", cfg.device_id, cfg.backend_ws) mic_queue: queue.Queue = queue.Queue(maxsize=64) streams = AudioStreams.open(sd, mic_queue=mic_queue) streams.start() sm = StateMachine() session_ref: list[Session] = [] def on_playback_done(label: str) -> None: _log.info("playback_done for=%s", label) if not session_ref: return session = session_ref[0] session.call_soon_threadsafe_send_playback_done(label) if label == "sleep": sm.sleep_played() playback = PlaybackWorker(streams, on_done=on_playback_done) playback.start() wakeword = WakewordDetector() callbacks = _make_callbacks(sm, playback) session = Session( backend_ws=cfg.backend_ws, device_token=cfg.device_token, device_id=cfg.device_id, callbacks=callbacks, ) session_ref.append(session) def set_volume_handler(args: dict) -> dict: level = int(args.get("level", -1)) playback.set_volume(level) return {"ok": True, "level": level} session.register_tool("set_volume", set_volume_handler) stop_event = threading.Event() bridge = _build_bridge_thread(mic_queue, sm, wakeword, session, playback, stop_event) bridge.start() def _shutdown(signum, _frame): _log.info("signal %d received; shutting down", signum) stop_event.set() try: playback.stop() finally: streams.close() sys.exit(0) signal.signal(signal.SIGTERM, _shutdown) signal.signal(signal.SIGINT, _shutdown) try: asyncio.run(session.run_forever()) finally: stop_event.set() playback.stop() streams.close() if __name__ == "__main__": main()