xCoDGAS

Untitled

Apr 21st, 2026
5
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 34.17 KB | None | 0 0
  1. šŸ“Œ Project State & Architecture Overview
  2.  
  3. Current State:
  4. We have successfully built a local, hybrid voice assistant bridge. The system consists of a Python orchestrator running on an ODROID-H4 (Intel N97, CPU-only for inference) that connects our local audio hardware to the OpenClaw WebSocket Gateway.
  5.  
  6. Core Stack:
  7.  
  8. Wake Word: openwakeword running locally with a custom-trained .onnx model.
  9.  
  10. VAD (Voice Activity Detection): webrtcvad to manage recording states and barge-in.
  11.  
  12. STT (Speech-to-Text): faster_whisper (int8, small model) running locally on CPU. We implemented a fallback mechanism: if Whisper's confidence is below 40%, it routes the audio to Google Cloud Speech-to-Text for high-precision parsing.
  13.  
  14. TTS (Text-to-Speech): Google Cloud TTS (Wavenet) running asynchronously in a dedicated queue thread.
  15.  
  16. UI: A local WebSocket server broadcasts states (idle, listening, thinking, speaking) to a Chromium kiosk running a custom Three.js visualizer (app.js + index.html).
  17.  
  18. Current Limitations & Roadblocks (Where we need advice):
  19.  
  20. STT CPU Latency (The Cold Start): Running faster_whisper on the CPU results in a massive cold-start delay on the first query (sometimes taking up to 19 seconds to transcribe 3 seconds of audio). We are planning to add a "dummy/empty" transcription on startup to warm up the RAM, but we'd love any architectural advice on optimizing local CPU transcription. Should we be streaming chunks to Whisper instead of waiting for the silence_to_stop_ms to trigger?
  21.  
  22. Turn-taking / End-of-Speech: We initially had the VAD silence threshold at 3000ms, which felt too slow. We plan to reduce it to ~1200ms, but finding the perfect balance between cutting the user off during a pause and responding snappily is tricky.
  23.  
  24. Below are the sanitized source files for a final code review.
  25. 1. Python Orchestrator (assistant_bridge.py)
  26. Python
  27.  
  28. #!/usr/bin/env python3
  29. import asyncio, json, time, subprocess, sys, os, queue, threading, collections, enum, unicodedata, re, math
  30. import base64, hashlib
  31. import numpy as np
  32. import sounddevice as sd
  33. import websockets
  34. import webrtcvad
  35. import openwakeword
  36. from openwakeword.model import Model
  37. from faster_whisper import WhisperModel
  38. from nacl.signing import SigningKey
  39.  
  40. # Cloud Config (Sanitized)
  41. os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "/path/to/your/google-key.json"
  42.  
  43. try:
  44. from google.cloud import speech
  45. speech_client = speech.SpeechClient()
  46. GOOGLE_STT_AVAILABLE = True
  47. except Exception:
  48. GOOGLE_STT_AVAILABLE = False
  49.  
  50. try:
  51. from google.cloud import texttospeech
  52. tts_client = texttospeech.TextToSpeechClient()
  53. GOOGLE_TTS_AVAILABLE = True
  54. except Exception:
  55. GOOGLE_TTS_AVAILABLE = False
  56.  
  57. GATEWAY_TOKEN = "YOUR_GATEWAY_TOKEN_HERE"
  58. URI = "ws://127.0.0.1:18789"
  59. WORKSPACE_PATH = "/path/to/your/workspace"
  60.  
  61. # ==========================================
  62. # UI WEBSOCKET BROADCASTER
  63. # ==========================================
  64. UI_CLIENTS = set()
  65. MAIN_LOOP = None
  66.  
  67. async def ui_broadcast(state_name):
  68. if UI_CLIENTS:
  69. msg = json.dumps({"state": state_name})
  70. await asyncio.gather(*[client.send(msg) for client in UI_CLIENTS], return_exceptions=True)
  71.  
  72. async def ui_server_handler(websocket):
  73. UI_CLIENTS.add(websocket)
  74. try:
  75. await websocket.wait_closed()
  76. finally:
  77. UI_CLIENTS.remove(websocket)
  78.  
  79. # ==========================================
  80. # AUDIO & SFX ENGINE
  81. # ==========================================
  82. THINKING_PROC = None
  83.  
  84. def play_sound(action):
  85. global THINKING_PROC
  86.  
  87. ui_map = {
  88. "wakeup": "listening",
  89. "thinking": "thinking",
  90. "stop_thinking": "speaking",
  91. "idle": "idle",
  92. "interrupt": "listening",
  93. "high_precision": "google"
  94. }
  95.  
  96. if action in ui_map:
  97. global MAIN_LOOP
  98. if MAIN_LOOP and MAIN_LOOP.is_running():
  99. asyncio.run_coroutine_threadsafe(ui_broadcast(ui_map[action]), MAIN_LOOP)
  100.  
  101. try:
  102. if action == "wakeup":
  103. subprocess.Popen(["play", "-q", f"{WORKSPACE_PATH}/wakeup.mp3"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  104. elif action == "thinking":
  105. THINKING_PROC = subprocess.Popen(["play", "-q", f"{WORKSPACE_PATH}/thinking.mp3", "repeat", "999"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  106. elif action == "stop_thinking":
  107. if THINKING_PROC and THINKING_PROC.poll() is None:
  108. THINKING_PROC.kill()
  109. THINKING_PROC = None
  110. elif action == "idle":
  111. subprocess.Popen(["play", "-q", f"{WORKSPACE_PATH}/idle.mp3", "trim", "0", "1"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  112. elif action == "high_precision":
  113. subprocess.Popen(["play", "-q", f"{WORKSPACE_PATH}/high_precision.mp3"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  114. elif action == "error":
  115. subprocess.Popen(["play", "-q", f"{WORKSPACE_PATH}/error.mp3"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  116. elif action == "interrupt":
  117. subprocess.Popen(["paplay", "/usr/share/sounds/freedesktop/stereo/window-attention.oga"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  118. except Exception: pass
  119.  
  120. # ==========================================
  121. # HELPER FUNCTIONS
  122. # ==========================================
  123. def b64url_no_pad(data: bytes) -> str:
  124. return base64.urlsafe_b64encode(data).decode('ascii').rstrip('=')
  125.  
  126. def norm_text(s: str) -> str:
  127. s = s.lower().strip()
  128. s = re.sub(r'[^\w\s]', '', s)
  129. s = unicodedata.normalize("NFKD", s)
  130. s = "".join(ch for ch in s if not unicodedata.combining(ch))
  131. return s.strip()
  132.  
  133. STOP_WORDS = {
  134. "goodbye", "stop listening", "cancel", "stop", "shut up"
  135. }
  136. STOP_WORDS_NORM = {norm_text(x) for x in STOP_WORDS}
  137.  
  138. class OpenClawWSClient:
  139. def __init__(self, uri, token):
  140. self.uri = uri
  141. self.token = token
  142. self.ws = None
  143. self.req_id = 2
  144. self.session_key = "agent:main:main"
  145. self.on_text = None
  146. self.on_final = None
  147. self.on_error = None
  148.  
  149. async def connect_and_loop(self):
  150. private_key = SigningKey.generate()
  151. public_key_bytes = private_key.verify_key.encode()
  152. device_id = hashlib.sha256(public_key_bytes).hexdigest()
  153.  
  154. while True:
  155. try:
  156. async with websockets.connect(self.uri) as ws:
  157. self.ws = ws
  158. msg = await ws.recv()
  159. nonce = json.loads(msg)["payload"]["nonce"]
  160. signed_at_ms = str(int(time.time() * 1000))
  161.  
  162. sig_string = f"v3|{device_id}|cli|backend|operator|operator.read,operator.write|{signed_at_ms}|{self.token}|{nonce}|linux|desktop"
  163. sig_str = b64url_no_pad(private_key.sign(sig_string.encode('utf-8')).signature)
  164.  
  165. auth_req = {
  166. "type": "req", "id": "auth-1", "method": "connect",
  167. "params": {
  168. "minProtocol": 3, "maxProtocol": 3,
  169. "client": {"id": "cli", "version": "1.0.0", "platform": "linux", "deviceFamily": "desktop", "mode": "backend"},
  170. "role": "operator", "scopes": ["operator.read", "operator.write"],
  171. "auth": {"token": self.token},
  172. "device": {"id": device_id, "publicKey": b64url_no_pad(public_key_bytes), "signature": sig_str, "signedAt": int(signed_at_ms), "nonce": nonce}
  173. }
  174. }
  175. await ws.send(json.dumps(auth_req))
  176. await ws.recv()
  177. print("āœ… Native Brain connected to Gateway!")
  178.  
  179. while True:
  180. msg = await ws.recv()
  181. obj = json.loads(msg)
  182. if obj.get("event") == "agent":
  183. stream = obj.get("payload", {}).get("stream")
  184. data = obj.get("payload", {}).get("data", {})
  185. if stream == "assistant" and "delta" in data:
  186. if self.on_text: self.on_text(data["delta"])
  187. elif stream == "lifecycle" and data.get("phase") == "end":
  188. if self.on_final: self.on_final("")
  189. except Exception as e:
  190. print(f"\nāŒ Connection lost. Reconnecting in 3s... ({e})")
  191. play_sound("error")
  192. if self.on_error: self.on_error(str(e))
  193. await asyncio.sleep(3)
  194.  
  195. async def send_chat(self, text):
  196. req = {"type": "req", "id": f"req-{self.req_id}", "method": "chat.send",
  197. "params": {"sessionKey": self.session_key, "message": text, "idempotencyKey": f"msg-{self.req_id}-{int(time.time()*1000)}"}}
  198. self.req_id += 1
  199. await self.ws.send(json.dumps(req))
  200.  
  201. async def abort_current(self):
  202. req = {"type": "req", "id": f"req-{self.req_id}", "method": "chat.abort", "params": {"sessionKey": self.session_key}}
  203. self.req_id += 1
  204. await self.ws.send(json.dumps(req))
  205.  
  206. class SentenceChunker:
  207. def __init__(self, on_chunk):
  208. self.buf = ""
  209. self.on_chunk = on_chunk
  210.  
  211. def push(self, text_delta: str):
  212. self.buf += text_delta
  213. while True:
  214. cut = -1
  215. for marker in [". ", "? ", "! ", "\n", ".\n"]:
  216. idx = self.buf.find(marker)
  217. if idx != -1:
  218. cut = idx + len(marker)
  219. break
  220. if cut == -1:
  221. if len(self.buf.split()) >= 15:
  222. chunk = self.buf.strip()
  223. self.buf = ""
  224. if chunk: self.on_chunk(chunk)
  225. break
  226. chunk = self.buf[:cut].strip()
  227. self.buf = self.buf[cut:].lstrip()
  228. if chunk: self.on_chunk(chunk)
  229.  
  230. def flush(self):
  231. tail = self.buf.strip()
  232. self.buf = ""
  233. if tail: self.on_chunk(tail)
  234.  
  235. def reset(self):
  236. self.buf = ""
  237.  
  238. # ==========================================
  239. # PREMIUM TTS ENGINE (GOOGLE CLOUD)
  240. # ==========================================
  241. class GoogleTTSThread(threading.Thread):
  242. def __init__(self):
  243. super().__init__(daemon=True)
  244. self.q = queue.Queue()
  245. self.idle = threading.Event()
  246. self.idle.set()
  247. self.player = None
  248. self.lock = threading.Lock()
  249. self.abort_flag = False
  250. self.is_speaking_now = False
  251.  
  252. def enqueue(self, text: str):
  253. text = text.strip()
  254. if text:
  255. self.idle.clear()
  256. self.q.put(text)
  257.  
  258. def stop_now(self):
  259. self.abort_flag = True
  260. try:
  261. while True: self.q.get_nowait()
  262. except queue.Empty: pass
  263. with self.lock:
  264. if self.player and self.player.poll() is None:
  265. self.player.kill()
  266. self.idle.set()
  267. self.is_speaking_now = False
  268.  
  269. def wait_until_idle(self):
  270. while not self.idle.is_set(): time.sleep(0.05)
  271.  
  272. def run(self):
  273. while True:
  274. text = self.q.get()
  275. if text is None: break
  276. self.abort_flag = False
  277.  
  278. if self.abort_flag:
  279. if self.q.empty(): self.idle.set()
  280. continue
  281.  
  282. try:
  283. synthesis_input = texttospeech.SynthesisInput(text=text)
  284. voice = texttospeech.VoiceSelectionParams(
  285. language_code="en-US", name="en-US-Wavenet-F"
  286. )
  287. audio_config = texttospeech.AudioConfig(
  288. audio_encoding=texttospeech.AudioEncoding.LINEAR16,
  289. sample_rate_hertz=24000
  290. )
  291.  
  292. response = tts_client.synthesize_speech(
  293. input=synthesis_input, voice=voice, audio_config=audio_config
  294. )
  295.  
  296. if self.abort_flag:
  297. if self.q.empty(): self.idle.set()
  298. continue
  299.  
  300. with self.lock:
  301. self.player = subprocess.Popen(
  302. ["paplay", "--raw", "--format=s16le", "--channels=1", "--rate", "24000"],
  303. stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
  304. )
  305.  
  306. self.is_speaking_now = True
  307. self.player.communicate(input=response.audio_content)
  308. self.is_speaking_now = False
  309.  
  310. except Exception as e:
  311. print(f"\nāš ļø [Cloud TTS Error]: {e}")
  312. self.is_speaking_now = False
  313. play_sound("error")
  314.  
  315. finally:
  316. if self.q.empty():
  317. self.idle.set()
  318.  
  319. class State(enum.Enum):
  320. IDLE = "idle"; RECORDING = "recording"; THINKING = "thinking"; SPEAKING = "speaking"; FOLLOWUP = "followup"
  321.  
  322. class VoiceLoop:
  323. def __init__(self, tts_engine):
  324. self.sample_rate = 16000
  325. self.frame_ms = 20
  326. self.audio_q = queue.Queue(maxsize=256)
  327. self.tts = tts_engine
  328.  
  329. self.wakeword_file = f"{WORKSPACE_PATH}/custom_wakeword.onnx"
  330.  
  331. self.oww = Model(wakeword_model_paths=[self.wakeword_file])
  332. self.wake_word_key = list(self.oww.models.keys())[0]
  333. self.vad = webrtcvad.Vad(3)
  334.  
  335. self.state = State.IDLE
  336. self.oww_batch = collections.deque(maxlen=4)
  337. self.preroll = collections.deque(maxlen=40)
  338. self.capture_frames = []
  339.  
  340. self.last_speech_ts = 0.0
  341. self.rearm_until = 0.0
  342. self.followup_deadline = 0.0
  343. self.silence_to_stop_ms = 3000
  344. self.min_utterance_ms = 400
  345.  
  346. self.continuous_mode = True
  347. self.followup_timeout_s = 5.0
  348.  
  349. self.barge_frames_needed = 20
  350. self.barge_speech_frames = 0
  351.  
  352. self.on_utterance_ready = None
  353. self.on_barge_in = None
  354.  
  355. def audio_callback(self, indata, frames, time_info, status):
  356. if status: play_sound("error")
  357. mono = indata[:, 0]
  358. pcm16 = (np.clip(mono, -1.0, 1.0) * 32767).astype(np.int16).tobytes()
  359. try: self.audio_q.put_nowait(pcm16)
  360. except queue.Full: pass
  361.  
  362. def drain_audio_q(self):
  363. try:
  364. while True: self.audio_q.get_nowait()
  365. except queue.Empty: pass
  366.  
  367. def rearm_idle(self, cooldown_s=1.0, play_idle_sound=False):
  368. play_sound("stop_thinking")
  369. if play_idle_sound or self.state == State.FOLLOWUP:
  370. play_sound("idle")
  371.  
  372. self.drain_audio_q()
  373. self.oww = Model(wakeword_model_paths=[self.wakeword_file])
  374. self.wake_word_key = list(self.oww.models.keys())[0]
  375. self.oww_batch.clear()
  376. self.preroll.clear()
  377. self.capture_frames.clear()
  378. self.last_speech_ts = 0.0
  379. self.barge_speech_frames = 0
  380. self.followup_deadline = 0.0
  381. self.rearm_until = time.monotonic() + cooldown_s
  382. self.set_state(State.IDLE)
  383.  
  384. def set_state(self, new_state: State):
  385. if new_state != self.state:
  386. print(f"\nšŸ”„ [{self.state.value.upper()} -> {new_state.value.upper()}]")
  387. self.state = new_state
  388.  
  389. ui_state = "idle"
  390. if new_state == State.RECORDING or new_state == State.FOLLOWUP: ui_state = "listening"
  391. elif new_state == State.THINKING: ui_state = "thinking"
  392. elif new_state == State.SPEAKING: ui_state = "speaking"
  393.  
  394. global MAIN_LOOP
  395. if MAIN_LOOP and MAIN_LOOP.is_running():
  396. asyncio.run_coroutine_threadsafe(ui_broadcast(ui_state), MAIN_LOOP)
  397.  
  398. def begin_recording(self, now: float):
  399. play_sound("wakeup")
  400. self.capture_frames = list(self.preroll)
  401. self.last_speech_ts = now
  402. self.barge_speech_frames = 0
  403. self.set_state(State.RECORDING)
  404.  
  405. def after_speaking_done(self):
  406. if self.continuous_mode:
  407. self.drain_audio_q()
  408. self.oww_batch.clear()
  409. self.preroll.clear()
  410. self.capture_frames.clear()
  411. self.barge_speech_frames = 0
  412. self.followup_deadline = time.monotonic() + self.followup_timeout_s
  413. self.set_state(State.FOLLOWUP)
  414. else:
  415. self.rearm_idle()
  416.  
  417. def finish_recording(self):
  418. audio_bytes = b"".join(self.capture_frames)
  419. self.capture_frames.clear()
  420. self.set_state(State.THINKING)
  421. if self.on_utterance_ready: self.on_utterance_ready(audio_bytes)
  422.  
  423. def process_frame(self, frame_bytes):
  424. now = time.monotonic()
  425.  
  426. if self.tts.is_speaking_now:
  427. self.barge_speech_frames = 0
  428. return
  429.  
  430. is_speech = self.vad.is_speech(frame_bytes, 16000)
  431.  
  432. if self.state in (State.IDLE, State.RECORDING, State.THINKING, State.SPEAKING, State.FOLLOWUP):
  433. self.preroll.append(frame_bytes)
  434.  
  435. if self.state in (State.THINKING, State.SPEAKING):
  436. if is_speech: self.barge_speech_frames += 1
  437. else: self.barge_speech_frames = 0
  438.  
  439. if self.barge_speech_frames >= self.barge_frames_needed:
  440. if self.on_barge_in: self.on_barge_in()
  441. self.begin_recording(now)
  442. return
  443.  
  444. if self.state == State.FOLLOWUP:
  445. if now >= self.followup_deadline:
  446. print("ā³ 5s timeout reached. Going to sleep...")
  447. self.rearm_idle()
  448. return
  449. if is_speech: self.begin_recording(now)
  450. return
  451.  
  452. if self.state == State.IDLE and now < self.rearm_until: return
  453.  
  454. if self.state == State.RECORDING:
  455. self.capture_frames.append(frame_bytes)
  456. if is_speech: self.last_speech_ts = now
  457. silence_ms = (now - self.last_speech_ts) * 1000.0
  458. sys.stdout.write(f"\r🤫 Silence: {silence_ms/1000:.1f}s / {self.silence_to_stop_ms/1000}s ")
  459. sys.stdout.flush()
  460. utterance_ms = len(self.capture_frames) * self.frame_ms
  461. if utterance_ms >= self.min_utterance_ms and silence_ms >= self.silence_to_stop_ms:
  462. self.finish_recording()
  463. return
  464.  
  465. if self.state == State.IDLE:
  466. self.oww_batch.append(frame_bytes)
  467. if len(self.oww_batch) < 4: return
  468. chunk_80ms = b"".join(self.oww_batch)
  469. self.oww_batch.clear()
  470. if self.oww.predict(np.frombuffer(chunk_80ms, dtype=np.int16)).get(self.wake_word_key, 0.0) >= 0.10:
  471. print(f"\nšŸ”” Wake Word detected!")
  472. self.begin_recording(now)
  473.  
  474. def run(self):
  475. try:
  476. with sd.InputStream(samplerate=16000, channels=1, dtype="float32", blocksize=320, callback=self.audio_callback):
  477. print("šŸŽ§ Assistant listening for Wake Word...")
  478. while True: self.process_frame(self.audio_q.get())
  479. except Exception as e:
  480. print(f"āŒ Fatal mic error: {e}")
  481. play_sound("error")
  482.  
  483. class AssistantApp:
  484. def __init__(self):
  485. print("\nāš™ļø Initializing AI modules...")
  486. self.whisper = WhisperModel("small", device="cpu", compute_type="int8")
  487.  
  488. if GOOGLE_STT_AVAILABLE:
  489. print("ā˜ļø Cloud Connection (Backup Ears): Ready")
  490. else:
  491. print("šŸ’» Cloud Connection (Ears): Inactive.")
  492.  
  493. if GOOGLE_TTS_AVAILABLE:
  494. print("ā˜ļø Cloud Connection (Premium Voice): Ready")
  495. else:
  496. print("šŸ’» ERROR: Google TTS unavailable. Check keys!")
  497.  
  498. self.ws_loop = asyncio.new_event_loop()
  499. global MAIN_LOOP
  500. MAIN_LOOP = self.ws_loop
  501.  
  502. self.ws = OpenClawWSClient(URI, GATEWAY_TOKEN)
  503.  
  504. self.tts = GoogleTTSThread()
  505. self.chunker = SentenceChunker(self.tts.enqueue)
  506.  
  507. self.voice = VoiceLoop(self.tts)
  508.  
  509. self.run_finished = threading.Event()
  510. self.run_finished.set()
  511.  
  512. self.current_turn_text = ""
  513. self.t_start_ws = 0.0
  514. self.first_word = False
  515.  
  516. self.voice.on_utterance_ready = self.handle_utterance
  517. self.voice.on_barge_in = self.handle_barge_in
  518. self.ws.on_text = self.handle_llm_delta
  519. self.ws.on_final = self.handle_llm_final
  520. self.ws.on_error = self.handle_llm_error
  521.  
  522. def start(self):
  523. html_path = f"{WORKSPACE_PATH}/voiceorb/index.html"
  524. if not os.path.exists(html_path):
  525. print(f"\nāŒ ERROR: File not found at: {html_path}")
  526. else:
  527. print("🌐 Launching Assistant UI...")
  528. subprocess.Popen(["chromium", "--kiosk", f"file://{html_path}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  529.  
  530. threading.Thread(target=self._start_ws, daemon=True).start()
  531. self.tts.start()
  532. time.sleep(1)
  533. self.voice.run()
  534.  
  535. async def run_all_services(self):
  536. print("šŸ“ŗ Visual Server activated on port 8765")
  537. async with websockets.serve(ui_server_handler, "127.0.0.1", 8765):
  538. await self.ws.connect_and_loop()
  539.  
  540. def _start_ws(self):
  541. asyncio.set_event_loop(self.ws_loop)
  542. self.ws_loop.run_until_complete(self.run_all_services())
  543.  
  544. def _run_ws(self, coro, timeout=10.0):
  545. try:
  546. future = asyncio.run_coroutine_threadsafe(coro, self.ws_loop)
  547. return future.result(timeout=timeout)
  548. except Exception as e:
  549. raise RuntimeError(f"Timeout or Gateway failure: {e}")
  550.  
  551. def handle_utterance(self, audio_bytes):
  552. play_sound("thinking")
  553. threading.Thread(target=self._transcribe, args=(audio_bytes,), daemon=True).start()
  554.  
  555. def _transcribe(self, audio_bytes):
  556. t_start_stt = time.time()
  557. audio = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
  558. segs, info = self.whisper.transcribe(audio, language="en")
  559. segments = list(segs)
  560. text = " ".join(s.text.strip() for s in segments).strip()
  561.  
  562. if segments:
  563. avg_prob = sum(math.exp(s.avg_logprob) for s in segments) / len(segments)
  564. confidence = avg_prob * 100
  565. else:
  566. confidence = 0.0
  567.  
  568. print(f"\n ā±ļø [STT Whisper] ({time.time() - t_start_stt:.2f}s) | Confidence: {confidence:.1f}%")
  569.  
  570. if not text:
  571. play_sound("stop_thinking")
  572. self.voice.rearm_idle()
  573. return
  574.  
  575. text_norm = norm_text(text)
  576. final_engine = "Local Whisper"
  577.  
  578. if GOOGLE_STT_AVAILABLE:
  579. needs_help = confidence < 40.0
  580. if needs_help:
  581. play_sound("high_precision")
  582. try:
  583. audio_google = speech.RecognitionAudio(content=audio_bytes)
  584. config = speech.RecognitionConfig(
  585. encoding=speech.RecognitionConfig.AudioEncoding.LINEAR16,
  586. sample_rate_hertz=16000, language_code="en-US",
  587. )
  588. response = speech_client.recognize(config=config, audio=audio_google)
  589. if response.results:
  590. text = response.results[0].alternatives[0].transcript
  591. text_norm = norm_text(text)
  592. final_engine = "Google Cloud"
  593. except Exception:
  594. play_sound("error")
  595.  
  596. print(f"šŸ‘¤ User ({final_engine}): {text}")
  597.  
  598. if self.voice.state == State.IDLE:
  599. play_sound("stop_thinking")
  600. return
  601.  
  602. if any(word in text_norm for word in STOP_WORDS_NORM):
  603. play_sound("stop_thinking")
  604. self.voice.rearm_idle(play_idle_sound=True)
  605. return
  606.  
  607. self.run_finished.clear()
  608. self.current_turn_text = ""
  609. self.first_word = False
  610. self.voice.set_state(State.THINKING)
  611.  
  612. self.t_start_ws = time.time()
  613. try:
  614. self._run_ws(self.ws.send_chat(text))
  615. except Exception as e:
  616. print(f"[Connection Error] {e}")
  617. play_sound("error")
  618. play_sound("stop_thinking")
  619. self.run_finished.set()
  620. self.voice.rearm_idle()
  621.  
  622. def handle_llm_delta(self, text_delta):
  623. if not self.first_word:
  624. play_sound("stop_thinking")
  625. print(f" ā±ļø [TTFT]: {time.time() - self.t_start_ws:.2f}s")
  626. self.first_word = True
  627. if self.voice.state == State.THINKING:
  628. self.voice.set_state(State.SPEAKING)
  629. sys.stdout.write(text_delta); sys.stdout.flush()
  630. self.current_turn_text += text_delta
  631. self.chunker.push(text_delta)
  632.  
  633. def handle_llm_final(self, full_text):
  634. print("\n")
  635. play_sound("stop_thinking")
  636. self.chunker.flush()
  637. self.run_finished.set()
  638. threading.Thread(target=self._wait_tts, daemon=True).start()
  639.  
  640. def handle_llm_error(self, err):
  641. play_sound("error")
  642. play_sound("stop_thinking")
  643. self.chunker.reset(); self.tts.stop_now(); self.run_finished.set(); self.voice.rearm_idle()
  644.  
  645. def _wait_tts(self):
  646. self.tts.wait_until_idle()
  647. if self.run_finished.is_set() and self.voice.state in (State.THINKING, State.SPEAKING):
  648. self.voice.after_speaking_done()
  649.  
  650. def handle_barge_in(self):
  651. play_sound("stop_thinking")
  652. play_sound("interrupt")
  653. print("\nšŸ›‘ INTERRUPTION DETECTED! Canceling audio...")
  654. self.chunker.reset()
  655. self.tts.stop_now()
  656. try: self._run_ws(self.ws.abort_current())
  657. except Exception: pass
  658. self.run_finished.set()
  659.  
  660. if __name__ == "__main__":
  661. import warnings
  662. warnings.filterwarnings("ignore", category=UserWarning)
  663. try: AssistantApp().start()
  664. except KeyboardInterrupt: print("\nSee you later!")
  665.  
  666. 2. Three.js Visualizer (app.js)
  667. JavaScript
  668.  
  669. let scene, camera, renderer, orbLayers = [], currentState = 'idle';
  670. let targetScale = 1.0, currentScale = 1.0;
  671.  
  672. // Color and State Configurations
  673. const states = {
  674. idle: { // Pale and smaller
  675. layers: [
  676. { color: 0x2a2e5e, opacity: 0.1, scale: 0.7, rotationSpeed: { x: 0.0005, y: 0.001 } },
  677. { color: 0x2a2e5e, opacity: 0.1, scale: 0.6, rotationSpeed: { x: -0.001, y: 0.0015 } }
  678. ],
  679. timeSpeed: 0.008, pulsate: false, chromaticAberration: 0.2, description: 'Sleeping'
  680. },
  681. listening: {
  682. layers: [
  683. { color: 0x434FCF, opacity: 0.2, scale: 1.0, rotationSpeed: { x: 0.002, y: 0.004 } },
  684. { color: 0x434FCF, opacity: 0.4, scale: 0.7, rotationSpeed: { x: 0.004, y: -0.003 } }
  685. ],
  686. timeSpeed: 0.022, pulsate: true, pulsateMin: 0.02, pulsateMax: 0.2, chromaticAberration: 1.2, description: 'Listening'
  687. },
  688. google: { // Visual bridge: Electric Cyan for High Precision parsing
  689. layers: [
  690. { color: 0x00f2ff, opacity: 0.3, scale: 1.1, rotationSpeed: { x: 0.008, y: 0.008 } },
  691. { color: 0x00f2ff, opacity: 0.5, scale: 0.8, rotationSpeed: { x: -0.01, y: 0.01 } }
  692. ],
  693. timeSpeed: 0.05, pulsate: true, pulsateMin: 0.1, pulsateMax: 0.4, chromaticAberration: 2.5, description: 'High Precision'
  694. },
  695. thinking: {
  696. layers: [
  697. { color: 0x8747F7, opacity: 0.2, scale: 0.85, rotationSpeed: { x: 0.003, y: 0.003 } },
  698. { color: 0x8747F7, opacity: 0.4, scale: 0.60, rotationSpeed: { x: 0.005, y: -0.004 } }
  699. ],
  700. timeSpeed: 0.02, pulsate: true, pulsateMin: 0.0, pulsateMax: 0.15, chromaticAberration: 0.8, description: 'Thinking'
  701. },
  702. speaking: {
  703. layers: [
  704. { color: 0xFF1893, opacity: 0.2, scale: 1.0, rotationSpeed: { x: 0.004, y: 0.005 } },
  705. { color: 0xFF1893, opacity: 0.4, scale: 0.70, rotationSpeed: { x: 0.006, y: -0.005 } }
  706. ],
  707. timeSpeed: 0.027, pulsate: true, pulsateMin: 0.05, pulsateMax: 0.22, chromaticAberration: 1.5, description: 'Speaking'
  708. }
  709. };
  710.  
  711. const vertexShader = `
  712. varying vec3 vNormal;
  713. varying vec3 vPosition;
  714. uniform float time;
  715. uniform float audioLevel;
  716. void main() {
  717. vNormal = normalize(normalMatrix * normal);
  718. vec3 pos = position;
  719. float distortion = sin(pos.y * 3.0 + time) * 0.02 * (1.0 + audioLevel);
  720. pos = pos + normal * distortion;
  721. vPosition = pos;
  722. gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
  723. }
  724. `;
  725.  
  726. const fragmentShader = `
  727. varying vec3 vNormal;
  728. varying vec3 vPosition;
  729. uniform vec3 sphereColor;
  730. uniform float opacity;
  731. uniform float chromaticAberration;
  732. void main() {
  733. vec3 viewDirection = normalize(cameraPosition - vPosition);
  734. float fresnel = pow(1.0 - abs(dot(viewDirection, normalize(vNormal))), 2.0);
  735. vec3 color = sphereColor + (fresnel * chromaticAberration * 0.3);
  736. gl_FragColor = vec4(color, opacity + (fresnel * 0.4));
  737. }
  738. `;
  739.  
  740. function init() {
  741. scene = new THREE.Scene();
  742. camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
  743. camera.position.z = 5;
  744. const canvas = document.getElementById('canvas');
  745. renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
  746. renderer.setSize(window.innerWidth, window.innerHeight);
  747.  
  748. // Create 2 fixed layers to allow smooth transitions
  749. for (let i = 0; i < 2; i++) {
  750. const geometry = new THREE.SphereGeometry(1, 64, 64);
  751. const material = new THREE.ShaderMaterial({
  752. vertexShader, fragmentShader,
  753. uniforms: {
  754. time: { value: 0 }, audioLevel: { value: 0 },
  755. sphereColor: { value: new THREE.Color(0x000000) },
  756. opacity: { value: 0 }, chromaticAberration: { value: 0 }
  757. },
  758. transparent: true, depthWrite: false
  759. });
  760. const sphere = new THREE.Mesh(geometry, material);
  761. sphere.userData = { currentScale: 0.1, targetScale: 0.1, rot: { x: 0, y: 0 } };
  762. scene.add(sphere);
  763. orbLayers.push(sphere);
  764. }
  765.  
  766. window.addEventListener('resize', () => {
  767. camera.aspect = window.innerWidth / window.innerHeight;
  768. camera.updateProjectionMatrix();
  769. renderer.setSize(window.innerWidth, window.innerHeight);
  770. });
  771. animate();
  772. }
  773.  
  774. function setState(stateName) {
  775. if (!states[stateName]) return;
  776. currentState = stateName;
  777. const statusEl = document.getElementById('status');
  778. if (statusEl) {
  779. statusEl.textContent = states[stateName].description;
  780. }
  781. }
  782.  
  783. function animate() {
  784. requestAnimationFrame(animate);
  785. const state = states[currentState];
  786. const lerpSpeed = 0.05; // Controls the smoothness of the transition
  787.  
  788. orbLayers.forEach((layer, index) => {
  789. const config = state.layers[index] || { color: 0x000000, opacity: 0, scale: 0, rotationSpeed: { x: 0, y: 0 } };
  790. const uniforms = layer.material.uniforms;
  791.  
  792. // Smooth Color Interpolation
  793. uniforms.sphereColor.value.lerp(new THREE.Color(config.color), lerpSpeed);
  794.  
  795. // Smooth Opacity and Aberration Interpolation
  796. uniforms.opacity.value += (config.opacity - uniforms.opacity.value) * lerpSpeed;
  797. uniforms.chromaticAberration.value += (state.chromaticAberration - uniforms.chromaticAberration.value) * lerpSpeed;
  798.  
  799. // Smooth Scale Interpolation
  800. let pulse = state.pulsate ? (Math.sin(Date.now() * 0.005) * (state.pulsateMax - state.pulsateMin)) : 0;
  801. let tScale = config.scale + pulse;
  802. layer.scale.setScalar(layer.scale.x + (tScale - layer.scale.x) * lerpSpeed);
  803.  
  804. // Animation
  805. uniforms.time.value += state.timeSpeed;
  806. layer.rotation.x += config.rotationSpeed.x;
  807. layer.rotation.y += config.rotationSpeed.y;
  808. });
  809.  
  810. renderer.render(scene, camera);
  811. }
  812.  
  813. function connectToAssistant() {
  814. const socket = new WebSocket('ws://127.0.0.1:8765');
  815.  
  816. socket.onopen = () => {
  817. const statusEl = document.getElementById('status');
  818. if (statusEl) statusEl.textContent = 'Assistant Online';
  819. };
  820.  
  821. socket.onmessage = (event) => {
  822. try {
  823. const data = JSON.parse(event.data);
  824. if (data.state) setState(data.state);
  825. } catch (e) {}
  826. };
  827. socket.onclose = () => setTimeout(connectToAssistant, 2000);
  828. }
  829.  
  830. window.addEventListener('DOMContentLoaded', () => {
  831. init();
  832. connectToAssistant();
  833. });
  834.  
  835. 3. Visual UI Base (index.html)
  836. HTML
  837.  
  838. <!DOCTYPE html>
  839. <html lang="en">
  840. <head>
  841. <meta charset="UTF-8">
  842. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  843. <title>Voice Assistant UI - Smooth</title>
  844. <link rel="stylesheet" href="style.css">
  845. <style>
  846. body {
  847. cursor: none;
  848. background-color: #000;
  849. margin: 0;
  850. overflow: hidden;
  851. }
  852. #controls {
  853. pointer-events: none;
  854. border: none;
  855. background: none;
  856. }
  857. .state-buttons, #startBtn, .theme-toggle {
  858. display: none !important;
  859. }
  860. #status {
  861. bottom: 30px;
  862. opacity: 0.3;
  863. font-size: 12px;
  864. letter-spacing: 2px;
  865. text-transform: uppercase;
  866. color: white;
  867. position: fixed;
  868. width: 100%;
  869. text-align: center;
  870. }
  871. </style>
  872. </head>
  873. <body>
  874. <div id="container">
  875. <canvas id="canvas"></canvas>
  876. <div id="controls">
  877. <div id="status">Connecting...</div>
  878. </div>
  879. </div>
  880. <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
  881. <script src="app.js"></script>
  882. </body>
  883. </html>
Add Comment
Please, Sign In to add comment