conversation_worker.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. """
  2. Full Pipeline Worker: VAD → ASR → LLM → TTS
  3. VAD: dual-threshold energy-based + sliding window (xiaozhi-inspired)
  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, incremental TTS
  7. TTS: Mimo API (remote), per-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 aiohttp
  21. import jwt
  22. import numpy as np
  23. from livekit import rtc
  24. _root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "whisper-qt-client", "whisper_asr")
  25. if not os.path.isdir(_root):
  26. _root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "whisper_asr")
  27. sys.path.insert(0, _root)
  28. from audio_processor import AudioBuffer
  29. from qwen_engine import QwenASREngine
  30. logger = logging.getLogger("worker")
  31. # ── Environment ──
  32. LIVEKIT_URL = os.environ.get("LIVEKIT_URL", "ws://localhost:7880")
  33. ASR_MODEL = os.environ.get("ASR_MODEL_PATH", "Qwen/Qwen3-ASR-0.6B")
  34. VLLM_URL = os.environ.get("VLLM_URL", "http://127.0.0.1:8000/v1")
  35. LLM_MODEL = os.environ.get("LLM_MODEL", "qwen3.6-35b-awq")
  36. MIMO_KEY = os.environ.get("MIMO_KEY", "")
  37. # ── VAD ──
  38. VAD_THRESHOLD_HIGH = 0.015
  39. VAD_THRESHOLD_LOW = 0.005
  40. VAD_WINDOW_SIZE = 8
  41. VAD_VOICE_RATIO = 0.5
  42. MIN_SPEECH_S = 0.3
  43. MIN_SILENCE_S = 0.8
  44. MAX_SPEECH_S = 8.0
  45. # ── LLM ──
  46. LLM_MAX_TOKENS = 128
  47. LLM_TEMPERATURE = 0.7
  48. LLM_TIMEOUT = 20
  49. # ── TTS ──
  50. TTS_CHUNK_PATTERN = re.compile(r"[。!?;\n]")
  51. SYSTEM_PROMPT = (
  52. "你是友好的中文语音助手,名字叫小智。"
  53. "回答简洁自然,2-3句即可,用口语化中文。"
  54. "不要使用 Markdown、代码块、表格或特殊符号。"
  55. "不要输出括号注释、不要使用英文缩写。"
  56. )
  57. # ── JWT ──
  58. def _token(room, ident):
  59. n = int(time.time())
  60. return jwt.encode({
  61. "iss": "devkey", "sub": ident, "name": ident,
  62. "nbf": n - 60, "exp": n + 6 * 3600,
  63. "video": {"roomJoin": True, "room": room,
  64. "canPublish": True, "canSubscribe": True, "canPublishData": True},
  65. }, "secretsecretsecretsecretsecret12", algorithm="HS256")
  66. # ── LLM Streaming ──
  67. async def _llm_stream(prompt: str, hist: list[dict]):
  68. """Stream LLM tokens via SSE, yields (delta_text, is_final)."""
  69. msgs = [{"role": "system", "content": SYSTEM_PROMPT}]
  70. msgs.extend(hist)
  71. msgs.append({"role": "user", "content": prompt})
  72. async with aiohttp.ClientSession() as s:
  73. async with s.post(
  74. f"{VLLM_URL}/chat/completions",
  75. json={"model": LLM_MODEL, "messages": msgs,
  76. "max_tokens": LLM_MAX_TOKENS, "temperature": LLM_TEMPERATURE,
  77. "stream": True},
  78. timeout=aiohttp.ClientTimeout(total=LLM_TIMEOUT),
  79. ) as r:
  80. full = ""
  81. async for line in r.content:
  82. line = line.decode().strip()
  83. if not line.startswith("data: "):
  84. continue
  85. data = line[6:]
  86. if data == "[DONE]":
  87. break
  88. try:
  89. chunk = json.loads(data)
  90. delta = chunk.get("choices", [{}])[0].get("delta", {})
  91. content = delta.get("content", "")
  92. if content:
  93. full += content
  94. yield content, False
  95. except Exception:
  96. continue
  97. yield "", True
  98. # ── TTS ──
  99. async def _tts(text: str) -> np.ndarray | None:
  100. key = os.environ.get("MIMO_KEY", "")
  101. if not key:
  102. return None
  103. h = {"api-key": key, "Content-Type": "application/json"}
  104. async with aiohttp.ClientSession() as s:
  105. async with s.post(
  106. "https://token-plan-cn.xiaomimimo.com/v1/chat/completions",
  107. json={"model": "mimo-v2.5-tts", "messages": [
  108. {"role": "user", "content": "请用自然语速朗读。"},
  109. {"role": "assistant", "content": text},
  110. ], "audio": {"format": "wav", "voice": "Chloe"}},
  111. headers=h, timeout=aiohttp.ClientTimeout(total=20),
  112. ) as r:
  113. try:
  114. b64 = (await r.json())["choices"][0]["message"]["audio"]["data"]
  115. return np.frombuffer(base64.b64decode(b64), dtype=np.int16)
  116. except Exception:
  117. return None
  118. # ── Enhanced VAD (dual-threshold + sliding window) ──
  119. @dataclass
  120. class _VADState:
  121. window: deque = field(default_factory=lambda: deque(maxlen=VAD_WINDOW_SIZE))
  122. in_speech: bool = False
  123. speech_start_sample: int = 0
  124. silence_counter: int = 0
  125. total_samples: int = 0
  126. def reset(self) -> None:
  127. self.window.clear()
  128. self.in_speech = False
  129. self.speech_start_sample = 0
  130. self.silence_counter = 0
  131. def _vad_process(state: _VADState, frame: np.ndarray, sample_rate: int) -> bool:
  132. n = len(frame)
  133. energy = float(np.sqrt(np.mean(frame ** 2)))
  134. if energy > VAD_THRESHOLD_HIGH:
  135. is_voice = True
  136. elif energy < VAD_THRESHOLD_LOW:
  137. is_voice = False
  138. else:
  139. is_voice = state.window[-1] if state.window else False
  140. state.window.append(is_voice)
  141. voice_ratio = sum(state.window) / max(len(state.window), 1)
  142. have_voice = voice_ratio >= VAD_VOICE_RATIO
  143. if have_voice and not state.in_speech:
  144. logger.info("VAD START")
  145. state.in_speech = True
  146. state.speech_start_sample = state.total_samples
  147. state.silence_counter = 0
  148. elif not have_voice and state.in_speech:
  149. state.silence_counter += n
  150. elif have_voice:
  151. state.silence_counter = 0
  152. state.total_samples += n
  153. return state.in_speech
  154. def _vad_should_end(state: _VADState, sample_rate: int) -> bool:
  155. return state.in_speech and state.silence_counter >= int(MIN_SILENCE_S * sample_rate)
  156. def _vad_min_speech_met(state: _VADState, sample_rate: int) -> bool:
  157. return (state.total_samples - state.speech_start_sample) / sample_rate >= MIN_SPEECH_S
  158. # ── Worker ──
  159. class Worker:
  160. def __init__(self, room, identity="asr-bot"):
  161. self.rn, self.id = room, identity
  162. self.room = rtc.Room()
  163. self.tasks: dict = {}
  164. self.hist: list[dict] = []
  165. async def run(self):
  166. logger.info("Loading ASR model...")
  167. self.asr = QwenASREngine(model_id=ASR_MODEL, language=None)
  168. logger.info("ASR ready")
  169. self.room.on("track_subscribed", self._on_track)
  170. self.room.on("participant_disconnected", self._on_off)
  171. self.room.on("participant_connected", lambda p: None)
  172. self.room.on("track_published", lambda pub, p: None)
  173. await self.room.connect(LIVEKIT_URL, _token(self.rn, self.id))
  174. logger.info("Worker ready (VAD+ASR+LLM+TTS)")
  175. for p in self.room.remote_participants.values():
  176. for pub in p.track_publications.values():
  177. if pub.track and pub.kind == rtc.TrackKind.KIND_AUDIO:
  178. self._on_track(pub.track, pub, p)
  179. try:
  180. await asyncio.Future()
  181. finally:
  182. await self.room.disconnect()
  183. def _on_track(self, t, _, p):
  184. s = p.sid
  185. if s in self.tasks:
  186. return
  187. logger.info("TRACK %s k=%s", p.identity, t.kind)
  188. self.tasks[s] = asyncio.create_task(self._run(s, t))
  189. def _on_off(self, p):
  190. x = self.tasks.pop(p.sid, None)
  191. if x:
  192. x.cancel()
  193. async def _run(self, sid, track):
  194. logger.info("_run %s", sid)
  195. buf = AudioBuffer(max_duration=MAX_SPEECH_S + 4, sample_rate=16000)
  196. sr = 16000
  197. vad = _VADState()
  198. busy = False
  199. cur_tts = None
  200. async def _acc():
  201. nonlocal busy, cur_tts
  202. stream = rtc.AudioStream(track, sample_rate=sr, num_channels=1)
  203. fc = 0
  204. async for ev in stream:
  205. fc += 1
  206. if fc == 1:
  207. logger.info("[%s] first audio frame", sid)
  208. if busy:
  209. continue
  210. arr = np.frombuffer(ev.frame.data, dtype=np.int16).astype(np.float32) / 32768.0
  211. buf.append(arr)
  212. was_speech = vad.in_speech
  213. _vad_process(vad, arr, sr)
  214. if fc % 100 == 0:
  215. logger.info("[%s] af#%d e=%.4f vad=%s blen=%d",
  216. sid, fc, float(np.sqrt(np.mean(arr ** 2))),
  217. vad.in_speech, len(buf.buffer))
  218. if was_speech and _vad_should_end(vad, sr) and _vad_min_speech_met(vad, sr):
  219. logger.info("[%s] VAD END blen=%d", sid, len(buf.buffer))
  220. if cur_tts:
  221. cur_tts = None
  222. busy = True
  223. try:
  224. await self._transcribe_and_respond(buf)
  225. except Exception:
  226. logger.exception("[%s] transcribe failed", sid)
  227. busy = False
  228. vad.reset()
  229. vad.total_samples = len(buf.buffer)
  230. if vad.in_speech and vad.total_samples >= int(MAX_SPEECH_S * sr):
  231. logger.info("[%s] VAD MAX blen=%d", sid, len(buf.buffer))
  232. busy = True
  233. try:
  234. await self._transcribe_and_respond(buf)
  235. except Exception:
  236. logger.exception("[%s] max transcribe failed", sid)
  237. busy = False
  238. vad.reset()
  239. vad.total_samples = len(buf.buffer)
  240. logger.info("[%s] stream ended after %d frames", sid, fc)
  241. try:
  242. await _acc()
  243. except asyncio.CancelledError:
  244. pass
  245. except Exception:
  246. logger.exception("[%s] _acc failed", sid)
  247. async def _transcribe_and_respond(self, buf: AudioBuffer):
  248. all_audio = buf.get_all()
  249. buf.clear()
  250. if len(all_audio) <= 1600:
  251. return
  252. loop = asyncio.get_running_loop()
  253. result = await loop.run_in_executor(None, self.asr.transcribe_array, all_audio)
  254. txt = result.get("text", "").strip()
  255. if not txt:
  256. return
  257. logger.info("ASR: %s", txt[:80])
  258. await self._send({"type": "utterance", "text": txt, "seq": 0})
  259. # ── Streaming LLM + incremental TTS ──
  260. reply_full = ""
  261. tts_pending = ""
  262. tts_task = None
  263. async def _play_segment(text: str):
  264. try:
  265. a = await _tts(text)
  266. if a is not None and len(a) > 0:
  267. await self._play(a, 24000)
  268. except Exception:
  269. logger.exception("TTS segment failed")
  270. try:
  271. async for delta, is_final in _llm_stream(txt, self.hist):
  272. if delta:
  273. reply_full += delta
  274. tts_pending += delta
  275. # Send partial reply to client for display
  276. await self._send({"type": "reply_partial", "text": reply_full, "seq": 0})
  277. # Split at punctuation for incremental TTS
  278. while True:
  279. m = TTS_CHUNK_PATTERN.search(tts_pending)
  280. if not m:
  281. break
  282. pos = m.end()
  283. segment = tts_pending[:pos].strip()
  284. tts_pending = tts_pending[pos:].lstrip()
  285. if segment and len(segment) >= 2:
  286. logger.info("TTS seg: %s", segment[:40])
  287. # Play previous segment if still running
  288. if tts_task:
  289. await tts_task
  290. tts_task = asyncio.create_task(_play_segment(segment))
  291. if is_final:
  292. # Flush remaining text
  293. if tts_pending.strip():
  294. if tts_task:
  295. await tts_task
  296. tts_task = asyncio.create_task(_play_segment(tts_pending.strip()))
  297. break
  298. except Exception:
  299. logger.exception("LLM stream failed")
  300. reply_full = "抱歉,我暂时无法回答。"
  301. # Wait for final TTS segment to finish
  302. if tts_task:
  303. try:
  304. await asyncio.wait_for(tts_task, timeout=15)
  305. except asyncio.TimeoutError:
  306. pass
  307. if reply_full:
  308. await self._send({"type": "reply", "text": reply_full})
  309. self.hist.extend([
  310. {"role": "user", "content": txt},
  311. {"role": "assistant", "content": reply_full},
  312. ])
  313. self.hist[:] = self.hist[-20:]
  314. async def _play(self, audio: np.ndarray, sr: int):
  315. src = rtc.AudioSource(sr, 1)
  316. tk = rtc.LocalAudioTrack.create_audio_track("tts", src)
  317. await self.room.local_participant.publish_track(tk)
  318. for i in range(0, len(audio), sr // 50):
  319. c = audio[i: i + sr // 50]
  320. await src.capture_frame(rtc.AudioFrame(
  321. data=c.tobytes(), sample_rate=sr,
  322. num_channels=1, samples_per_channel=len(c),
  323. ))
  324. await asyncio.sleep(0.018)
  325. return tk
  326. async def _send(self, msg: dict):
  327. try:
  328. await self.room.local_participant.publish_data(
  329. json.dumps(msg, ensure_ascii=False).encode(),
  330. reliable=True, topic="transcription",
  331. )
  332. except Exception:
  333. logger.exception("send failed")
  334. async def main():
  335. import argparse
  336. p = argparse.ArgumentParser()
  337. p.add_argument("--room", required=True)
  338. a = p.parse_args()
  339. logging.basicConfig(level=logging.INFO)
  340. await Worker(a.room).run()
  341. if __name__ == "__main__":
  342. asyncio.run(main())