| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227 |
- """
- LiveKit ASR Worker — subscribes to participant audio, runs Qwen ASR,
- and sends transcriptions back as room data messages.
- """
- from __future__ import annotations
- import asyncio
- import json
- import logging
- import os
- import sys
- import time
- import traceback
- import jwt
- import numpy as np
- from livekit import rtc
- # Import whisper_asr modules (same directory or adjacent).
- _asr_root = os.path.join(os.path.dirname(__file__), "whisper_asr")
- sys.path.insert(0, _asr_root)
- from audio_processor import AudioBuffer # noqa: E402
- from qwen_engine import QwenASREngine # noqa: E402
- from transcript_processor import TranscriptProcessor # noqa: E402
- logger = logging.getLogger("asr-worker")
- LIVEKIT_URL = os.environ.get("LIVEKIT_URL", "ws://localhost:10003")
- API_KEY = "devkey"
- API_SECRET = "secretsecretsecretsecretsecret12"
- ASR_MODEL_PATH = os.environ.get("ASR_MODEL_PATH", "/data/models/Qwen3-ASR")
- TTS_MODEL_PATH = os.environ.get("TTS_MODEL_PATH", "/data/models/Qwen3-TTS")
- TRANSCRIBE_INTERVAL = 2.0
- BUFFER_DURATION = 5.0
- SILENCE_REPEATS = 3
- SILENCE_SECONDS = 3.0
- def create_token(room_name: str, identity: str) -> str:
- now = int(time.time())
- return jwt.encode(
- {
- "iss": API_KEY,
- "sub": identity,
- "name": identity,
- "nbf": now - 60,
- "exp": now + 6 * 3600,
- "video": {
- "roomJoin": True,
- "room": room_name,
- "canPublish": True,
- "canSubscribe": True,
- "canPublishData": True,
- },
- },
- API_SECRET,
- algorithm="HS256",
- )
- class ASRWorker:
- def __init__(self, room_name: str, identity: str = "asr-bot"):
- self._room_name = room_name
- self._identity = identity
- self._room = rtc.Room()
- self._engine: QwenASREngine | None = None
- self._processors: dict[str, TranscriptProcessor] = {}
- self._buffers: dict[str, AudioBuffer] = {}
- self._tasks: dict[str, asyncio.Task] = {}
- async def run(self):
- logger.info("Loading Qwen ASR model...")
- self._engine = QwenASREngine(
- model_id=ASR_MODEL_PATH, language=None
- )
- self._room.on("track_subscribed", self._on_track_subscribed)
- self._room.on(
- "participant_disconnected", self._on_participant_disconnected
- )
- self._room.on("participant_connected", lambda p: None)
- self._room.on("track_published", lambda pub, p: None)
- token = create_token(self._room_name, self._identity)
- logger.info(f"Connecting to room: {self._room_name}")
- await self._room.connect(LIVEKIT_URL, token)
- logger.info(f"ASR worker ready. Participants: {len(self._room.remote_participants)}")
- # Subscribe to any tracks that already exist in the room.
- for p in self._room.remote_participants.values():
- logger.info(f"Existing participant: {p.identity}, tracks: {len(p.track_publications)}")
- for pub in p.track_publications.values():
- 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}")
- if pub.track is not None and pub.kind == rtc.TrackKind.KIND_AUDIO:
- self._on_track_subscribed(pub.track, pub, p)
- logger.info(f"Subscribed tracks: {list(self._buffers.keys())}")
- try:
- await asyncio.Future()
- finally:
- await self._room.disconnect()
- def _on_track_subscribed(
- self,
- track: rtc.RemoteAudioTrack,
- publication: rtc.RemoteTrackPublication,
- participant: rtc.RemoteParticipant,
- ):
- sid = participant.sid
- if sid in self._buffers:
- return
- logger.info(f"Audio track subscribed: {participant.identity} ({sid})")
- logger.info(f" track kind={track.kind}, muted={track.muted}, stream_state={track.stream_state}")
- self._buffers[sid] = AudioBuffer(
- max_duration=BUFFER_DURATION * 2,
- sample_rate=16000,
- )
- self._processors[sid] = TranscriptProcessor(
- silence_repeats=SILENCE_REPEATS,
- silence_seconds=SILENCE_SECONDS,
- )
- self._tasks[sid] = asyncio.create_task(self._process(sid, track))
- def _on_participant_disconnected(self, participant: rtc.RemoteParticipant):
- sid = participant.sid
- t = self._tasks.pop(sid, None)
- if t:
- t.cancel()
- self._buffers.pop(sid, None)
- self._processors.pop(sid, None)
- async def _process(self, sid: str, track: rtc.RemoteAudioTrack):
- buf = self._buffers[sid]
- proc = self._processors[sid]
- async def _accumulate():
- try:
- stream = rtc.AudioStream(track, sample_rate=16000, num_channels=1)
- count = 0
- async for ev in stream:
- frame = ev.frame
- data = frame.data
- count += 1
- if count == 1:
- logger.info(f"[{sid}] first audio frame: {len(data)} bytes, sr={frame.sample_rate}, ch={frame.num_channels}")
- arr = (
- np.frombuffer(data, dtype=np.int16).astype(np.float32)
- / 32768.0
- )
- buf.append(arr)
- logger.info(f"[{sid}] stream ended after {count} frames")
- except Exception:
- logger.exception(f"[{sid}] accumulate failed")
- async def _transcribe():
- tick = 0
- last_pos = 0
- while True:
- await asyncio.sleep(TRANSCRIBE_INTERVAL)
- tick += 1
- total = len(buf.buffer)
- if total - last_pos < 3200:
- continue
- context = int(0.5 * 16000)
- audio = buf.buffer[max(0, last_pos - context):]
- if len(audio) <= 1600:
- continue
- loop = asyncio.get_running_loop()
- try:
- result = await loop.run_in_executor(
- None, self._engine.transcribe_array, audio
- )
- except Exception:
- logger.exception("transcription failed")
- continue
- new_samples = total - last_pos
- last_pos = total
- raw = result.get("text", "").strip()
- if tick % 5 == 0:
- logger.info(f"[{sid}] transcribe tick={tick} raw='{raw}' new={new_samples}")
- if not raw:
- continue
- for msg in proc.feed(raw):
- logger.info(f"[{sid}] msg: {msg['type']} '{msg['text'][:50]}'")
- await self._send(msg)
- t = asyncio.create_task(_transcribe())
- try:
- await _accumulate()
- finally:
- t.cancel()
- async def _send(self, message: dict):
- try:
- await self._room.local_participant.publish_data(
- json.dumps(message, ensure_ascii=False).encode(),
- reliable=True,
- topic="transcription",
- )
- except Exception:
- logger.exception("publish_data failed")
- async def main():
- import argparse
- parser = argparse.ArgumentParser()
- parser.add_argument("--room", required=True)
- parser.add_argument("--identity", default="asr-bot")
- args = parser.parse_args()
- logging.basicConfig(level=logging.INFO)
- worker = ASRWorker(room_name=args.room, identity=args.identity)
- await worker.run()
- if __name__ == "__main__":
- asyncio.run(main())
|