worker.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689
  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. text = text.strip()
  159. else:
  160. loop = asyncio.get_running_loop()
  161. result = await loop.run_in_executor(None, asr_engine.transcribe_array, audio_data)
  162. text = result.get("text", "").strip()
  163. # Filter single-char filler results (common ASR noise)
  164. fillers = {"嗯", "啊", "哦", "呃", "咦", "噢", "唔", "哎"}
  165. if text and len(text) <= 2 and all(c in "嗯啊哦呃咦噢唔哎?!。,、~·~" for c in text):
  166. logger.info("ASR filtered filler: %s", text)
  167. return ""
  168. return text
  169. async def _asr_mimo_stream(audio_data: np.ndarray):
  170. """Mimo v2.5 streaming ASR. Yields (text_snapshot, is_final)."""
  171. if not MIMO_KEY:
  172. yield "", True
  173. return
  174. import io, wave
  175. buf = io.BytesIO()
  176. with wave.open(buf, "wb") as wf:
  177. wf.setnchannels(1)
  178. wf.setsampwidth(2)
  179. wf.setframerate(16000)
  180. wf.writeframes((audio_data * 32767).astype(np.int16).tobytes())
  181. audio_b64 = base64.b64encode(buf.getvalue()).decode()
  182. h = {"api-key": MIMO_KEY, "Content-Type": "application/json"}
  183. async with aiohttp.ClientSession() as s:
  184. async with s.post(
  185. f"{MIMO_API_BASE}/chat/completions",
  186. json={
  187. "model": "mimo-v2.5-asr",
  188. "messages": [{"role": "user", "content": [
  189. {"type": "input_audio", "input_audio": {
  190. "data": f"data:audio/wav;base64,{audio_b64}"}}
  191. ]}],
  192. "stream": True,
  193. "asr_options": {"language": "auto"},
  194. },
  195. headers=h, timeout=aiohttp.ClientTimeout(total=20),
  196. ) as r:
  197. async for line in r.content:
  198. line = line.decode().strip()
  199. if not line.startswith("data: "):
  200. continue
  201. data = line[6:]
  202. if data == "[DONE]":
  203. yield "", True
  204. return
  205. try:
  206. chunk = json.loads(data)
  207. delta = chunk.get("choices", [{}])[0].get("delta", {})
  208. text = delta.get("content", "")
  209. if text:
  210. yield text, False
  211. except Exception:
  212. continue
  213. yield "", True
  214. # ═══════════════════════════════════════════════════════════════
  215. # TTS
  216. # ═══════════════════════════════════════════════════════════════
  217. async def _tts(text: str, voice: str = "Chloe") -> np.ndarray | None:
  218. """Mimo TTS. Returns int16 PCM at 24kHz mono, or None on failure."""
  219. if not MIMO_KEY:
  220. return None
  221. if voice.startswith("data:"):
  222. model = "mimo-v2.5-tts-voiceclone"
  223. else:
  224. model = "mimo-v2.5-tts"
  225. h = {"api-key": MIMO_KEY, "Content-Type": "application/json"}
  226. body: dict = {
  227. "model": model,
  228. "messages": [
  229. {"role": "user", "content": ""},
  230. {"role": "assistant", "content": text},
  231. ],
  232. "audio": {"format": "wav", "voice": voice},
  233. }
  234. async with aiohttp.ClientSession() as s:
  235. try:
  236. async with s.post(
  237. f"{MIMO_API_BASE}/chat/completions",
  238. json=body, headers=h,
  239. timeout=aiohttp.ClientTimeout(total=30),
  240. ) as r:
  241. result = await r.json()
  242. b64 = result.get("choices", [{}])[0].get("message", {}).get("audio", {}).get("data", "")
  243. if b64:
  244. return np.frombuffer(base64.b64decode(b64), dtype=np.int16)
  245. except Exception:
  246. logger.exception("TTS failed")
  247. return None
  248. def _clean_tts_text(text: str) -> str:
  249. text = re.sub(r"\*{1,3}(.*?)\*{1,3}", r"\1", text)
  250. text = re.sub(r"`{1,3}.*?`{1,3}", "", text)
  251. text = re.sub(r"\[([^\]]+)]\([^)]+\)", r"\1", text)
  252. text = re.sub(r"#{1,6}\s*", "", text)
  253. text = re.sub(r"[>\-]\s", "", text)
  254. text = re.sub(r"[\U0001F300-\U0001F9FF]", "", text)
  255. return text.strip()
  256. # ═══════════════════════════════════════════════════════════════
  257. # TTS Segmenter (xiaozhi-style: two-tier, processed_chars)
  258. # ═══════════════════════════════════════════════════════════════
  259. @dataclass
  260. class _TTSSegmenter:
  261. buffer: str = ""
  262. processed: int = 0
  263. is_first: bool = True
  264. def feed(self, text: str) -> list[str]:
  265. self.buffer += text
  266. segments = []
  267. puncts = FIRST_SENTENCE_PUNCT if self.is_first else SENTENCE_END_PUNCT
  268. unprocessed = self.buffer[self.processed:]
  269. last_pos = -1
  270. for ch in puncts:
  271. pos = unprocessed.rfind(ch)
  272. if pos > last_pos:
  273. last_pos = pos
  274. if last_pos >= 0:
  275. seg_raw = unprocessed[:last_pos + 1]
  276. seg_clean = _clean_tts_text(seg_raw)
  277. if len(seg_clean) >= MIN_TTS_CHARS:
  278. segments.append(seg_clean)
  279. self.processed += len(seg_raw)
  280. if self.is_first:
  281. self.is_first = False
  282. return segments
  283. def flush(self) -> str:
  284. remaining = self.buffer[self.processed:].strip()
  285. if remaining:
  286. remaining = _clean_tts_text(remaining)
  287. self.processed = len(self.buffer)
  288. return remaining
  289. # ── VAD ──
  290. VAD_MODEL_PATH = os.environ.get("VAD_MODEL_PATH") or os.path.join(
  291. os.path.dirname(os.path.abspath(__file__)), "whisper_asr", "silero_vad.onnx")
  292. VAD_THRESHOLD_HIGH = float(os.environ.get("VAD_THRESHOLD_HIGH", "0.5"))
  293. VAD_THRESHOLD_LOW = float(os.environ.get("VAD_THRESHOLD_LOW", "0.2"))
  294. VAD_WINDOW_SIZE = int(os.environ.get("VAD_WINDOW_SIZE", "5"))
  295. VAD_VOICE_RATIO = float(os.environ.get("VAD_VOICE_RATIO", "0.5"))
  296. MIN_SPEECH_S = float(os.environ.get("MIN_SPEECH_S", "0.3"))
  297. MIN_SILENCE_S = float(os.environ.get("MIN_SILENCE_S", "0.4"))
  298. MAX_SPEECH_S = float(os.environ.get("MAX_SPEECH_S", "8.0"))
  299. # Global ONNX session (shared across participants)
  300. _vad_session = None
  301. def _get_vad_session():
  302. global _vad_session
  303. if _vad_session is not None:
  304. return _vad_session
  305. if not _HAS_ONNX or not os.path.exists(VAD_MODEL_PATH):
  306. return None
  307. opts = onnxruntime.SessionOptions()
  308. opts.inter_op_num_threads = 1
  309. opts.intra_op_num_threads = 1
  310. _vad_session = onnxruntime.InferenceSession(
  311. VAD_MODEL_PATH, providers=["CPUExecutionProvider"], sess_options=opts,
  312. )
  313. logger.info("Silero VAD loaded (%s)", VAD_MODEL_PATH)
  314. return _vad_session
  315. # ═══════════════════════════════════════════════════════════════
  316. # Silero VAD (ONNX, per-participant state)
  317. # ═══════════════════════════════════════════════════════════════
  318. class _SileroVAD:
  319. """Xiaozhi-style Silero VAD with dual-threshold + sliding window."""
  320. def __init__(self):
  321. self._sess = _get_vad_session()
  322. self._state = np.zeros((2, 1, 128), dtype=np.float32)
  323. self._context = np.zeros((1, 64), dtype=np.float32)
  324. self._window: deque = deque(maxlen=max(VAD_WINDOW_SIZE, 1))
  325. self.in_speech = False
  326. self.speech_start_sample = 0
  327. self.silence_counter = 0
  328. self.total_samples = 0
  329. self._sr = np.array(16000, dtype=np.int64)
  330. def process(self, frame: np.ndarray) -> bool:
  331. """Process 160-sample (10ms) frame. Call every frame. Returns in_speech."""
  332. n = len(frame)
  333. if self._sess is not None:
  334. # ── Silero ONNX inference ──
  335. # Silero processes 512-sample windows. Accumulate frames.
  336. if not hasattr(self, '_buf'):
  337. self._buf = np.array([], dtype=np.float32)
  338. self._buf = np.concatenate([self._buf, frame]).astype(np.float32)
  339. is_voice = False
  340. while len(self._buf) >= 512:
  341. chunk = self._buf[:512]
  342. self._buf = self._buf[512:]
  343. audio_in = chunk.reshape(1, -1)
  344. inp = np.concatenate([self._context, audio_in], axis=1).astype(np.float32)
  345. out, self._state = self._sess.run(
  346. None, {"input": inp, "state": self._state, "sr": self._sr},
  347. )
  348. self._context = inp[:, -64:]
  349. prob = out.item()
  350. is_voice = prob >= VAD_THRESHOLD_HIGH or (
  351. prob > VAD_THRESHOLD_LOW and (
  352. self._window[-1] if self._window else False
  353. )
  354. )
  355. self._window.append(is_voice)
  356. else:
  357. # ── Fallback: energy-based ──
  358. energy = float(np.sqrt(np.mean(frame ** 2)))
  359. if energy > 0.015:
  360. is_voice = True
  361. elif energy < 0.005:
  362. is_voice = False
  363. else:
  364. is_voice = self._window[-1] if self._window else False
  365. self._window.append(is_voice)
  366. have_voice = sum(self._window) / max(len(self._window), 1) >= VAD_VOICE_RATIO
  367. if have_voice and not self.in_speech:
  368. logger.info("VAD START")
  369. self.in_speech = True
  370. self.speech_start_sample = self.total_samples
  371. self.silence_counter = 0
  372. elif not have_voice and self.in_speech:
  373. self.silence_counter += n
  374. elif have_voice:
  375. self.silence_counter = 0
  376. self.total_samples += n
  377. return self.in_speech
  378. def should_end(self) -> bool:
  379. return self.in_speech and self.silence_counter >= int(MIN_SILENCE_S * 16000)
  380. def min_speech_met(self) -> bool:
  381. return (self.total_samples - self.speech_start_sample) / 16000 >= MIN_SPEECH_S
  382. def reset(self):
  383. self._window.clear()
  384. self.in_speech = False
  385. self.speech_start_sample = 0
  386. self.silence_counter = 0
  387. self._state = np.zeros((2, 1, 128), dtype=np.float32)
  388. self._context = np.zeros((1, 64), dtype=np.float32)
  389. if hasattr(self, '_buf'):
  390. self._buf = np.array([], dtype=np.float32)
  391. # ═══════════════════════════════════════════════════════════════
  392. # Worker
  393. # ═══════════════════════════════════════════════════════════════
  394. class Worker:
  395. def __init__(self, room, identity="asr-bot"):
  396. self.rn, self.id = room, identity
  397. self.room = rtc.Room()
  398. self.tasks: dict = {}
  399. self.hist: list[dict] = []
  400. async def run(self):
  401. # Pre-load Silero VAD at startup (not lazy)
  402. _get_vad_session()
  403. # Only load Qwen3 if using local ASR
  404. if ASR_PROVIDER != "mimo":
  405. logger.info("Loading ASR model...")
  406. self.asr = QwenASREngine(model_id=ASR_MODEL, language=None)
  407. logger.info("ASR ready")
  408. else:
  409. self.asr = None
  410. logger.info("ASR: using Mimo API (no local model)")
  411. self.room.on("track_subscribed", self._on_track)
  412. self.room.on("participant_disconnected", self._on_off)
  413. self.room.on("participant_connected", lambda p: None)
  414. self.room.on("track_published", lambda pub, p: None)
  415. await self.room.connect(LIVEKIT_URL, _token(self.rn, self.id))
  416. logger.info("Worker ready (VAD+ASR+LLM+TTS)")
  417. for p in self.room.remote_participants.values():
  418. for pub in p.track_publications.values():
  419. if pub.track and pub.kind == rtc.TrackKind.KIND_AUDIO:
  420. self._on_track(pub.track, pub, p)
  421. try:
  422. await asyncio.Future()
  423. finally:
  424. await self.room.disconnect()
  425. def _on_track(self, t, _, p):
  426. s = p.sid
  427. if s in self.tasks:
  428. return
  429. logger.info("TRACK %s", p.identity)
  430. self.tasks[s] = asyncio.create_task(self._run(s, t))
  431. def _on_off(self, p):
  432. x = self.tasks.pop(p.sid, None)
  433. if x:
  434. x.cancel()
  435. async def _run(self, sid, track):
  436. logger.info("_run %s", sid)
  437. buf = AudioBuffer(max_duration=MAX_SPEECH_S + 4, sample_rate=16000)
  438. sr = 16000
  439. vad = _SileroVAD()
  440. busy = False
  441. # ── Single persistent TTS output track per session ──
  442. # One track is reused for all turns so audio never cross-streams
  443. # between turns/segments (old code published a new track each call).
  444. tts_src = rtc.AudioSource(24000, 1)
  445. tts_track = rtc.LocalAudioTrack.create_audio_track("tts", tts_src)
  446. await self.room.local_participant.publish_track(tts_track)
  447. play_q: "asyncio.Queue[tuple[int, np.ndarray]]" = asyncio.Queue()
  448. gen = 0 # turn generation; bumped to flush in-flight audio
  449. cur_gen = {"v": -1} # generation currently being played by _player
  450. async def _player():
  451. """Drain TTS audio sequentially on the single track."""
  452. while True:
  453. g, audio = await play_q.get()
  454. cur_gen["v"] = g
  455. for i in range(0, len(audio), 24000 // 50):
  456. if cur_gen["v"] != g:
  457. break # a newer turn started; drop this stale audio
  458. c = audio[i: i + 24000 // 50]
  459. await tts_src.capture_frame(rtc.AudioFrame(
  460. data=c.tobytes(), sample_rate=24000,
  461. num_channels=1, samples_per_channel=len(c),
  462. ))
  463. await asyncio.sleep(0.018)
  464. cur_gen["v"] = -1
  465. player_task = asyncio.create_task(_player())
  466. async def _acc():
  467. nonlocal busy, gen
  468. stream = rtc.AudioStream(track, sample_rate=sr, num_channels=1)
  469. fc = 0
  470. async for ev in stream:
  471. fc += 1
  472. if busy:
  473. continue
  474. arr = np.frombuffer(ev.frame.data, dtype=np.int16).astype(np.float32) / 32768.0
  475. buf.append(arr)
  476. was_speech = vad.in_speech
  477. vad.process(arr)
  478. if fc % 100 == 0:
  479. logger.info("[%s] af#%d e=%.4f vad=%s blen=%d",
  480. sid, fc, float(np.sqrt(np.mean(arr ** 2))),
  481. vad.in_speech, len(buf.buffer))
  482. if was_speech and vad.should_end() and vad.min_speech_met():
  483. logger.info("[%s] VAD END blen=%d", sid, len(buf.buffer))
  484. gen += 1
  485. cur_gen["v"] = -1 # stop current playback immediately
  486. _drain_queue(play_q)
  487. busy = True
  488. try:
  489. await self._transcribe_and_respond(buf, play_q, gen)
  490. except Exception:
  491. logger.exception("[%s] transcribe failed", sid)
  492. busy = False
  493. vad.reset()
  494. vad.total_samples = len(buf.buffer)
  495. if vad.in_speech and vad.total_samples >= int(MAX_SPEECH_S * sr):
  496. logger.info("[%s] VAD MAX blen=%d", sid, len(buf.buffer))
  497. gen += 1
  498. cur_gen["v"] = -1
  499. _drain_queue(play_q)
  500. busy = True
  501. try:
  502. await self._transcribe_and_respond(buf, play_q, gen)
  503. except Exception:
  504. logger.exception("[%s] max failed", sid)
  505. busy = False
  506. vad.reset()
  507. vad.total_samples = len(buf.buffer)
  508. logger.info("[%s] stream end fc=%d", sid, fc)
  509. try:
  510. await _acc()
  511. except asyncio.CancelledError:
  512. pass
  513. except Exception:
  514. logger.exception("[%s] _acc failed", sid)
  515. finally:
  516. player_task.cancel()
  517. try:
  518. await self.room.local_participant.unpublish_track(tts_track)
  519. except Exception:
  520. pass
  521. async def _transcribe_and_respond(self, buf: AudioBuffer, play_q, gen: int):
  522. all_audio = buf.get_all()
  523. buf.clear()
  524. if len(all_audio) <= 1600:
  525. return
  526. txt = await _asr_transcribe(all_audio, self.asr)
  527. if not txt:
  528. return
  529. logger.info("ASR: %s", txt[:80])
  530. await self._send({"type": "utterance", "text": txt, "seq": 0})
  531. # ── Streaming LLM, collect segments for ordered TTS ──
  532. reply_full = ""
  533. seg = _TTSSegmenter()
  534. tts_segments: list[str] = []
  535. try:
  536. async for delta, is_final in _llm_stream(txt, self.hist):
  537. if delta:
  538. reply_full += delta
  539. await self._send({"type": "reply_partial", "text": reply_full, "seq": 0})
  540. tts_segments.extend(seg.feed(delta))
  541. if is_final:
  542. remaining = seg.flush()
  543. if remaining:
  544. tts_segments.append(remaining)
  545. break
  546. except Exception:
  547. logger.exception("LLM stream failed")
  548. reply_full = "抱歉,我暂时无法回答。"
  549. # Ordered TTS: generate concurrently but enqueue in order
  550. if tts_segments:
  551. tasks = [asyncio.create_task(_tts(s)) for s in tts_segments]
  552. for i, task in enumerate(tasks):
  553. try:
  554. pcm = await asyncio.wait_for(task, timeout=15)
  555. if pcm is not None and len(pcm) > 0:
  556. logger.info("TTS seg(%d/%d): %s", i+1, len(tasks), tts_segments[i][:40])
  557. await play_q.put((gen, pcm))
  558. except (asyncio.TimeoutError, asyncio.CancelledError):
  559. break
  560. except Exception:
  561. logger.exception("TTS seg %d failed", i+1)
  562. if reply_full:
  563. await self._send({"type": "reply", "text": reply_full})
  564. self.hist.extend([
  565. {"role": "user", "content": txt},
  566. {"role": "assistant", "content": reply_full},
  567. ])
  568. self.hist[:] = self.hist[-20:]
  569. async def _send(self, msg: dict):
  570. try:
  571. await self.room.local_participant.publish_data(
  572. json.dumps(msg, ensure_ascii=False).encode(),
  573. reliable=True, topic="transcription",
  574. )
  575. except Exception:
  576. pass
  577. async def main():
  578. import argparse
  579. p = argparse.ArgumentParser()
  580. p.add_argument("--room", required=True)
  581. a = p.parse_args()
  582. logging.basicConfig(level=logging.INFO)
  583. await Worker(a.room).run()
  584. def _drain_queue(q: "asyncio.Queue") -> None:
  585. """Discard any queued-but-not-yet-played TTS audio."""
  586. while not q.empty():
  587. try:
  588. q.get_nowait()
  589. except Exception:
  590. break
  591. if __name__ == "__main__":
  592. asyncio.run(main())