# backend/voice/space_injection.py # §2.1b — Space Context Voice Injection # # Path: space change event → persona prompt → XTTS synthesize → WS audio bytes # Bypasses openWakeWord, SileroVAD, and Whisper entirely. # Mutual exclusion: queues behind any active wake-word conversation via _pipeline_lock. # # Cross-platform: # • APK → ContinuousVoiceRelay.injectSpaceEventNow() calls this module's REST # endpoint (/voice/space_ack) which triggers speak_space_acknowledgement(). # • EXE → WS event "space:changed" → EXE shows toast (NO TTS, avoids double-speak). # import asyncio import logging from typing import Literal SpaceKey = Literal["dark_space", "family_friendly"] # ─── Shared pipeline lock ──────────────────────────────────────────────────── # Also used by audio_ws.py's wake_lock — imported here so both modules share # the SAME lock instance. audio_ws sets this reference on its first use. _pipeline_lock: asyncio.Lock | None = None def get_pipeline_lock() -> asyncio.Lock: """Lazy-init: returns a module-level lock used by both audio_ws and space_injection.""" global _pipeline_lock if _pipeline_lock is None: _pipeline_lock = asyncio.Lock() return _pipeline_lock # ─── Injection entry point ─────────────────────────────────────────────────── async def speak_space_acknowledgement(new_space: SpaceKey, persona: str) -> None: """ Directly synthesise and broadcast TTS for a space change. - Does NOT go through wake word or STT. - Acquires _pipeline_lock so it cannot collide with an active utterance. - Broadcasts audio bytes via WS so the connected Tauri EXE / mobile client can play them immediately. - Also emits a 'space:changed' WS event for the EXE toast. """ from backend.agent.personas import get_space_ack_prompt from backend.voice.tts import TTSPipeline from backend.ws.agent_ws import ws_manager prompt_text = get_space_ack_prompt(persona, new_space) lock = get_pipeline_lock() logging.info(f"[SpaceInjection] Waiting for pipeline lock (space={new_space}, persona={persona})") async with lock: logging.info(f"[SpaceInjection] Synthesising: '{prompt_text}'") try: tts = TTSPipeline() # §0.4: context="conversation" → XTTS engine (JARVIS/FRIDAY actually speaking, not a system beep) audio_bytes: bytes = await tts.synthesize(prompt_text, personality=persona, context="conversation") except Exception as e: logging.error(f"[SpaceInjection] TTS synthesis failed: {e}") # Even if TTS fails, still emit the WS event so EXE shows a toast audio_bytes = b"" # ── 1. Broadcast WS event → EXE shows a toast (no EXE TTS — avoids double-speak) await ws_manager.broadcast({ "event": "space:changed", "payload": { "space": new_space, "persona": persona, "label": new_space.replace("_", " ").title(), "message": prompt_text, } }) # ── 2. Send audio bytes to WS clients (APK + EXE if they have audio player connected) if audio_bytes: # Signal start await ws_manager.broadcast({ "event": "voice:space_ack_start", "payload": {"space": new_space, "persona": persona} }) # Raw audio payload — clients that support binary audio will play it for conn in list(ws_manager.active_connections): try: await conn.send_bytes(audio_bytes) except Exception as e: logging.getLogger(__name__).error(f"Swallowed exception: {e}") # Signal end await ws_manager.broadcast({ "event": "voice:space_ack_end", "payload": {"space": new_space} }) logging.info(f"[SpaceInjection] Done: space={new_space}, persona={persona}")