conversation_worker.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  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-qt-client", "whisper_asr")
  30. if not os.path.isdir(_root):
  31. _root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "whisper_asr")
  32. sys.path.insert(0, _root)
  33. from audio_processor import AudioBuffer
  34. from qwen_engine import QwenASREngine
  35. logger = logging.getLogger("worker")
  36. # ── Env ──
  37. LIVEKIT_URL = os.environ.get("LIVEKIT_URL", "ws://localhost:7880")
  38. ASR_MODEL = os.environ.get("ASR_MODEL_PATH", "Qwen/Qwen3-ASR-0.6B")
  39. VLLM_URL = os.environ.get("VLLM_URL", "http://127.0.0.1:8000/v1")
  40. LLM_MODEL = os.environ.get("LLM_MODEL", "qwen3.6-35b-awq")
  41. MIMO_KEY = os.environ.get("MIMO_KEY", "")
  42. # ── LLM ──
  43. LLM_MAX_TOKENS = 128
  44. LLM_TEMPERATURE = 0.7
  45. LLM_TIMEOUT = 25
  46. SYSTEM_PROMPT = (
  47. "你是友好的中文语音助手,名字叫小智。"
  48. "回答简洁自然,2-3句即可,用口语化中文。"
  49. "不要使用 Markdown、代码块、表格或特殊符号。"
  50. "不要输出括号注释、不要使用英文缩写。"
  51. )
  52. # ── TTS segmentation (xiaozhi-style two-tier) ──
  53. FIRST_SENTENCE_PUNCT = ",、,。!?;::\n"
  54. SENTENCE_END_PUNCT = "。!?!?\n"
  55. MIN_TTS_CHARS = 2
  56. # ═══════════════════════════════════════════════════════════════
  57. # JWT
  58. # ═══════════════════════════════════════════════════════════════
  59. def _token(room, ident):
  60. n = int(time.time())
  61. return jwt.encode({
  62. "iss": "devkey", "sub": ident, "name": ident,
  63. "nbf": n - 60, "exp": n + 6 * 3600,
  64. "video": {"roomJoin": True, "room": room,
  65. "canPublish": True, "canSubscribe": True, "canPublishData": True},
  66. }, "secretsecretsecretsecretsecret12", algorithm="HS256")
  67. # ═══════════════════════════════════════════════════════════════
  68. # LLM (streaming, <think> filtering)
  69. # ═══════════════════════════════════════════════════════════════
  70. async def _llm_stream(prompt: str, hist: list[dict]):
  71. msgs = [{"role": "system", "content": SYSTEM_PROMPT}]
  72. msgs.extend(hist)
  73. msgs.append({"role": "user", "content": prompt})
  74. async with aiohttp.ClientSession() as s:
  75. async with s.post(
  76. f"{VLLM_URL}/chat/completions",
  77. json={"model": LLM_MODEL, "messages": msgs,
  78. "max_tokens": LLM_MAX_TOKENS, "temperature": LLM_TEMPERATURE,
  79. "stream": True,
  80. "chat_template_kwargs": {"enable_thinking": False}},
  81. timeout=aiohttp.ClientTimeout(total=LLM_TIMEOUT),
  82. ) as r:
  83. async for line in r.content:
  84. line = line.decode().strip()
  85. if not line.startswith("data: "):
  86. continue
  87. data = line[6:]
  88. if data == "[DONE]":
  89. break
  90. try:
  91. chunk = json.loads(data)
  92. delta = chunk.get("choices", [{}])[0].get("delta", {})
  93. text = delta.get("content", "")
  94. if text:
  95. if "</think>" in text:
  96. text = text.split("</think>")[-1]
  97. if "<think>" in text:
  98. text = text.split("<think>")[0]
  99. if text.strip():
  100. yield text, False
  101. except Exception:
  102. continue
  103. yield "", True
  104. # ═══════════════════════════════════════════════════════════════
  105. # TTS
  106. # ═══════════════════════════════════════════════════════════════
  107. async def _tts(text: str) -> np.ndarray | None:
  108. key = os.environ.get("MIMO_KEY", "")
  109. if not key:
  110. return None
  111. h = {"api-key": key, "Content-Type": "application/json"}
  112. async with aiohttp.ClientSession() as s:
  113. async with s.post(
  114. "https://token-plan-cn.xiaomimimo.com/v1/chat/completions",
  115. json={"model": "mimo-v2.5-tts", "messages": [
  116. {"role": "user", "content": "请用自然语速朗读。"},
  117. {"role": "assistant", "content": text},
  118. ], "audio": {"format": "wav", "voice": "Chloe"}},
  119. headers=h, timeout=aiohttp.ClientTimeout(total=20),
  120. ) as r:
  121. try:
  122. b64 = (await r.json())["choices"][0]["message"]["audio"]["data"]
  123. return np.frombuffer(base64.b64decode(b64), dtype=np.int16)
  124. except Exception:
  125. return None
  126. def _clean_tts_text(text: str) -> str:
  127. text = re.sub(r"\*{1,3}(.*?)\*{1,3}", r"\1", text)
  128. text = re.sub(r"`{1,3}.*?`{1,3}", "", text)
  129. text = re.sub(r"\[([^\]]+)]\([^)]+\)", r"\1", text)
  130. text = re.sub(r"#{1,6}\s*", "", text)
  131. text = re.sub(r"[>\-]\s", "", text)
  132. text = re.sub(r"[\U0001F300-\U0001F9FF]", "", text)
  133. return text.strip()
  134. # ═══════════════════════════════════════════════════════════════
  135. # TTS Segmenter (xiaozhi-style: two-tier, processed_chars)
  136. # ═══════════════════════════════════════════════════════════════
  137. @dataclass
  138. class _TTSSegmenter:
  139. buffer: str = ""
  140. processed: int = 0
  141. is_first: bool = True
  142. def feed(self, text: str) -> list[str]:
  143. self.buffer += text
  144. segments = []
  145. puncts = FIRST_SENTENCE_PUNCT if self.is_first else SENTENCE_END_PUNCT
  146. unprocessed = self.buffer[self.processed:]
  147. last_pos = -1
  148. for ch in puncts:
  149. pos = unprocessed.rfind(ch)
  150. if pos > last_pos:
  151. last_pos = pos
  152. if last_pos >= 0:
  153. seg_raw = unprocessed[:last_pos + 1]
  154. seg_clean = _clean_tts_text(seg_raw)
  155. if len(seg_clean) >= MIN_TTS_CHARS:
  156. segments.append(seg_clean)
  157. self.processed += len(seg_raw)
  158. if self.is_first:
  159. self.is_first = False
  160. return segments
  161. def flush(self) -> str:
  162. remaining = self.buffer[self.processed:].strip()
  163. if remaining:
  164. remaining = _clean_tts_text(remaining)
  165. self.processed = len(self.buffer)
  166. return remaining
  167. # ── VAD ──
  168. VAD_MODEL_PATH = os.environ.get("VAD_MODEL_PATH", "/data/silero_vad/silero_vad.onnx")
  169. VAD_THRESHOLD_HIGH = float(os.environ.get("VAD_THRESHOLD_HIGH", "0.5"))
  170. VAD_THRESHOLD_LOW = float(os.environ.get("VAD_THRESHOLD_LOW", "0.2"))
  171. VAD_WINDOW_SIZE = int(os.environ.get("VAD_WINDOW_SIZE", "5"))
  172. VAD_VOICE_RATIO = float(os.environ.get("VAD_VOICE_RATIO", "0.5"))
  173. MIN_SPEECH_S = float(os.environ.get("MIN_SPEECH_S", "0.3"))
  174. MIN_SILENCE_S = float(os.environ.get("MIN_SILENCE_S", "0.8"))
  175. MAX_SPEECH_S = float(os.environ.get("MAX_SPEECH_S", "8.0"))
  176. # Global ONNX session (shared across participants)
  177. _vad_session = None
  178. def _get_vad_session():
  179. global _vad_session
  180. if _vad_session is not None:
  181. return _vad_session
  182. if not _HAS_ONNX or not os.path.exists(VAD_MODEL_PATH):
  183. return None
  184. opts = onnxruntime.SessionOptions()
  185. opts.inter_op_num_threads = 1
  186. opts.intra_op_num_threads = 1
  187. _vad_session = onnxruntime.InferenceSession(
  188. VAD_MODEL_PATH, providers=["CPUExecutionProvider"], sess_options=opts,
  189. )
  190. logger.info("Silero VAD loaded (%s)", VAD_MODEL_PATH)
  191. return _vad_session
  192. # ═══════════════════════════════════════════════════════════════
  193. # Silero VAD (ONNX, per-participant state)
  194. # ═══════════════════════════════════════════════════════════════
  195. class _SileroVAD:
  196. """Xiaozhi-style Silero VAD with dual-threshold + sliding window."""
  197. def __init__(self):
  198. self._sess = _get_vad_session()
  199. self._state = np.zeros((2, 1, 128), dtype=np.float32)
  200. self._context = np.zeros((1, 64), dtype=np.float32)
  201. self._window: deque = deque(maxlen=max(VAD_WINDOW_SIZE, 1))
  202. self.in_speech = False
  203. self.speech_start_sample = 0
  204. self.silence_counter = 0
  205. self.total_samples = 0
  206. self._sr = np.array(16000, dtype=np.int64)
  207. def process(self, frame: np.ndarray) -> bool:
  208. """Process 160-sample (10ms) frame. Call every frame. Returns in_speech."""
  209. n = len(frame)
  210. if self._sess is not None:
  211. # ── Silero ONNX inference ──
  212. # Silero processes 512-sample windows. Accumulate frames.
  213. if not hasattr(self, '_buf'):
  214. self._buf = np.array([], dtype=np.float32)
  215. self._buf = np.concatenate([self._buf, frame]).astype(np.float32)
  216. is_voice = False
  217. while len(self._buf) >= 512:
  218. chunk = self._buf[:512]
  219. self._buf = self._buf[512:]
  220. audio_in = chunk.reshape(1, -1)
  221. inp = np.concatenate([self._context, audio_in], axis=1).astype(np.float32)
  222. out, self._state = self._sess.run(
  223. None, {"input": inp, "state": self._state, "sr": self._sr},
  224. )
  225. self._context = inp[:, -64:]
  226. prob = out.item()
  227. is_voice = prob >= VAD_THRESHOLD_HIGH or (
  228. prob > VAD_THRESHOLD_LOW and (
  229. self._window[-1] if self._window else False
  230. )
  231. )
  232. self._window.append(is_voice)
  233. else:
  234. # ── Fallback: energy-based ──
  235. energy = float(np.sqrt(np.mean(frame ** 2)))
  236. if energy > 0.015:
  237. is_voice = True
  238. elif energy < 0.005:
  239. is_voice = False
  240. else:
  241. is_voice = self._window[-1] if self._window else False
  242. self._window.append(is_voice)
  243. have_voice = sum(self._window) / max(len(self._window), 1) >= VAD_VOICE_RATIO
  244. if have_voice and not self.in_speech:
  245. logger.info("VAD START")
  246. self.in_speech = True
  247. self.speech_start_sample = self.total_samples
  248. self.silence_counter = 0
  249. elif not have_voice and self.in_speech:
  250. self.silence_counter += n
  251. elif have_voice:
  252. self.silence_counter = 0
  253. self.total_samples += n
  254. return self.in_speech
  255. def should_end(self) -> bool:
  256. return self.in_speech and self.silence_counter >= int(MIN_SILENCE_S * 16000)
  257. def min_speech_met(self) -> bool:
  258. return (self.total_samples - self.speech_start_sample) / 16000 >= MIN_SPEECH_S
  259. def reset(self):
  260. self._window.clear()
  261. self.in_speech = False
  262. self.speech_start_sample = 0
  263. self.silence_counter = 0
  264. self._state = np.zeros((2, 1, 128), dtype=np.float32)
  265. self._context = np.zeros((1, 64), dtype=np.float32)
  266. if hasattr(self, '_buf'):
  267. self._buf = np.array([], dtype=np.float32)
  268. # ═══════════════════════════════════════════════════════════════
  269. # Worker
  270. # ═══════════════════════════════════════════════════════════════
  271. class Worker:
  272. def __init__(self, room, identity="asr-bot"):
  273. self.rn, self.id = room, identity
  274. self.room = rtc.Room()
  275. self.tasks: dict = {}
  276. self.hist: list[dict] = []
  277. async def run(self):
  278. logger.info("Loading ASR model...")
  279. self.asr = QwenASREngine(model_id=ASR_MODEL, language=None)
  280. logger.info("ASR ready")
  281. self.room.on("track_subscribed", self._on_track)
  282. self.room.on("participant_disconnected", self._on_off)
  283. self.room.on("participant_connected", lambda p: None)
  284. self.room.on("track_published", lambda pub, p: None)
  285. await self.room.connect(LIVEKIT_URL, _token(self.rn, self.id))
  286. logger.info("Worker ready (VAD+ASR+LLM+TTS)")
  287. for p in self.room.remote_participants.values():
  288. for pub in p.track_publications.values():
  289. if pub.track and pub.kind == rtc.TrackKind.KIND_AUDIO:
  290. self._on_track(pub.track, pub, p)
  291. try:
  292. await asyncio.Future()
  293. finally:
  294. await self.room.disconnect()
  295. def _on_track(self, t, _, p):
  296. s = p.sid
  297. if s in self.tasks:
  298. return
  299. logger.info("TRACK %s", p.identity)
  300. self.tasks[s] = asyncio.create_task(self._run(s, t))
  301. def _on_off(self, p):
  302. x = self.tasks.pop(p.sid, None)
  303. if x:
  304. x.cancel()
  305. async def _run(self, sid, track):
  306. logger.info("_run %s", sid)
  307. buf = AudioBuffer(max_duration=MAX_SPEECH_S + 4, sample_rate=16000)
  308. sr = 16000
  309. vad = _SileroVAD()
  310. busy = False
  311. cur_tts = None
  312. async def _acc():
  313. nonlocal busy, cur_tts
  314. stream = rtc.AudioStream(track, sample_rate=sr, num_channels=1)
  315. fc = 0
  316. async for ev in stream:
  317. fc += 1
  318. if busy:
  319. continue
  320. arr = np.frombuffer(ev.frame.data, dtype=np.int16).astype(np.float32) / 32768.0
  321. buf.append(arr)
  322. was_speech = vad.in_speech
  323. vad.process(arr)
  324. if fc % 100 == 0:
  325. logger.info("[%s] af#%d e=%.4f vad=%s blen=%d",
  326. sid, fc, float(np.sqrt(np.mean(arr ** 2))),
  327. vad.in_speech, len(buf.buffer))
  328. if was_speech and vad.should_end() and vad.min_speech_met():
  329. logger.info("[%s] VAD END blen=%d", sid, len(buf.buffer))
  330. if cur_tts:
  331. cur_tts = None
  332. busy = True
  333. try:
  334. await self._transcribe_and_respond(buf)
  335. except Exception:
  336. logger.exception("[%s] transcribe failed", sid)
  337. busy = False
  338. vad.reset()
  339. vad.total_samples = len(buf.buffer)
  340. if vad.in_speech and vad.total_samples >= int(MAX_SPEECH_S * sr):
  341. logger.info("[%s] VAD MAX blen=%d", sid, len(buf.buffer))
  342. busy = True
  343. try:
  344. await self._transcribe_and_respond(buf)
  345. except Exception:
  346. logger.exception("[%s] max failed", sid)
  347. busy = False
  348. vad.reset()
  349. vad.total_samples = len(buf.buffer)
  350. logger.info("[%s] stream end fc=%d", sid, fc)
  351. try:
  352. await _acc()
  353. except asyncio.CancelledError:
  354. pass
  355. except Exception:
  356. logger.exception("[%s] _acc failed", sid)
  357. async def _transcribe_and_respond(self, buf: AudioBuffer):
  358. all_audio = buf.get_all()
  359. buf.clear()
  360. if len(all_audio) <= 1600:
  361. return
  362. loop = asyncio.get_running_loop()
  363. result = await loop.run_in_executor(None, self.asr.transcribe_array, all_audio)
  364. txt = result.get("text", "").strip()
  365. if not txt:
  366. return
  367. logger.info("ASR: %s", txt[:80])
  368. await self._send({"type": "utterance", "text": txt, "seq": 0})
  369. # ── Streaming LLM + xiaozhi-style TTS ──
  370. reply_full = ""
  371. seg = _TTSSegmenter()
  372. tts_task = None
  373. try:
  374. async for delta, is_final in _llm_stream(txt, self.hist):
  375. if delta:
  376. reply_full += delta
  377. await self._send({"type": "reply_partial", "text": reply_full, "seq": 0})
  378. for s in seg.feed(delta):
  379. if tts_task:
  380. await tts_task
  381. logger.info("TTS seg(%d): %s", len(s), s[:40])
  382. tts_task = asyncio.create_task(_tts(s))
  383. if is_final:
  384. remaining = seg.flush()
  385. if remaining:
  386. if tts_task:
  387. await tts_task
  388. tts_task = asyncio.create_task(_tts(remaining))
  389. break
  390. except Exception:
  391. logger.exception("LLM stream failed")
  392. reply_full = "抱歉,我暂时无法回答。"
  393. if tts_task:
  394. try:
  395. wav = await asyncio.wait_for(tts_task, timeout=15)
  396. if wav is not None and len(wav) > 0:
  397. await self._play(wav, 24000)
  398. except (asyncio.TimeoutError, Exception):
  399. pass
  400. if reply_full:
  401. await self._send({"type": "reply", "text": reply_full})
  402. self.hist.extend([
  403. {"role": "user", "content": txt},
  404. {"role": "assistant", "content": reply_full},
  405. ])
  406. self.hist[:] = self.hist[-20:]
  407. async def _play(self, audio: np.ndarray, sr: int):
  408. src = rtc.AudioSource(sr, 1)
  409. tk = rtc.LocalAudioTrack.create_audio_track("tts", src)
  410. await self.room.local_participant.publish_track(tk)
  411. for i in range(0, len(audio), sr // 50):
  412. c = audio[i: i + sr // 50]
  413. await src.capture_frame(rtc.AudioFrame(
  414. data=c.tobytes(), sample_rate=sr,
  415. num_channels=1, samples_per_channel=len(c),
  416. ))
  417. await asyncio.sleep(0.018)
  418. return tk
  419. async def _send(self, msg: dict):
  420. try:
  421. await self.room.local_participant.publish_data(
  422. json.dumps(msg, ensure_ascii=False).encode(),
  423. reliable=True, topic="transcription",
  424. )
  425. except Exception:
  426. pass
  427. async def main():
  428. import argparse
  429. p = argparse.ArgumentParser()
  430. p.add_argument("--room", required=True)
  431. a = p.parse_args()
  432. logging.basicConfig(level=logging.INFO)
  433. await Worker(a.room).run()
  434. if __name__ == "__main__":
  435. asyncio.run(main())