Spaces:
Running
Running
| # backend/voice/audio_ws.py | |
| import asyncio | |
| import json | |
| import logging | |
| import numpy as np | |
| import websockets | |
| import wave | |
| import io | |
| import os | |
| from datetime import datetime | |
| from .vad import SileroVAD | |
| from .stt import STTPipeline | |
| from .tts import TTSPipeline | |
| from .wake_word import WakeWordDetector | |
| from backend.ws.agent_ws import ws_manager | |
| import sys | |
| parent_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| if parent_dir not in sys.path: | |
| sys.path.insert(0, parent_dir) | |
| from modules.assistant_identity import set_mode | |
| # Shared pipeline lock — also used by space_injection.py so wake-word utterances | |
| # and space-ack speech cannot physically overlap. | |
| from backend.voice.space_injection import get_pipeline_lock | |
| AUDIO_LOGS_DIR = os.path.join(os.path.dirname(__file__), "audio_logs") | |
| os.makedirs(AUDIO_LOGS_DIR, exist_ok=True) | |
| TRANSCRIPT_LOG_PATH = os.path.join(AUDIO_LOGS_DIR, "transcripts_log.txt") | |
| active_audio_ws = None | |
| class AudioWebSocketServer: | |
| def __init__(self, host="127.0.0.1", port=8767): | |
| global active_audio_ws | |
| active_audio_ws = self | |
| self.host = host | |
| self.port = port | |
| self.vad = SileroVAD() | |
| self.stt = STTPipeline() | |
| self.tts = TTSPipeline() | |
| self.wake_word = WakeWordDetector() | |
| self.stop_future = None | |
| self.interrupt_flag = False | |
| # Use the shared pipeline lock so space_injection cannot fire mid-utterance | |
| self.wake_lock = get_pipeline_lock() | |
| def interrupt(self): | |
| self.interrupt_flag = True | |
| async def handler(self, websocket): | |
| logging.info("Client connected to Voice WebSocket.") | |
| speech_buffer = bytearray() | |
| is_speaking = False | |
| silence_chunks = 0 | |
| MAX_SILENCE_CHUNKS = 15 # Approx 0.5s of silence to trigger end of utterance | |
| is_tts_playing = False | |
| try: | |
| async for message in websocket: | |
| if self.interrupt_flag: | |
| speech_buffer.clear() | |
| is_speaking = False | |
| silence_chunks = 0 | |
| self.interrupt_flag = False | |
| if isinstance(message, bytes): | |
| if is_tts_playing: | |
| continue # Mute mic input while TTS is speaking to prevent self-triggering | |
| audio_np = np.frombuffer(message, dtype=np.int16).astype(np.float32) / 32768.0 | |
| # 1. Wake Word Detection (openWakeWord) | |
| keyword = self.wake_word.process_frame(audio_np) | |
| if keyword: | |
| async with self.wake_lock: | |
| if "friday" in keyword: | |
| set_mode("friday") | |
| elif "jarvis" in keyword: | |
| set_mode("jarvis") | |
| logging.info(f"openWakeWord triggered: {keyword}") | |
| await ws_manager.broadcast({"event": "voice:wake_word", "payload": {"agent": keyword}}) | |
| speech_buffer = bytearray() # Clear buffer to start listening | |
| is_speaking = True | |
| silence_chunks = 0 | |
| continue | |
| # 2. VAD Confirmation | |
| vad_res = self.vad.process_chunk(audio_np) | |
| if vad_res: | |
| is_speaking = True | |
| silence_chunks = 0 | |
| speech_buffer.extend(message) | |
| elif is_speaking: | |
| speech_buffer.extend(message) | |
| silence_chunks += 1 | |
| if silence_chunks >= MAX_SILENCE_CHUNKS: | |
| is_speaking = False | |
| silence_chunks = 0 | |
| if len(speech_buffer) > 16000: | |
| logging.info("Utterance complete. Transcribing...") | |
| wav_io = io.BytesIO() | |
| with wave.open(wav_io, 'wb') as wf: | |
| wf.setnchannels(1) | |
| wf.setsampwidth(2) | |
| wf.setframerate(16000) | |
| wf.writeframes(speech_buffer) | |
| # 3. Whisper Transcription | |
| text, detected_lang = await self.stt.transcribe(wav_io.getvalue()) | |
| if text.strip(): | |
| timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") | |
| wav_filepath = os.path.join(AUDIO_LOGS_DIR, f"{timestamp}.wav") | |
| with open(wav_filepath, "wb") as f: | |
| f.write(wav_io.getvalue()) | |
| await websocket.send(json.dumps({ | |
| "type": "transcript", | |
| "text": text.strip(), | |
| "language": detected_lang | |
| })) | |
| # --- JARVIS 10X Gaming Coach Interrupt --- | |
| # If the user yells a tactical interrupt, kill the audio immediately. | |
| interrupt_phrases = ["quiet", "stop", "shut up", "enemy on me", "contact"] | |
| if any(phrase in text.lower() for phrase in interrupt_phrases): | |
| logging.warning(f"JARVIS 10X Tactical Interrupt Detected: '{text.strip()}'") | |
| # Send hard kill signal to frontend audio player | |
| await websocket.send(json.dumps({"type": "audio_interrupt"})) | |
| # Skip routing to LLM for this utterance unless it's a query | |
| if "what went wrong" not in text.lower(): | |
| speech_buffer = bytearray() | |
| continue | |
| # 4. Route to LLM | |
| logging.info(f"Routing to ReAct LLM Agent: {text.strip()} (Lang: {detected_lang})") | |
| await ws_manager.handle_client_event( | |
| websocket=None, | |
| data={"type": "chat", "text": text.strip(), "language": detected_lang} | |
| ) | |
| speech_buffer = bytearray() | |
| elif isinstance(message, str): | |
| try: | |
| data = json.loads(message) | |
| msg_type = data.get("type") | |
| if msg_type == "tts_request": | |
| text = data.get("text", "") | |
| personality = data.get("personality", "jarvis") | |
| language = data.get("language", "en") | |
| if text: | |
| logging.info(f"Synthesizing TTS for: {text} in {language}") | |
| wav_bytes = await self.tts.synthesize(text, personality, language) | |
| async def _play_and_mute(): | |
| nonlocal is_tts_playing | |
| is_tts_playing = True | |
| await websocket.send(json.dumps({ | |
| "type": "tts_response_start", | |
| "text": text | |
| })) | |
| await websocket.send(wav_bytes) | |
| await websocket.send(json.dumps({ | |
| "type": "tts_response_end" | |
| })) | |
| # 16-bit PCM = 2 bytes per sample @ 24kHz | |
| audio_duration = len(wav_bytes) / 24000.0 / 2.0 | |
| await asyncio.sleep(audio_duration + 0.5) # +0.5s trailing silence buffer | |
| is_tts_playing = False | |
| asyncio.create_task(_play_and_mute()) | |
| except json.JSONDecodeError: | |
| pass | |
| except websockets.exceptions.ConnectionClosed: | |
| logging.info("Client disconnected from Voice WebSocket.") | |
| except Exception as e: | |
| logging.error(f"Voice WebSocket Error: {e}") | |
| async def start_server(self): | |
| self.stop_future = asyncio.Future() | |
| server = None | |
| for p in range(self.port, self.port + 11): | |
| try: | |
| server = await websockets.serve(self.handler, self.host, p) | |
| self.port = p | |
| logging.info(f"Voice WebSocket running on ws://{self.host}:{self.port}") | |
| break | |
| except OSError as e: | |
| if "address already in use" in str(e).lower() or "10048" in str(e): | |
| logging.warning(f"Voice WS Port {p} in use, trying next...") | |
| continue | |
| raise | |
| if server: | |
| await self.stop_future # run until stopped | |
| server.close() | |
| await server.wait_closed() | |
| logging.info("Voice WebSocket server shut down cleanly.") | |
| def stop(self): | |
| if self.stop_future and not self.stop_future.done(): | |
| self.stop_future.set_result(True) | |
| def run_in_thread(host="127.0.0.1", port=8767): | |
| import os | |
| if os.environ.get("CLOUD_ENV", "false").lower() == "true": | |
| logging.info("[AudioWS] Cloud mode — skipping local audio WebSocket server (no mic on HF Spaces).") | |
| return None | |
| server = AudioWebSocketServer(host=host, port=port) | |
| def _run(): | |
| loop = asyncio.new_event_loop() | |
| asyncio.set_event_loop(loop) | |
| loop.run_until_complete(server.start_server()) | |
| import threading | |
| t = threading.Thread(target=_run, daemon=True) | |
| t.start() | |
| return server | |
| if __name__ == "__main__": | |
| logging.basicConfig(level=logging.INFO) | |
| asyncio.run(AudioWebSocketServer().start_server()) | |