jarvis-cloud / backend /voice /space_injection.py
Jarvis2345's picture
Squash history β€” remove all prior commits (secret hygiene, S4)
a31f556
Raw
History Blame
4.36 kB
# 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}")