Spaces:
Running
Running
| # 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: | |
| import logging; 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}") | |