conversation_worker.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672
  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. logger = logging.getLogger("worker")
  34. # ── Env ──
  35. LIVEKIT_URL = os.environ.get("LIVEKIT_URL", "ws://localhost:7880")
  36. ASR_MODEL = os.environ.get("ASR_MODEL_PATH", "Qwen/Qwen3-ASR-0.6B")
  37. VLLM_URL = os.environ.get("VLLM_URL", "http://127.0.0.1:8000/v1")
  38. LLM_MODEL = os.environ.get("LLM_MODEL", "qwen3.6-35b-awq")
  39. MIMO_KEY = os.environ.get("MIMO_KEY", "")
  40. MIMO_API_BASE = os.environ.get("MIMO_API_BASE", "https://token-plan-cn.xiaomimimo.com/v1")
  41. # Provider switches: "vllm" | "mimo" for LLM, "qwen" | "mimo" for ASR
  42. LLM_PROVIDER = os.environ.get("LLM_PROVIDER", "vllm")
  43. ASR_PROVIDER = os.environ.get("ASR_PROVIDER", "qwen")
  44. # ── LLM ──
  45. LLM_MAX_TOKENS = 128
  46. LLM_TEMPERATURE = 0.7
  47. LLM_TIMEOUT = 25
  48. SYSTEM_PROMPT = (
  49. "你是友好的中文语音助手,名字叫狄诺尼试验员。"
  50. "回答简洁自然,2-3句即可,用口语化中文。"
  51. "不要使用 Markdown、代码块、表格或特殊符号。"
  52. "不要输出括号注释、不要使用英文缩写。"
  53. )
  54. # ── TTS segmentation (xiaozhi-style two-tier) ──
  55. FIRST_SENTENCE_PUNCT = ",、,。!?;::\n"
  56. SENTENCE_END_PUNCT = "。!?!?\n"
  57. MIN_TTS_CHARS = 2
  58. # ═══════════════════════════════════════════════════════════════
  59. # JWT
  60. # ═══════════════════════════════════════════════════════════════
  61. def _token(room, ident):
  62. n = int(time.time())
  63. return jwt.encode({
  64. "iss": "devkey", "sub": ident, "name": ident,
  65. "nbf": n - 60, "exp": n + 6 * 3600,
  66. "video": {"roomJoin": True, "room": room,
  67. "canPublish": True, "canSubscribe": True, "canPublishData": True},
  68. }, "secretsecretsecretsecretsecret12", algorithm="HS256")
  69. # ═══════════════════════════════════════════════════════════════
  70. # LLM (streaming, <think> filtering)
  71. # ═══════════════════════════════════════════════════════════════
  72. async def _llm_stream(prompt: str, hist: list[dict]):
  73. """Stream LLM tokens, dispatching based on LLM_PROVIDER env."""
  74. if LLM_PROVIDER == "mimo":
  75. async for x in _llm_stream_mimo(prompt, hist):
  76. yield x
  77. return
  78. async for x in _llm_stream_vllm(prompt, hist):
  79. yield x
  80. async def _llm_stream_vllm(prompt: str, hist: list[dict]):
  81. """vLLM streaming via OpenAI-compatible SSE."""
  82. msgs = [{"role": "system", "content": SYSTEM_PROMPT}]
  83. msgs.extend(hist)
  84. msgs.append({"role": "user", "content": prompt})
  85. async with aiohttp.ClientSession() as s:
  86. async with s.post(
  87. f"{VLLM_URL}/chat/completions",
  88. json={"model": LLM_MODEL, "messages": msgs,
  89. "max_tokens": LLM_MAX_TOKENS, "temperature": LLM_TEMPERATURE,
  90. "stream": True,
  91. "chat_template_kwargs": {"enable_thinking": False}},
  92. timeout=aiohttp.ClientTimeout(total=LLM_TIMEOUT),
  93. ) as r:
  94. async for line in r.content:
  95. line = line.decode().strip()
  96. if not line.startswith("data: "):
  97. continue
  98. data = line[6:]
  99. if data == "[DONE]":
  100. break
  101. try:
  102. chunk = json.loads(data)
  103. delta = chunk.get("choices", [{}])[0].get("delta", {})
  104. text = delta.get("content", "")
  105. if text:
  106. if "</think>" in text:
  107. text = text.split("</think>")[-1]
  108. if "<think>" in text:
  109. text = text.split("<think>")[0]
  110. if text.strip():
  111. yield text, False
  112. except Exception:
  113. continue
  114. yield "", True
  115. async def _llm_stream_mimo(prompt: str, hist: list[dict]):
  116. """Mimo v2.5 LLM streaming via OpenAI-compatible SSE."""
  117. if not MIMO_KEY:
  118. yield "", True
  119. return
  120. msgs = [{"role": "system", "content": SYSTEM_PROMPT}]
  121. msgs.extend(hist)
  122. msgs.append({"role": "user", "content": prompt})
  123. h = {"api-key": MIMO_KEY, "Content-Type": "application/json"}
  124. async with aiohttp.ClientSession() as s:
  125. async with s.post(
  126. f"{MIMO_API_BASE}/chat/completions",
  127. json={"model": "mimo-v2.5", "messages": msgs,
  128. "max_tokens": LLM_MAX_TOKENS, "temperature": LLM_TEMPERATURE,
  129. "stream": True},
  130. headers=h, timeout=aiohttp.ClientTimeout(total=LLM_TIMEOUT),
  131. ) as r:
  132. async for line in r.content:
  133. line = line.decode().strip()
  134. if not line.startswith("data: "):
  135. continue
  136. data = line[6:]
  137. if data == "[DONE]":
  138. break
  139. try:
  140. chunk = json.loads(data)
  141. delta = chunk.get("choices", [{}])[0].get("delta", {})
  142. text = delta.get("content", "")
  143. if text and text.strip():
  144. yield text, False
  145. except Exception:
  146. continue
  147. yield "", True
  148. # ═══════════════════════════════════════════════════════════════
  149. # ASR (dispatch: qwen-local | mimo-api)
  150. # ═══════════════════════════════════════════════════════════════
  151. async def _asr_transcribe(audio_data: np.ndarray, asr_engine=None) -> str:
  152. """Dispatch ASR based on ASR_PROVIDER env. Return final text."""
  153. if ASR_PROVIDER == "mimo":
  154. text = ""
  155. async for chunk, is_done in _asr_mimo_stream(audio_data):
  156. if chunk:
  157. text += chunk
  158. return text.strip()
  159. loop = asyncio.get_running_loop()
  160. result = await loop.run_in_executor(None, asr_engine.transcribe_array, audio_data)
  161. return result.get("text", "").strip()
  162. async def _asr_mimo_stream(audio_data: np.ndarray):
  163. """Mimo v2.5 streaming ASR. Yields (text_snapshot, is_final)."""
  164. if not MIMO_KEY:
  165. yield "", True
  166. return
  167. import io, wave
  168. buf = io.BytesIO()
  169. with wave.open(buf, "wb") as wf:
  170. wf.setnchannels(1)
  171. wf.setsampwidth(2)
  172. wf.setframerate(16000)
  173. wf.writeframes((audio_data * 32767).astype(np.int16).tobytes())
  174. audio_b64 = base64.b64encode(buf.getvalue()).decode()
  175. h = {"api-key": MIMO_KEY, "Content-Type": "application/json"}
  176. async with aiohttp.ClientSession() as s:
  177. async with s.post(
  178. f"{MIMO_API_BASE}/chat/completions",
  179. json={
  180. "model": "mimo-v2.5-asr",
  181. "messages": [{"role": "user", "content": [
  182. {"type": "input_audio", "input_audio": {
  183. "data": f"data:audio/wav;base64,{audio_b64}"}}
  184. ]}],
  185. "stream": True,
  186. "asr_options": {"language": "auto"},
  187. },
  188. headers=h, timeout=aiohttp.ClientTimeout(total=20),
  189. ) as r:
  190. async for line in r.content:
  191. line = line.decode().strip()
  192. if not line.startswith("data: "):
  193. continue
  194. data = line[6:]
  195. if data == "[DONE]":
  196. yield "", True
  197. return
  198. try:
  199. chunk = json.loads(data)
  200. delta = chunk.get("choices", [{}])[0].get("delta", {})
  201. text = delta.get("content", "")
  202. if text:
  203. yield text, False
  204. except Exception:
  205. continue
  206. yield "", True
  207. # ═══════════════════════════════════════════════════════════════
  208. # TTS
  209. # ═══════════════════════════════════════════════════════════════
  210. async def _tts_stream(text: str, voice: str = "Chloe"):
  211. """Mimo v2.5 streaming TTS. Yields (pcm_chunk: np.ndarray, sr: int, is_final: bool)."""
  212. if not MIMO_KEY:
  213. yield np.array([], dtype=np.float32), 24000, True
  214. return
  215. if voice.startswith("data:"):
  216. model = "mimo-v2.5-tts-voiceclone"
  217. else:
  218. model = "mimo-v2.5-tts"
  219. h = {"api-key": MIMO_KEY, "Content-Type": "application/json"}
  220. body: dict = {
  221. "model": model,
  222. "messages": [
  223. {"role": "user", "content": ""},
  224. {"role": "assistant", "content": text},
  225. ],
  226. "audio": {"format": "wav", "voice": voice},
  227. "stream": True,
  228. }
  229. async with aiohttp.ClientSession() as s:
  230. try:
  231. async with s.post(
  232. f"{MIMO_API_BASE}/chat/completions",
  233. json=body, headers=h,
  234. timeout=aiohttp.ClientTimeout(total=30),
  235. ) as r:
  236. async for line in r.content:
  237. line = line.decode().strip()
  238. if not line.startswith("data: "):
  239. continue
  240. data = line[6:]
  241. if data == "[DONE]":
  242. break
  243. try:
  244. chunk = json.loads(data)
  245. delta = chunk.get("choices", [{}])[0].get("delta", {})
  246. audio = delta.get("audio")
  247. if audio and isinstance(audio, dict) and "data" in audio:
  248. pcm = np.frombuffer(
  249. base64.b64decode(audio["data"]), dtype=np.int16
  250. ).astype(np.float32) / 32768.0
  251. if len(pcm) > 0:
  252. yield pcm, 24000, False
  253. except Exception:
  254. continue
  255. except Exception:
  256. logger.exception("TTS stream failed")
  257. yield np.array([], dtype=np.float32), 24000, True
  258. def _clean_tts_text(text: str) -> str:
  259. text = re.sub(r"\*{1,3}(.*?)\*{1,3}", r"\1", text)
  260. text = re.sub(r"`{1,3}.*?`{1,3}", "", text)
  261. text = re.sub(r"\[([^\]]+)]\([^)]+\)", r"\1", text)
  262. text = re.sub(r"#{1,6}\s*", "", text)
  263. text = re.sub(r"[>\-]\s", "", text)
  264. text = re.sub(r"[\U0001F300-\U0001F9FF]", "", text)
  265. return text.strip()
  266. # ═══════════════════════════════════════════════════════════════
  267. # TTS Segmenter (xiaozhi-style: two-tier, processed_chars)
  268. # ═══════════════════════════════════════════════════════════════
  269. @dataclass
  270. class _TTSSegmenter:
  271. buffer: str = ""
  272. processed: int = 0
  273. is_first: bool = True
  274. def feed(self, text: str) -> list[str]:
  275. self.buffer += text
  276. segments = []
  277. puncts = FIRST_SENTENCE_PUNCT if self.is_first else SENTENCE_END_PUNCT
  278. unprocessed = self.buffer[self.processed:]
  279. last_pos = -1
  280. for ch in puncts:
  281. pos = unprocessed.rfind(ch)
  282. if pos > last_pos:
  283. last_pos = pos
  284. if last_pos >= 0:
  285. seg_raw = unprocessed[:last_pos + 1]
  286. seg_clean = _clean_tts_text(seg_raw)
  287. if len(seg_clean) >= MIN_TTS_CHARS:
  288. segments.append(seg_clean)
  289. self.processed += len(seg_raw)
  290. if self.is_first:
  291. self.is_first = False
  292. return segments
  293. def flush(self) -> str:
  294. remaining = self.buffer[self.processed:].strip()
  295. if remaining:
  296. remaining = _clean_tts_text(remaining)
  297. self.processed = len(self.buffer)
  298. return remaining
  299. # ── VAD ──
  300. VAD_MODEL_PATH = os.environ.get("VAD_MODEL_PATH") or os.path.join(
  301. os.path.dirname(os.path.abspath(__file__)), "silero_vad.onnx")
  302. VAD_THRESHOLD_HIGH = float(os.environ.get("VAD_THRESHOLD_HIGH", "0.5"))
  303. VAD_THRESHOLD_LOW = float(os.environ.get("VAD_THRESHOLD_LOW", "0.2"))
  304. VAD_WINDOW_SIZE = int(os.environ.get("VAD_WINDOW_SIZE", "5"))
  305. VAD_VOICE_RATIO = float(os.environ.get("VAD_VOICE_RATIO", "0.5"))
  306. MIN_SPEECH_S = float(os.environ.get("MIN_SPEECH_S", "0.3"))
  307. MIN_SILENCE_S = float(os.environ.get("MIN_SILENCE_S", "0.4"))
  308. MAX_SPEECH_S = float(os.environ.get("MAX_SPEECH_S", "8.0"))
  309. # Global ONNX session (shared across participants)
  310. _vad_session = None
  311. def _get_vad_session():
  312. global _vad_session
  313. if _vad_session is not None:
  314. return _vad_session
  315. if not _HAS_ONNX or not os.path.exists(VAD_MODEL_PATH):
  316. return None
  317. opts = onnxruntime.SessionOptions()
  318. opts.inter_op_num_threads = 1
  319. opts.intra_op_num_threads = 1
  320. _vad_session = onnxruntime.InferenceSession(
  321. VAD_MODEL_PATH, providers=["CPUExecutionProvider"], sess_options=opts,
  322. )
  323. logger.info("Silero VAD loaded (%s)", VAD_MODEL_PATH)
  324. return _vad_session
  325. # ═══════════════════════════════════════════════════════════════
  326. # Silero VAD (ONNX, per-participant state)
  327. # ═══════════════════════════════════════════════════════════════
  328. class _SileroVAD:
  329. """Xiaozhi-style Silero VAD with dual-threshold + sliding window."""
  330. def __init__(self):
  331. self._sess = _get_vad_session()
  332. self._state = np.zeros((2, 1, 128), dtype=np.float32)
  333. self._context = np.zeros((1, 64), dtype=np.float32)
  334. self._window: deque = deque(maxlen=max(VAD_WINDOW_SIZE, 1))
  335. self.in_speech = False
  336. self.speech_start_sample = 0
  337. self.silence_counter = 0
  338. self.total_samples = 0
  339. self._sr = np.array(16000, dtype=np.int64)
  340. def process(self, frame: np.ndarray) -> bool:
  341. """Process 160-sample (10ms) frame. Call every frame. Returns in_speech."""
  342. n = len(frame)
  343. if self._sess is not None:
  344. # ── Silero ONNX inference ──
  345. # Silero processes 512-sample windows. Accumulate frames.
  346. if not hasattr(self, '_buf'):
  347. self._buf = np.array([], dtype=np.float32)
  348. self._buf = np.concatenate([self._buf, frame]).astype(np.float32)
  349. is_voice = False
  350. while len(self._buf) >= 512:
  351. chunk = self._buf[:512]
  352. self._buf = self._buf[512:]
  353. audio_in = chunk.reshape(1, -1)
  354. inp = np.concatenate([self._context, audio_in], axis=1).astype(np.float32)
  355. out, self._state = self._sess.run(
  356. None, {"input": inp, "state": self._state, "sr": self._sr},
  357. )
  358. self._context = inp[:, -64:]
  359. prob = out.item()
  360. is_voice = prob >= VAD_THRESHOLD_HIGH or (
  361. prob > VAD_THRESHOLD_LOW and (
  362. self._window[-1] if self._window else False
  363. )
  364. )
  365. self._window.append(is_voice)
  366. else:
  367. # ── Fallback: energy-based ──
  368. energy = float(np.sqrt(np.mean(frame ** 2)))
  369. if energy > 0.015:
  370. is_voice = True
  371. elif energy < 0.005:
  372. is_voice = False
  373. else:
  374. is_voice = self._window[-1] if self._window else False
  375. self._window.append(is_voice)
  376. have_voice = sum(self._window) / max(len(self._window), 1) >= VAD_VOICE_RATIO
  377. if have_voice and not self.in_speech:
  378. logger.info("VAD START")
  379. self.in_speech = True
  380. self.speech_start_sample = self.total_samples
  381. self.silence_counter = 0
  382. elif not have_voice and self.in_speech:
  383. self.silence_counter += n
  384. elif have_voice:
  385. self.silence_counter = 0
  386. self.total_samples += n
  387. return self.in_speech
  388. def should_end(self) -> bool:
  389. return self.in_speech and self.silence_counter >= int(MIN_SILENCE_S * 16000)
  390. def min_speech_met(self) -> bool:
  391. return (self.total_samples - self.speech_start_sample) / 16000 >= MIN_SPEECH_S
  392. def reset(self):
  393. self._window.clear()
  394. self.in_speech = False
  395. self.speech_start_sample = 0
  396. self.silence_counter = 0
  397. self._state = np.zeros((2, 1, 128), dtype=np.float32)
  398. self._context = np.zeros((1, 64), dtype=np.float32)
  399. if hasattr(self, '_buf'):
  400. self._buf = np.array([], dtype=np.float32)
  401. # ═══════════════════════════════════════════════════════════════
  402. # Worker
  403. # ═══════════════════════════════════════════════════════════════
  404. class Worker:
  405. def __init__(self, room, identity="asr-bot"):
  406. self.rn, self.id = room, identity
  407. self.room = rtc.Room()
  408. self.tasks: dict = {}
  409. self.hist: list[dict] = []
  410. async def run(self):
  411. logger.info("Loading ASR model...")
  412. self.asr = QwenASREngine(model_id=ASR_MODEL, language=None)
  413. logger.info("ASR ready")
  414. self.room.on("track_subscribed", self._on_track)
  415. self.room.on("participant_disconnected", self._on_off)
  416. self.room.on("participant_connected", lambda p: None)
  417. self.room.on("track_published", lambda pub, p: None)
  418. await self.room.connect(LIVEKIT_URL, _token(self.rn, self.id))
  419. logger.info("Worker ready (VAD+ASR+LLM+TTS)")
  420. for p in self.room.remote_participants.values():
  421. for pub in p.track_publications.values():
  422. if pub.track and pub.kind == rtc.TrackKind.KIND_AUDIO:
  423. self._on_track(pub.track, pub, p)
  424. try:
  425. await asyncio.Future()
  426. finally:
  427. await self.room.disconnect()
  428. def _on_track(self, t, _, p):
  429. s = p.sid
  430. if s in self.tasks:
  431. return
  432. logger.info("TRACK %s", p.identity)
  433. self.tasks[s] = asyncio.create_task(self._run(s, t))
  434. def _on_off(self, p):
  435. x = self.tasks.pop(p.sid, None)
  436. if x:
  437. x.cancel()
  438. async def _run(self, sid, track):
  439. logger.info("_run %s", sid)
  440. buf = AudioBuffer(max_duration=MAX_SPEECH_S + 4, sample_rate=16000)
  441. sr = 16000
  442. vad = _SileroVAD()
  443. busy = False
  444. play_tasks: list[asyncio.Task] = []
  445. async def _acc():
  446. nonlocal busy, play_tasks
  447. stream = rtc.AudioStream(track, sample_rate=sr, num_channels=1)
  448. fc = 0
  449. async for ev in stream:
  450. fc += 1
  451. if busy:
  452. continue
  453. arr = np.frombuffer(ev.frame.data, dtype=np.int16).astype(np.float32) / 32768.0
  454. buf.append(arr)
  455. was_speech = vad.in_speech
  456. vad.process(arr)
  457. if fc % 100 == 0:
  458. logger.info("[%s] af#%d e=%.4f vad=%s blen=%d",
  459. sid, fc, float(np.sqrt(np.mean(arr ** 2))),
  460. vad.in_speech, len(buf.buffer))
  461. if was_speech and vad.should_end() and vad.min_speech_met():
  462. logger.info("[%s] VAD END blen=%d", sid, len(buf.buffer))
  463. for pt in play_tasks:
  464. if not pt.done():
  465. pt.cancel()
  466. play_tasks.clear()
  467. busy = True
  468. try:
  469. await self._transcribe_and_respond(buf, play_tasks)
  470. except Exception:
  471. logger.exception("[%s] transcribe failed", sid)
  472. busy = False
  473. vad.reset()
  474. vad.total_samples = len(buf.buffer)
  475. if vad.in_speech and vad.total_samples >= int(MAX_SPEECH_S * sr):
  476. logger.info("[%s] VAD MAX blen=%d", sid, len(buf.buffer))
  477. for pt in play_tasks:
  478. if not pt.done():
  479. pt.cancel()
  480. play_tasks.clear()
  481. busy = True
  482. try:
  483. await self._transcribe_and_respond(buf, play_tasks)
  484. except Exception:
  485. logger.exception("[%s] max failed", sid)
  486. busy = False
  487. vad.reset()
  488. vad.total_samples = len(buf.buffer)
  489. logger.info("[%s] stream end fc=%d", sid, fc)
  490. try:
  491. await _acc()
  492. except asyncio.CancelledError:
  493. pass
  494. except Exception:
  495. logger.exception("[%s] _acc failed", sid)
  496. async def _transcribe_and_respond(self, buf: AudioBuffer, play_tasks: list[asyncio.Task] | None = None):
  497. if play_tasks is None:
  498. play_tasks = []
  499. all_audio = buf.get_all()
  500. buf.clear()
  501. if len(all_audio) <= 1600:
  502. return
  503. loop = asyncio.get_running_loop()
  504. txt = await _asr_transcribe(all_audio, self.asr)
  505. if not txt:
  506. return
  507. logger.info("ASR: %s", txt[:80])
  508. await self._send({"type": "utterance", "text": txt, "seq": 0})
  509. # ── Streaming LLM + concurrent TTS playback ──
  510. reply_full = ""
  511. seg = _TTSSegmenter()
  512. try:
  513. async for delta, is_final in _llm_stream(txt, self.hist):
  514. if delta:
  515. reply_full += delta
  516. await self._send({"type": "reply_partial", "text": reply_full, "seq": 0})
  517. for s in seg.feed(delta):
  518. logger.info("TTS seg(%d): %s", len(s), s[:40])
  519. play_tasks.append(asyncio.create_task(self._tts_and_play(s)))
  520. if is_final:
  521. remaining = seg.flush()
  522. if remaining:
  523. play_tasks.append(asyncio.create_task(self._tts_and_play(remaining)))
  524. break
  525. except Exception:
  526. logger.exception("LLM stream failed")
  527. reply_full = "抱歉,我暂时无法回答。"
  528. if reply_full:
  529. await self._send({"type": "reply", "text": reply_full})
  530. self.hist.extend([
  531. {"role": "user", "content": txt},
  532. {"role": "assistant", "content": reply_full},
  533. ])
  534. self.hist[:] = self.hist[-20:]
  535. async def _tts_and_play(self, text: str):
  536. """Stream TTS audio and play chunks as they arrive."""
  537. try:
  538. chunks: list[np.ndarray] = []
  539. async for pcm, sr, is_final in _tts_stream(text):
  540. if len(pcm) > 0:
  541. chunks.append(pcm)
  542. if is_final:
  543. break
  544. if chunks:
  545. audio = np.concatenate(chunks)
  546. if len(audio) > 0:
  547. await self._play(audio, 24000)
  548. except (asyncio.CancelledError, asyncio.TimeoutError):
  549. pass
  550. except Exception:
  551. logger.exception("TTS+play failed")
  552. async def _play(self, audio: np.ndarray, sr: int):
  553. src = rtc.AudioSource(sr, 1)
  554. tk = rtc.LocalAudioTrack.create_audio_track("tts", src)
  555. await self.room.local_participant.publish_track(tk)
  556. for i in range(0, len(audio), sr // 50):
  557. c = audio[i: i + sr // 50]
  558. await src.capture_frame(rtc.AudioFrame(
  559. data=c.tobytes(), sample_rate=sr,
  560. num_channels=1, samples_per_channel=len(c),
  561. ))
  562. await asyncio.sleep(0.018)
  563. return tk
  564. async def _send(self, msg: dict):
  565. try:
  566. await self.room.local_participant.publish_data(
  567. json.dumps(msg, ensure_ascii=False).encode(),
  568. reliable=True, topic="transcription",
  569. )
  570. except Exception:
  571. pass
  572. async def main():
  573. import argparse
  574. p = argparse.ArgumentParser()
  575. p.add_argument("--room", required=True)
  576. a = p.parse_args()
  577. logging.basicConfig(level=logging.INFO)
  578. await Worker(a.room).run()
  579. if __name__ == "__main__":
  580. asyncio.run(main())