conversation_worker.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  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. # ── Single persistent TTS output track per session ──
  445. # One track is reused for all turns so audio never cross-streams
  446. # between turns/segments (old code published a new track each call).
  447. tts_src = rtc.AudioSource(24000, 1)
  448. tts_track = rtc.LocalAudioTrack.create_audio_track("tts", tts_src)
  449. await self.room.local_participant.publish_track(tts_track)
  450. play_q: "asyncio.Queue[tuple[int, np.ndarray]]" = asyncio.Queue()
  451. gen = 0 # turn generation; bumped to flush in-flight audio
  452. cur_gen = {"v": -1} # generation currently being played by _player
  453. async def _player():
  454. """Drain TTS audio sequentially on the single track."""
  455. while True:
  456. g, audio = await play_q.get()
  457. cur_gen["v"] = g
  458. for i in range(0, len(audio), 24000 // 50):
  459. if cur_gen["v"] != g:
  460. break # a newer turn started; drop this stale audio
  461. c = audio[i: i + 24000 // 50]
  462. await tts_src.capture_frame(rtc.AudioFrame(
  463. data=c.tobytes(), sample_rate=24000,
  464. num_channels=1, samples_per_channel=len(c),
  465. ))
  466. await asyncio.sleep(0.018)
  467. cur_gen["v"] = -1
  468. player_task = asyncio.create_task(_player())
  469. async def _acc():
  470. nonlocal busy, gen
  471. stream = rtc.AudioStream(track, sample_rate=sr, num_channels=1)
  472. fc = 0
  473. async for ev in stream:
  474. fc += 1
  475. if busy:
  476. continue
  477. arr = np.frombuffer(ev.frame.data, dtype=np.int16).astype(np.float32) / 32768.0
  478. buf.append(arr)
  479. was_speech = vad.in_speech
  480. vad.process(arr)
  481. if fc % 100 == 0:
  482. logger.info("[%s] af#%d e=%.4f vad=%s blen=%d",
  483. sid, fc, float(np.sqrt(np.mean(arr ** 2))),
  484. vad.in_speech, len(buf.buffer))
  485. if was_speech and vad.should_end() and vad.min_speech_met():
  486. logger.info("[%s] VAD END blen=%d", sid, len(buf.buffer))
  487. gen += 1
  488. cur_gen["v"] = -1 # stop current playback immediately
  489. _drain_queue(play_q)
  490. busy = True
  491. try:
  492. await self._transcribe_and_respond(buf, play_q, gen)
  493. except Exception:
  494. logger.exception("[%s] transcribe failed", sid)
  495. busy = False
  496. vad.reset()
  497. vad.total_samples = len(buf.buffer)
  498. if vad.in_speech and vad.total_samples >= int(MAX_SPEECH_S * sr):
  499. logger.info("[%s] VAD MAX blen=%d", sid, len(buf.buffer))
  500. gen += 1
  501. cur_gen["v"] = -1
  502. _drain_queue(play_q)
  503. busy = True
  504. try:
  505. await self._transcribe_and_respond(buf, play_q, gen)
  506. except Exception:
  507. logger.exception("[%s] max failed", sid)
  508. busy = False
  509. vad.reset()
  510. vad.total_samples = len(buf.buffer)
  511. logger.info("[%s] stream end fc=%d", sid, fc)
  512. try:
  513. await _acc()
  514. except asyncio.CancelledError:
  515. pass
  516. except Exception:
  517. logger.exception("[%s] _acc failed", sid)
  518. finally:
  519. player_task.cancel()
  520. try:
  521. await self.room.local_participant.unpublish_track(tts_track)
  522. except Exception:
  523. pass
  524. async def _transcribe_and_respond(self, buf: AudioBuffer, play_q, gen: int):
  525. all_audio = buf.get_all()
  526. buf.clear()
  527. if len(all_audio) <= 1600:
  528. return
  529. txt = await _asr_transcribe(all_audio, self.asr)
  530. if not txt:
  531. return
  532. logger.info("ASR: %s", txt[:80])
  533. await self._send({"type": "utterance", "text": txt, "seq": 0})
  534. # ── Streaming LLM + concurrent TTS generation (sequential playback) ──
  535. # TTS is generated concurrently per segment, but the player consumes the
  536. # queue in order on a single track, so segments never overlap.
  537. reply_full = ""
  538. seg = _TTSSegmenter()
  539. try:
  540. async for delta, is_final in _llm_stream(txt, self.hist):
  541. if delta:
  542. reply_full += delta
  543. await self._send({"type": "reply_partial", "text": reply_full, "seq": 0})
  544. for s in seg.feed(delta):
  545. logger.info("TTS seg(%d): %s", len(s), s[:40])
  546. asyncio.create_task(self._tts_enqueue(s, play_q, gen))
  547. if is_final:
  548. remaining = seg.flush()
  549. if remaining:
  550. asyncio.create_task(self._tts_enqueue(remaining, play_q, gen))
  551. break
  552. except Exception:
  553. logger.exception("LLM stream failed")
  554. reply_full = "抱歉,我暂时无法回答。"
  555. if reply_full:
  556. await self._send({"type": "reply", "text": reply_full})
  557. self.hist.extend([
  558. {"role": "user", "content": txt},
  559. {"role": "assistant", "content": reply_full},
  560. ])
  561. self.hist[:] = self.hist[-20:]
  562. async def _tts_enqueue(self, text: str, play_q, gen: int):
  563. """Stream TTS audio for one segment, then enqueue it for playback."""
  564. try:
  565. chunks: list[np.ndarray] = []
  566. async for pcm, sr, is_final in _tts_stream(text):
  567. if len(pcm) > 0:
  568. chunks.append(pcm)
  569. if is_final:
  570. break
  571. if chunks:
  572. audio = np.concatenate(chunks)
  573. if len(audio) > 0:
  574. await play_q.put((gen, audio))
  575. except asyncio.CancelledError:
  576. pass
  577. except Exception:
  578. logger.exception("TTS failed")
  579. pass
  580. async def main():
  581. import argparse
  582. p = argparse.ArgumentParser()
  583. p.add_argument("--room", required=True)
  584. a = p.parse_args()
  585. logging.basicConfig(level=logging.INFO)
  586. await Worker(a.room).run()
  587. if __name__ == "__main__":
  588. asyncio.run(main())
  589. def _drain_queue(q: "asyncio.Queue") -> None:
  590. """Discard any queued-but-not-yet-played TTS audio."""
  591. while not q.empty():
  592. try:
  593. q.get_nowait()
  594. except Exception:
  595. break