worker.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. """
  2. LiveKit ASR Worker — subscribes to participant audio, runs Qwen ASR,
  3. and sends transcriptions back as room data messages.
  4. """
  5. from __future__ import annotations
  6. import asyncio
  7. import json
  8. import logging
  9. import os
  10. import sys
  11. import time
  12. import traceback
  13. import jwt
  14. import numpy as np
  15. from livekit import rtc
  16. # Import whisper_asr modules (same directory or adjacent).
  17. _asr_root = os.path.join(os.path.dirname(__file__), "whisper_asr")
  18. sys.path.insert(0, _asr_root)
  19. from audio_processor import AudioBuffer # noqa: E402
  20. from qwen_engine import QwenASREngine # noqa: E402
  21. from transcript_processor import TranscriptProcessor # noqa: E402
  22. logger = logging.getLogger("asr-worker")
  23. LIVEKIT_URL = os.environ.get("LIVEKIT_URL", "ws://localhost:10003")
  24. API_KEY = "devkey"
  25. API_SECRET = "secretsecretsecretsecretsecret12"
  26. ASR_MODEL_PATH = os.environ.get("ASR_MODEL_PATH", "/data/models/Qwen3-ASR")
  27. TTS_MODEL_PATH = os.environ.get("TTS_MODEL_PATH", "/data/models/Qwen3-TTS")
  28. TRANSCRIBE_INTERVAL = 2.0
  29. BUFFER_DURATION = 5.0
  30. SILENCE_REPEATS = 3
  31. SILENCE_SECONDS = 3.0
  32. def create_token(room_name: str, identity: str) -> str:
  33. now = int(time.time())
  34. return jwt.encode(
  35. {
  36. "iss": API_KEY,
  37. "sub": identity,
  38. "name": identity,
  39. "nbf": now - 60,
  40. "exp": now + 6 * 3600,
  41. "video": {
  42. "roomJoin": True,
  43. "room": room_name,
  44. "canPublish": True,
  45. "canSubscribe": True,
  46. "canPublishData": True,
  47. },
  48. },
  49. API_SECRET,
  50. algorithm="HS256",
  51. )
  52. class ASRWorker:
  53. def __init__(self, room_name: str, identity: str = "asr-bot"):
  54. self._room_name = room_name
  55. self._identity = identity
  56. self._room = rtc.Room()
  57. self._engine: QwenASREngine | None = None
  58. self._processors: dict[str, TranscriptProcessor] = {}
  59. self._buffers: dict[str, AudioBuffer] = {}
  60. self._tasks: dict[str, asyncio.Task] = {}
  61. async def run(self):
  62. logger.info("Loading Qwen ASR model...")
  63. self._engine = QwenASREngine(
  64. model_id=ASR_MODEL_PATH, language=None
  65. )
  66. self._room.on("track_subscribed", self._on_track_subscribed)
  67. self._room.on(
  68. "participant_disconnected", self._on_participant_disconnected
  69. )
  70. self._room.on("participant_connected", lambda p: None)
  71. self._room.on("track_published", lambda pub, p: None)
  72. token = create_token(self._room_name, self._identity)
  73. logger.info(f"Connecting to room: {self._room_name}")
  74. await self._room.connect(LIVEKIT_URL, token)
  75. logger.info(f"ASR worker ready. Participants: {len(self._room.remote_participants)}")
  76. # Subscribe to any tracks that already exist in the room.
  77. for p in self._room.remote_participants.values():
  78. logger.info(f"Existing participant: {p.identity}, tracks: {len(p.track_publications)}")
  79. for pub in p.track_publications.values():
  80. logger.info(f" pub kind={pub.kind}, source={pub.source.name if pub.source else 'N/A'}, track={type(pub.track).__name__ if pub.track else 'None'}, subscribed={pub.subscribed}")
  81. if pub.track is not None and pub.kind == rtc.TrackKind.KIND_AUDIO:
  82. self._on_track_subscribed(pub.track, pub, p)
  83. logger.info(f"Subscribed tracks: {list(self._buffers.keys())}")
  84. try:
  85. await asyncio.Future()
  86. finally:
  87. await self._room.disconnect()
  88. def _on_track_subscribed(
  89. self,
  90. track: rtc.RemoteAudioTrack,
  91. publication: rtc.RemoteTrackPublication,
  92. participant: rtc.RemoteParticipant,
  93. ):
  94. sid = participant.sid
  95. if sid in self._buffers:
  96. return
  97. logger.info(f"Audio track subscribed: {participant.identity} ({sid})")
  98. logger.info(f" track kind={track.kind}, muted={track.muted}, stream_state={track.stream_state}")
  99. self._buffers[sid] = AudioBuffer(
  100. max_duration=BUFFER_DURATION * 2,
  101. sample_rate=16000,
  102. )
  103. self._processors[sid] = TranscriptProcessor(
  104. silence_repeats=SILENCE_REPEATS,
  105. silence_seconds=SILENCE_SECONDS,
  106. )
  107. self._tasks[sid] = asyncio.create_task(self._process(sid, track))
  108. def _on_participant_disconnected(self, participant: rtc.RemoteParticipant):
  109. sid = participant.sid
  110. t = self._tasks.pop(sid, None)
  111. if t:
  112. t.cancel()
  113. self._buffers.pop(sid, None)
  114. self._processors.pop(sid, None)
  115. async def _process(self, sid: str, track: rtc.RemoteAudioTrack):
  116. buf = self._buffers[sid]
  117. proc = self._processors[sid]
  118. async def _accumulate():
  119. try:
  120. stream = rtc.AudioStream(track, sample_rate=16000, num_channels=1)
  121. count = 0
  122. async for ev in stream:
  123. frame = ev.frame
  124. data = frame.data
  125. count += 1
  126. if count == 1:
  127. logger.info(f"[{sid}] first audio frame: {len(data)} bytes, sr={frame.sample_rate}, ch={frame.num_channels}")
  128. arr = (
  129. np.frombuffer(data, dtype=np.int16).astype(np.float32)
  130. / 32768.0
  131. )
  132. buf.append(arr)
  133. logger.info(f"[{sid}] stream ended after {count} frames")
  134. except Exception:
  135. logger.exception(f"[{sid}] accumulate failed")
  136. async def _transcribe():
  137. tick = 0
  138. last_pos = 0
  139. while True:
  140. await asyncio.sleep(TRANSCRIBE_INTERVAL)
  141. tick += 1
  142. total = len(buf.buffer)
  143. if total - last_pos < 3200:
  144. continue
  145. context = int(0.5 * 16000)
  146. audio = buf.buffer[max(0, last_pos - context):]
  147. if len(audio) <= 1600:
  148. continue
  149. loop = asyncio.get_running_loop()
  150. try:
  151. result = await loop.run_in_executor(
  152. None, self._engine.transcribe_array, audio
  153. )
  154. except Exception:
  155. logger.exception("transcription failed")
  156. continue
  157. new_samples = total - last_pos
  158. last_pos = total
  159. raw = result.get("text", "").strip()
  160. if tick % 5 == 0:
  161. logger.info(f"[{sid}] transcribe tick={tick} raw='{raw}' new={new_samples}")
  162. if not raw:
  163. continue
  164. for msg in proc.feed(raw):
  165. logger.info(f"[{sid}] msg: {msg['type']} '{msg['text'][:50]}'")
  166. await self._send(msg)
  167. t = asyncio.create_task(_transcribe())
  168. try:
  169. await _accumulate()
  170. finally:
  171. t.cancel()
  172. async def _send(self, message: dict):
  173. try:
  174. await self._room.local_participant.publish_data(
  175. json.dumps(message, ensure_ascii=False).encode(),
  176. reliable=True,
  177. topic="transcription",
  178. )
  179. except Exception:
  180. logger.exception("publish_data failed")
  181. async def main():
  182. import argparse
  183. parser = argparse.ArgumentParser()
  184. parser.add_argument("--room", required=True)
  185. parser.add_argument("--identity", default="asr-bot")
  186. args = parser.parse_args()
  187. logging.basicConfig(level=logging.INFO)
  188. worker = ASRWorker(room_name=args.room, identity=args.identity)
  189. await worker.run()
  190. if __name__ == "__main__":
  191. asyncio.run(main())