worker.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768
  1. """
  2. Full Pipeline Worker: VAD -> ASR -> LLM -> TTS
  3. VAD: Silero VAD (ONNX) with dual-threshold + sliding window
  4. VP: AEC/ANS/AGC handled by LiveKit WebRTC
  5. ASR: Qwen3-ASR (local model, batch on speech-end)
  6. LLM: streaming via OpenAI-compatible API (vLLM), <think> tag filtering
  7. TTS: Mimo API (remote), xiaozhi-style two-tier sentence segmentation
  8. """
  9. from __future__ import annotations
  10. import asyncio
  11. import base64
  12. import json
  13. import logging
  14. import os
  15. import re
  16. import sys
  17. import time
  18. from collections import deque
  19. from dataclasses import dataclass, field
  20. import numpy as np
  21. try:
  22. import onnxruntime
  23. _HAS_ONNX = True
  24. except ImportError:
  25. _HAS_ONNX = False
  26. import aiohttp
  27. import jwt
  28. from livekit import rtc
  29. _root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "whisper_asr")
  30. sys.path.insert(0, _root)
  31. from audio_processor import AudioBuffer
  32. from qwen_engine import QwenASREngine
  33. try:
  34. from voiceprint import get_store, save_conversation, load_conversation_context
  35. _VP_ENABLED = True
  36. except ImportError:
  37. _VP_ENABLED = False
  38. def get_store(): return None
  39. def save_conversation(*a): pass
  40. def load_conversation_context(*a): return ""
  41. logger = logging.getLogger("worker")
  42. # ── Env ──
  43. LIVEKIT_URL = os.environ.get("LIVEKIT_URL", "ws://localhost:7880")
  44. ASR_MODEL = os.environ.get("ASR_MODEL_PATH", "Qwen/Qwen3-ASR-0.6B")
  45. VLLM_URL = os.environ.get("VLLM_URL", "http://127.0.0.1:8000/v1")
  46. LLM_MODEL = os.environ.get("LLM_MODEL", "qwen3.6-35b-awq")
  47. MIMO_KEY = os.environ.get("MIMO_KEY", "")
  48. MIMO_API_BASE = os.environ.get("MIMO_API_BASE", "https://token-plan-cn.xiaomimimo.com/v1")
  49. # Provider switches: "vllm" | "mimo" for LLM, "qwen" | "mimo" for ASR
  50. LLM_PROVIDER = os.environ.get("LLM_PROVIDER", "vllm")
  51. ASR_PROVIDER = os.environ.get("ASR_PROVIDER", "qwen")
  52. # ── LLM ──
  53. LLM_MAX_TOKENS = 128
  54. LLM_TEMPERATURE = 0.7
  55. LLM_TIMEOUT = 25
  56. SYSTEM_PROMPT = (
  57. "你是狄诺尼试验员,一名专业的工程试验助手。"
  58. "回答简洁自然,2-3句即可,用口语化中文。"
  59. "不要使用 Markdown、代码块、表格或特殊符号。"
  60. "不要输出括号注释、不要使用英文缩写。"
  61. "如果系统提供了说话人信息,请根据对方的身份调整语气,"
  62. "但绝对不要在回复中说出或暗示说话人的名字或代号。"
  63. )
  64. # ── TTS segmentation (xiaozhi-style two-tier) ──
  65. FIRST_SENTENCE_PUNCT = ",、,。!?;::\n"
  66. SENTENCE_END_PUNCT = "。!?!?\n"
  67. MIN_TTS_CHARS = 2
  68. # ═══════════════════════════════════════════════════════════════
  69. # JWT
  70. # ═══════════════════════════════════════════════════════════════
  71. def _token(room, ident):
  72. n = int(time.time())
  73. return jwt.encode({
  74. "iss": "devkey", "sub": ident, "name": ident,
  75. "nbf": n - 60, "exp": n + 6 * 3600,
  76. "video": {"roomJoin": True, "room": room,
  77. "canPublish": True, "canSubscribe": True, "canPublishData": True},
  78. }, "secretsecretsecretsecretsecret12", algorithm="HS256")
  79. # ═══════════════════════════════════════════════════════════════
  80. # LLM (streaming, <think> filtering)
  81. # ═══════════════════════════════════════════════════════════════
  82. async def _llm_stream(prompt: str, hist: list[dict], speaker: str | None = None, speaker_id: str | None = None):
  83. """Stream LLM tokens, dispatching based on LLM_PROVIDER env."""
  84. if LLM_PROVIDER == "mimo":
  85. async for x in _llm_stream_mimo(prompt, hist, speaker, speaker_id):
  86. yield x
  87. return
  88. async for x in _llm_stream_vllm(prompt, hist, speaker, speaker_id):
  89. yield x
  90. async def _llm_stream_vllm(prompt: str, hist: list[dict], speaker: str | None = None, speaker_id: str | None = None):
  91. """vLLM streaming via OpenAI-compatible SSE."""
  92. msgs = [{"role": "system", "content": SYSTEM_PROMPT}]
  93. if speaker:
  94. msgs.append({"role": "system", "content": f"[内部上下文] 当前说话人: {speaker}。请根据对方身份自然地调整回应风格,但不要在回复中主动提及说话人的名字。"})
  95. # Conversation memory context
  96. mem_ctx = load_conversation_context(speaker_id, speaker) if speaker_id else ""
  97. if mem_ctx:
  98. msgs.append({"role": "system", "content": mem_ctx})
  99. msgs.extend(hist)
  100. msgs.append({"role": "user", "content": prompt})
  101. async with aiohttp.ClientSession() as s:
  102. async with s.post(
  103. f"{VLLM_URL}/chat/completions",
  104. json={"model": LLM_MODEL, "messages": msgs,
  105. "max_tokens": LLM_MAX_TOKENS, "temperature": LLM_TEMPERATURE,
  106. "stream": True,
  107. "chat_template_kwargs": {"enable_thinking": False}},
  108. timeout=aiohttp.ClientTimeout(total=LLM_TIMEOUT),
  109. ) as r:
  110. async for line in r.content:
  111. line = line.decode().strip()
  112. if not line.startswith("data: "):
  113. continue
  114. data = line[6:]
  115. if data == "[DONE]":
  116. break
  117. try:
  118. chunk = json.loads(data)
  119. delta = chunk.get("choices", [{}])[0].get("delta", {})
  120. text = delta.get("content", "")
  121. if text:
  122. if "</think>" in text:
  123. text = text.split("</think>")[-1]
  124. if "<think>" in text:
  125. text = text.split("<think>")[0]
  126. if text.strip():
  127. yield text, False
  128. except Exception:
  129. continue
  130. yield "", True
  131. async def _llm_stream_mimo(prompt: str, hist: list[dict], speaker: str | None = None, speaker_id: str | None = None):
  132. """Mimo v2.5 LLM streaming via OpenAI-compatible SSE."""
  133. if not MIMO_KEY:
  134. yield "", True
  135. return
  136. msgs = [{"role": "system", "content": SYSTEM_PROMPT}]
  137. if speaker:
  138. msgs.append({"role": "system", "content": f"[内部上下文] 当前说话人: {speaker}。请根据对方身份自然地调整回应风格,但不要在回复中主动提及说话人的名字。"})
  139. mem_ctx = load_conversation_context(speaker_id, speaker) if speaker_id else ""
  140. if mem_ctx:
  141. msgs.append({"role": "system", "content": mem_ctx})
  142. msgs.extend(hist)
  143. msgs.append({"role": "user", "content": prompt})
  144. h = {"api-key": MIMO_KEY, "Content-Type": "application/json"}
  145. async with aiohttp.ClientSession() as s:
  146. async with s.post(
  147. f"{MIMO_API_BASE}/chat/completions",
  148. json={"model": "mimo-v2.5", "messages": msgs,
  149. "max_tokens": LLM_MAX_TOKENS, "temperature": LLM_TEMPERATURE,
  150. "stream": True},
  151. headers=h, timeout=aiohttp.ClientTimeout(total=LLM_TIMEOUT),
  152. ) as r:
  153. async for line in r.content:
  154. line = line.decode().strip()
  155. if not line.startswith("data: "):
  156. continue
  157. data = line[6:]
  158. if data == "[DONE]":
  159. break
  160. try:
  161. chunk = json.loads(data)
  162. delta = chunk.get("choices", [{}])[0].get("delta", {})
  163. text = delta.get("content", "")
  164. if text and text.strip():
  165. yield text, False
  166. except Exception:
  167. continue
  168. yield "", True
  169. # ═══════════════════════════════════════════════════════════════
  170. # ASR (dispatch: qwen-local | mimo-api)
  171. # ═══════════════════════════════════════════════════════════════
  172. async def _asr_transcribe(audio_data: np.ndarray, asr_engine=None) -> str:
  173. """Dispatch ASR based on ASR_PROVIDER env. Return final text."""
  174. if ASR_PROVIDER == "mimo":
  175. text = ""
  176. async for chunk, is_done in _asr_mimo_stream(audio_data):
  177. if chunk:
  178. text += chunk
  179. text = text.strip()
  180. else:
  181. loop = asyncio.get_running_loop()
  182. result = await loop.run_in_executor(None, asr_engine.transcribe_array, audio_data)
  183. text = result.get("text", "").strip()
  184. # Filter single-char filler results (common ASR noise)
  185. fillers = {"嗯", "啊", "哦", "呃", "咦", "噢", "唔", "哎"}
  186. if text and len(text) <= 2 and all(c in "嗯啊哦呃咦噢唔哎?!。,、~·~" for c in text):
  187. logger.info("ASR filtered filler: %s", text)
  188. return ""
  189. return text
  190. async def _asr_mimo_stream(audio_data: np.ndarray):
  191. """Mimo v2.5 streaming ASR. Yields (text_snapshot, is_final)."""
  192. if not MIMO_KEY:
  193. yield "", True
  194. return
  195. import io, wave
  196. buf = io.BytesIO()
  197. with wave.open(buf, "wb") as wf:
  198. wf.setnchannels(1)
  199. wf.setsampwidth(2)
  200. wf.setframerate(16000)
  201. wf.writeframes((audio_data * 32767).astype(np.int16).tobytes())
  202. audio_b64 = base64.b64encode(buf.getvalue()).decode()
  203. h = {"api-key": MIMO_KEY, "Content-Type": "application/json"}
  204. async with aiohttp.ClientSession() as s:
  205. async with s.post(
  206. f"{MIMO_API_BASE}/chat/completions",
  207. json={
  208. "model": "mimo-v2.5-asr",
  209. "messages": [{"role": "user", "content": [
  210. {"type": "input_audio", "input_audio": {
  211. "data": f"data:audio/wav;base64,{audio_b64}"}}
  212. ]}],
  213. "stream": True,
  214. "asr_options": {"language": "auto"},
  215. },
  216. headers=h, timeout=aiohttp.ClientTimeout(total=20),
  217. ) as r:
  218. async for line in r.content:
  219. line = line.decode().strip()
  220. if not line.startswith("data: "):
  221. continue
  222. data = line[6:]
  223. if data == "[DONE]":
  224. yield "", True
  225. return
  226. try:
  227. chunk = json.loads(data)
  228. delta = chunk.get("choices", [{}])[0].get("delta", {})
  229. text = delta.get("content", "")
  230. if text:
  231. yield text, False
  232. except Exception:
  233. continue
  234. yield "", True
  235. # ═══════════════════════════════════════════════════════════════
  236. # TTS
  237. # ═══════════════════════════════════════════════════════════════
  238. async def _tts(text: str, voice: str = "Chloe") -> np.ndarray | None:
  239. """Mimo TTS. Returns int16 PCM at 24kHz mono, or None on failure."""
  240. if not MIMO_KEY:
  241. return None
  242. if voice.startswith("data:"):
  243. model = "mimo-v2.5-tts-voiceclone"
  244. else:
  245. model = "mimo-v2.5-tts"
  246. h = {"api-key": MIMO_KEY, "Content-Type": "application/json"}
  247. body: dict = {
  248. "model": model,
  249. "messages": [
  250. {"role": "user", "content": ""},
  251. {"role": "assistant", "content": text},
  252. ],
  253. "audio": {"format": "wav", "voice": voice},
  254. }
  255. async with aiohttp.ClientSession() as s:
  256. try:
  257. async with s.post(
  258. f"{MIMO_API_BASE}/chat/completions",
  259. json=body, headers=h,
  260. timeout=aiohttp.ClientTimeout(total=30),
  261. ) as r:
  262. result = await r.json()
  263. b64 = result.get("choices", [{}])[0].get("message", {}).get("audio", {}).get("data", "")
  264. if b64:
  265. return np.frombuffer(base64.b64decode(b64), dtype=np.int16)
  266. except Exception:
  267. logger.exception("TTS failed")
  268. return None
  269. def _clean_tts_text(text: str) -> str:
  270. text = re.sub(r"\*{1,3}(.*?)\*{1,3}", r"\1", text)
  271. text = re.sub(r"`{1,3}.*?`{1,3}", "", text)
  272. text = re.sub(r"\[([^\]]+)]\([^)]+\)", r"\1", text)
  273. text = re.sub(r"#{1,6}\s*", "", text)
  274. text = re.sub(r"[>\-]\s", "", text)
  275. text = re.sub(r"[\U0001F300-\U0001F9FF]", "", text)
  276. return text.strip()
  277. # ═══════════════════════════════════════════════════════════════
  278. # TTS Segmenter (xiaozhi-style: two-tier, processed_chars)
  279. # ═══════════════════════════════════════════════════════════════
  280. @dataclass
  281. class _TTSSegmenter:
  282. buffer: str = ""
  283. processed: int = 0
  284. is_first: bool = True
  285. def feed(self, text: str) -> list[str]:
  286. self.buffer += text
  287. segments = []
  288. puncts = FIRST_SENTENCE_PUNCT if self.is_first else SENTENCE_END_PUNCT
  289. unprocessed = self.buffer[self.processed:]
  290. last_pos = -1
  291. for ch in puncts:
  292. pos = unprocessed.rfind(ch)
  293. if pos > last_pos:
  294. last_pos = pos
  295. if last_pos >= 0:
  296. seg_raw = unprocessed[:last_pos + 1]
  297. seg_clean = _clean_tts_text(seg_raw)
  298. if len(seg_clean) >= MIN_TTS_CHARS:
  299. segments.append(seg_clean)
  300. self.processed += len(seg_raw)
  301. if self.is_first:
  302. self.is_first = False
  303. return segments
  304. def flush(self) -> str:
  305. remaining = self.buffer[self.processed:].strip()
  306. if remaining:
  307. remaining = _clean_tts_text(remaining)
  308. self.processed = len(self.buffer)
  309. return remaining
  310. # ── VAD ──
  311. VAD_MODEL_PATH = os.environ.get("VAD_MODEL_PATH") or os.path.join(
  312. os.path.dirname(os.path.abspath(__file__)), "whisper_asr", "silero_vad.onnx")
  313. VAD_THRESHOLD_HIGH = float(os.environ.get("VAD_THRESHOLD_HIGH", "0.5"))
  314. VAD_THRESHOLD_LOW = float(os.environ.get("VAD_THRESHOLD_LOW", "0.2"))
  315. VAD_WINDOW_SIZE = int(os.environ.get("VAD_WINDOW_SIZE", "5"))
  316. VAD_VOICE_RATIO = float(os.environ.get("VAD_VOICE_RATIO", "0.5"))
  317. MIN_SPEECH_S = float(os.environ.get("MIN_SPEECH_S", "0.3"))
  318. MIN_SILENCE_S = float(os.environ.get("MIN_SILENCE_S", "0.4"))
  319. MAX_SPEECH_S = float(os.environ.get("MAX_SPEECH_S", "8.0"))
  320. # Global ONNX session (shared across participants)
  321. _vad_session = None
  322. def _get_vad_session():
  323. global _vad_session
  324. if _vad_session is not None:
  325. return _vad_session
  326. if not _HAS_ONNX or not os.path.exists(VAD_MODEL_PATH):
  327. return None
  328. opts = onnxruntime.SessionOptions()
  329. opts.inter_op_num_threads = 1
  330. opts.intra_op_num_threads = 1
  331. _vad_session = onnxruntime.InferenceSession(
  332. VAD_MODEL_PATH, providers=["CPUExecutionProvider"], sess_options=opts,
  333. )
  334. logger.info("Silero VAD loaded (%s)", VAD_MODEL_PATH)
  335. return _vad_session
  336. # ═══════════════════════════════════════════════════════════════
  337. # Silero VAD (ONNX, per-participant state)
  338. # ═══════════════════════════════════════════════════════════════
  339. class _SileroVAD:
  340. """Xiaozhi-style Silero VAD with dual-threshold + sliding window."""
  341. def __init__(self):
  342. self._sess = _get_vad_session()
  343. self._state = np.zeros((2, 1, 128), dtype=np.float32)
  344. self._context = np.zeros((1, 64), dtype=np.float32)
  345. self._window: deque = deque(maxlen=max(VAD_WINDOW_SIZE, 1))
  346. self.in_speech = False
  347. self.speech_start_sample = 0
  348. self.silence_counter = 0
  349. self.total_samples = 0
  350. self._sr = np.array(16000, dtype=np.int64)
  351. def process(self, frame: np.ndarray) -> bool:
  352. """Process 160-sample (10ms) frame. Call every frame. Returns in_speech."""
  353. n = len(frame)
  354. if self._sess is not None:
  355. # ── Silero ONNX inference ──
  356. # Silero processes 512-sample windows. Accumulate frames.
  357. if not hasattr(self, '_buf'):
  358. self._buf = np.array([], dtype=np.float32)
  359. self._buf = np.concatenate([self._buf, frame]).astype(np.float32)
  360. is_voice = False
  361. while len(self._buf) >= 512:
  362. chunk = self._buf[:512]
  363. self._buf = self._buf[512:]
  364. audio_in = chunk.reshape(1, -1)
  365. inp = np.concatenate([self._context, audio_in], axis=1).astype(np.float32)
  366. out, self._state = self._sess.run(
  367. None, {"input": inp, "state": self._state, "sr": self._sr},
  368. )
  369. self._context = inp[:, -64:]
  370. prob = out.item()
  371. is_voice = prob >= VAD_THRESHOLD_HIGH or (
  372. prob > VAD_THRESHOLD_LOW and (
  373. self._window[-1] if self._window else False
  374. )
  375. )
  376. self._window.append(is_voice)
  377. else:
  378. # ── Fallback: energy-based ──
  379. energy = float(np.sqrt(np.mean(frame ** 2)))
  380. if energy > 0.015:
  381. is_voice = True
  382. elif energy < 0.005:
  383. is_voice = False
  384. else:
  385. is_voice = self._window[-1] if self._window else False
  386. self._window.append(is_voice)
  387. have_voice = sum(self._window) / max(len(self._window), 1) >= VAD_VOICE_RATIO
  388. if have_voice and not self.in_speech:
  389. logger.info("VAD START")
  390. self.in_speech = True
  391. self.speech_start_sample = self.total_samples
  392. self.silence_counter = 0
  393. elif not have_voice and self.in_speech:
  394. self.silence_counter += n
  395. elif have_voice:
  396. self.silence_counter = 0
  397. self.total_samples += n
  398. return self.in_speech
  399. def should_end(self) -> bool:
  400. return self.in_speech and self.silence_counter >= int(MIN_SILENCE_S * 16000)
  401. def min_speech_met(self) -> bool:
  402. return (self.total_samples - self.speech_start_sample) / 16000 >= MIN_SPEECH_S
  403. def reset(self):
  404. self._window.clear()
  405. self.in_speech = False
  406. self.speech_start_sample = 0
  407. self.silence_counter = 0
  408. self._state = np.zeros((2, 1, 128), dtype=np.float32)
  409. self._context = np.zeros((1, 64), dtype=np.float32)
  410. if hasattr(self, '_buf'):
  411. self._buf = np.array([], dtype=np.float32)
  412. # ═══════════════════════════════════════════════════════════════
  413. # Worker
  414. # ═══════════════════════════════════════════════════════════════
  415. class Worker:
  416. def __init__(self, room, identity="asr-bot"):
  417. self.rn, self.id = room, identity
  418. self.room = rtc.Room()
  419. self.tasks: dict = {}
  420. self.hist: list[dict] = []
  421. self._exit_task = None
  422. self._shutdown_event = asyncio.Event()
  423. async def run(self):
  424. # Pre-load Silero VAD at startup (not lazy)
  425. _get_vad_session()
  426. # Only load Qwen3 if using local ASR
  427. if ASR_PROVIDER != "mimo":
  428. logger.info("Loading ASR model...")
  429. self.asr = QwenASREngine(model_id=ASR_MODEL, language=None)
  430. logger.info("ASR ready")
  431. else:
  432. self.asr = None
  433. logger.info("ASR: using Mimo API (no local model)")
  434. self.room.on("track_subscribed", self._on_track)
  435. self.room.on("participant_disconnected", self._on_off)
  436. self.room.on("participant_connected", self._on_on)
  437. self.room.on("track_published", lambda pub, p: None)
  438. await self.room.connect(LIVEKIT_URL, _token(self.rn, self.id))
  439. logger.info("Worker ready (VAD+ASR+LLM+TTS)")
  440. for p in self.room.remote_participants.values():
  441. for pub in p.track_publications.values():
  442. if pub.track and pub.kind == rtc.TrackKind.KIND_AUDIO:
  443. self._on_track(pub.track, pub, p)
  444. try:
  445. await self._shutdown_event.wait()
  446. finally:
  447. await self.room.disconnect()
  448. def _on_track(self, t, _, p):
  449. s = p.sid
  450. if s in self.tasks:
  451. return
  452. logger.info("TRACK %s", p.identity)
  453. self.tasks[s] = asyncio.create_task(self._run(s, t))
  454. def _on_off(self, p):
  455. x = self.tasks.pop(p.sid, None)
  456. if x:
  457. x.cancel()
  458. # Auto-exit when room stays empty for a grace period, to allow App resume.
  459. if not self.room.remote_participants:
  460. if self._exit_task is None or self._exit_task.done():
  461. logger.info("Room empty, scheduling exit")
  462. self._exit_task = asyncio.create_task(self._delayed_exit())
  463. else:
  464. if self._exit_task and not self._exit_task.done():
  465. self._exit_task.cancel()
  466. self._exit_task = None
  467. def _on_on(self, p):
  468. if self._exit_task and not self._exit_task.done():
  469. logger.info("Participant rejoined, canceling exit")
  470. self._exit_task.cancel()
  471. self._exit_task = None
  472. async def _delayed_exit(self, delay_s: float = 60.0):
  473. await asyncio.sleep(delay_s)
  474. if not self.room.remote_participants:
  475. logger.info("Room still empty, exiting")
  476. self._shutdown_event.set()
  477. async def _run(self, sid, track):
  478. logger.info("_run %s", sid)
  479. buf = AudioBuffer(max_duration=MAX_SPEECH_S + 4, sample_rate=16000)
  480. sr = 16000
  481. vad = _SileroVAD()
  482. busy = False
  483. # ── Single persistent TTS output track per session ──
  484. # One track is reused for all turns so audio never cross-streams
  485. # between turns/segments (old code published a new track each call).
  486. tts_src = rtc.AudioSource(24000, 1)
  487. tts_track = rtc.LocalAudioTrack.create_audio_track("tts", tts_src)
  488. await self.room.local_participant.publish_track(tts_track)
  489. play_q: "asyncio.Queue[tuple[int, np.ndarray]]" = asyncio.Queue()
  490. cur_gen = {"v": 0} # generation counter + currently playing gen
  491. async def _player():
  492. """Drain TTS audio sequentially on the single track."""
  493. while True:
  494. g, audio = await play_q.get()
  495. logger.info("PLAY start gen=%d samples=%d", g, len(audio))
  496. for i in range(0, len(audio), 24000 // 50):
  497. if cur_gen["v"] != g:
  498. break
  499. c = audio[i: i + 24000 // 50]
  500. await tts_src.capture_frame(rtc.AudioFrame(
  501. data=c.tobytes(), sample_rate=24000,
  502. num_channels=1, samples_per_channel=len(c),
  503. ))
  504. await asyncio.sleep(0.018)
  505. logger.info("PLAY end gen=%d", g)
  506. player_task = asyncio.create_task(_player())
  507. async def _acc():
  508. nonlocal busy
  509. stream = rtc.AudioStream(track, sample_rate=sr, num_channels=1)
  510. fc = 0
  511. async for ev in stream:
  512. fc += 1
  513. if busy:
  514. continue
  515. arr = np.frombuffer(ev.frame.data, dtype=np.int16).astype(np.float32) / 32768.0
  516. buf.append(arr)
  517. was_speech = vad.in_speech
  518. vad.process(arr)
  519. if fc % 100 == 0:
  520. logger.info("[%s] af#%d e=%.4f vad=%s blen=%d",
  521. sid, fc, float(np.sqrt(np.mean(arr ** 2))),
  522. vad.in_speech, len(buf.buffer))
  523. if was_speech and vad.should_end() and vad.min_speech_met():
  524. logger.info("[%s] VAD END blen=%d", sid, len(buf.buffer))
  525. busy = True
  526. try:
  527. await self._transcribe_and_respond(buf, play_q, cur_gen)
  528. except Exception:
  529. logger.exception("[%s] transcribe failed", sid)
  530. busy = False
  531. vad.reset()
  532. vad.total_samples = len(buf.buffer)
  533. if vad.in_speech and vad.total_samples >= int(MAX_SPEECH_S * sr):
  534. logger.info("[%s] VAD MAX blen=%d", sid, len(buf.buffer))
  535. busy = True
  536. try:
  537. await self._transcribe_and_respond(buf, play_q, cur_gen)
  538. except Exception:
  539. logger.exception("[%s] max failed", sid)
  540. busy = False
  541. vad.reset()
  542. vad.total_samples = len(buf.buffer)
  543. logger.info("[%s] stream end fc=%d", sid, fc)
  544. try:
  545. await _acc()
  546. except asyncio.CancelledError:
  547. pass
  548. except Exception:
  549. logger.exception("[%s] _acc failed", sid)
  550. finally:
  551. player_task.cancel()
  552. try:
  553. await self.room.local_participant.unpublish_track(tts_track)
  554. except Exception:
  555. pass
  556. async def _transcribe_and_respond(self, buf: AudioBuffer, play_q, cur_gen: dict):
  557. all_audio = buf.get_all()
  558. buf.clear()
  559. if len(all_audio) <= 1600:
  560. return
  561. txt = await _asr_transcribe(all_audio, self.asr)
  562. if not txt:
  563. return
  564. # Real utterance — stop previous TTS, start new generation
  565. cur_gen["v"] += 1
  566. gen = cur_gen["v"]
  567. _drain_queue(play_q)
  568. logger.info("ASR: %s", txt[:80])
  569. await self._send({"type": "utterance", "text": txt, "seq": 0})
  570. # ── Voiceprint: identify or auto-register ──
  571. speaker_name = None
  572. speaker_id = None
  573. if _VP_ENABLED:
  574. store = get_store()
  575. if store and store.enabled:
  576. loop = asyncio.get_running_loop()
  577. speaker_id, speaker_name, _sim = await loop.run_in_executor(None, store.identify, all_audio)
  578. if speaker_name:
  579. logger.info("VP: identified %s (sim=%.3f)", speaker_name, _sim)
  580. await self._send({"type": "speaker", "name": speaker_name, "confidence": round(_sim, 3)})
  581. # Restore persistent conversation history into current session
  582. try:
  583. from voiceprint import load_conversation_hist
  584. loaded = load_conversation_hist(speaker_id, limit=6)
  585. if loaded:
  586. self.hist = loaded + self.hist
  587. self.hist[:] = self.hist[-20:]
  588. logger.info("Restored %d turns for %s", len(loaded), speaker_name)
  589. except Exception:
  590. pass
  591. else:
  592. auto_id = f"user_{int(time.time() * 1000) % 1000000:06d}"
  593. auto_name = f"用户{auto_id[-3:]}"
  594. ok = await loop.run_in_executor(None, store.register, auto_id, auto_name, all_audio)
  595. if ok:
  596. speaker_id, speaker_name = auto_id, auto_name
  597. logger.info("VP: auto-registered %s", auto_name)
  598. await self._send({"type": "vp_registered", "name": auto_name, "user_id": auto_id})
  599. logger.info("LLM start")
  600. reply_full = ""
  601. seg = _TTSSegmenter()
  602. tts_segments: list[str] = []
  603. try:
  604. async for delta, is_final in _llm_stream(txt, self.hist, speaker_name, speaker_id):
  605. if delta:
  606. reply_full += delta
  607. await self._send({"type": "reply_partial", "text": reply_full, "seq": 0})
  608. tts_segments.extend(seg.feed(delta))
  609. if is_final:
  610. remaining = seg.flush()
  611. if remaining:
  612. tts_segments.append(remaining)
  613. break
  614. except Exception:
  615. logger.exception("LLM stream failed")
  616. reply_full = "抱歉,我暂时无法回答。"
  617. logger.info("LLM done reply=%s segments=%d", reply_full[:40], len(tts_segments))
  618. # Ordered TTS: generate concurrently but enqueue in order
  619. if tts_segments:
  620. tasks = [asyncio.create_task(_tts(s)) for s in tts_segments]
  621. for i, task in enumerate(tasks):
  622. try:
  623. pcm = await asyncio.wait_for(task, timeout=15)
  624. if pcm is not None and len(pcm) > 0:
  625. logger.info("TTS seg(%d/%d): %s", i+1, len(tasks), tts_segments[i][:40])
  626. await play_q.put((gen, pcm))
  627. except (asyncio.TimeoutError, asyncio.CancelledError):
  628. break
  629. except Exception:
  630. logger.exception("TTS seg %d failed", i+1)
  631. if reply_full:
  632. await self._send({"type": "reply", "text": reply_full})
  633. self.hist.extend([
  634. {"role": "user", "content": txt},
  635. {"role": "assistant", "content": reply_full},
  636. ])
  637. self.hist[:] = self.hist[-20:]
  638. # Save conversation to persistent memory
  639. if speaker_id and _VP_ENABLED:
  640. loop = asyncio.get_running_loop()
  641. await loop.run_in_executor(None, save_conversation, speaker_id, speaker_name or "unknown", txt, reply_full)
  642. async def _send(self, msg: dict):
  643. try:
  644. await self.room.local_participant.publish_data(
  645. json.dumps(msg, ensure_ascii=False).encode(),
  646. reliable=True, topic="transcription",
  647. )
  648. except Exception:
  649. pass
  650. async def main():
  651. import argparse
  652. p = argparse.ArgumentParser()
  653. p.add_argument("--room", required=True)
  654. a = p.parse_args()
  655. logging.basicConfig(level=logging.INFO)
  656. await Worker(a.room).run()
  657. def _drain_queue(q: "asyncio.Queue") -> None:
  658. """Discard any queued-but-not-yet-played TTS audio."""
  659. while not q.empty():
  660. try:
  661. q.get_nowait()
  662. except Exception:
  663. break
  664. if __name__ == "__main__":
  665. asyncio.run(main())