"""Async Qwen-Audio/Fun-ASR adapter using the DashScope recognition protocol.""" import asyncio import json import uuid import aiohttp from config import settings from interfaces.asr import ASR from implementations.base_service import BaseService from services.interrupt_handler import interrupt_handler from utils.logger import session_logger class ASRError(RuntimeError): pass class AliyunASR(ASR, BaseService): def __init__(self, selected_role=None): BaseService.__init__(self, "asr", selected_role) self.api_key = settings.aliyun_api_key self.model = settings.aliyun_asr_model self.url = settings.aliyun_asr_url # Gummy vocabulary IDs are model-specific; never reuse them implicitly. self.vocabulary_id = settings.aliyun_asr_vocabulary_id self.sample_rate = 16000 self.device_id = "unknown" self.session_id = "unknown" self.connected = False self.transcript = "" self.error = None self.task_id = None self.audio_buffer = bytearray() self.buffer_size = 3200 self._client = None self._websocket = None self._receiver = None self._ready = asyncio.Event() self._finished = asyncio.Event() self._sentences = {} self._pending = set() self._complete = False self._ending = False self._closing = False self._end_lock = asyncio.Lock() self._close_lock = asyncio.Lock() def _fail(self, message): if self.error is None: self.error = ASRError(message) session_logger.error( self.device_id, self.session_id, f"ASR失败 model={self.model} task_id={self.task_id}: {message}", ) self.connected = False self.transcript = "" self._ready.set() self._finished.set() def _check_error(self): if self.error: raise self.error def _command(self, action, payload): return { "header": {"action": action, "task_id": self.task_id, "streaming": "duplex"}, "payload": payload, } async def connect(self): if self.connected: return await self.close() self._closing = False self._ending = False self._complete = False self.error = None self.transcript = "" self.audio_buffer.clear() self._sentences.clear() self._pending.clear() self._ready.clear() self._finished.clear() self.task_id = uuid.uuid4().hex parameters = { "format": "mp3", "sample_rate": self.sample_rate, "semantic_punctuation_enabled": False, "max_sentence_silence": 2000, "heartbeat": True, } if self.vocabulary_id: parameters["vocabulary_id"] = self.vocabulary_id try: if not self.api_key: raise ASRError("未配置 ALIYUN_API_KEY") # Native async I/O allows cancellation and bounded cleanup; the old # SDK's stop() joins a worker thread without a timeout. self._client = aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=None, connect=settings.asr_connect_timeout_seconds), ) self._websocket = await asyncio.wait_for( self._client.ws_connect( self.url, headers={"Authorization": f"Bearer {self.api_key}"}, heartbeat=20, ), timeout=settings.asr_connect_timeout_seconds, ) self._receiver = asyncio.create_task(self._receive(), name=f"asr:{self.task_id}") await asyncio.wait_for(self._websocket.send_json(self._command("run-task", { "task_group": "audio", "task": "asr", "function": "recognition", "model": self.model, "parameters": parameters, "input": {}, })), timeout=settings.asr_send_timeout_seconds) await asyncio.wait_for(self._ready.wait(), timeout=settings.asr_connect_timeout_seconds) self._check_error() session_logger.info( self.device_id, self.session_id, f"ASR任务已就绪 model={self.model} task_id={self.task_id}", ) except asyncio.CancelledError: await self.close() raise except Exception as exc: self._fail(f"启动失败: {type(exc).__name__}: {exc}") await self.close() raise self.error from exc async def _receive(self): try: async for message in self._websocket: if message.type == aiohttp.WSMsgType.ERROR: raise ASRError("识别 WebSocket 传输错误") if message.type != aiohttp.WSMsgType.TEXT: continue event = json.loads(message.data) header = event.get("header", {}) if header.get("task_id") != self.task_id: continue kind = header.get("event") if kind == "task-started": self.connected = True self._ready.set() elif kind == "result-generated": sentence = event.get("payload", {}).get("output", {}).get("sentence", {}) if not sentence or sentence.get("heartbeat"): continue key = sentence.get("sentence_id", sentence.get("begin_time")) if key is None: raise ASRError("识别结果缺少句子编号和起始时间") if sentence.get("sentence_end"): self._sentences[key] = sentence.get("text", "") self._pending.discard(key) elif sentence.get("text") and key not in self._sentences: self._pending.add(key) elif kind == "task-finished": if not self._ending: self._fail("识别任务提前结束,设备音频尚未发送完毕") return self._complete = True self.connected = False self._finished.set() return elif kind == "task-failed": self._fail(f"{header.get('error_code', 'Unknown')}: {header.get('error_message', '')}") return except asyncio.CancelledError: raise except Exception as exc: self._fail(f"接收失败: {type(exc).__name__}: {exc}") finally: if not self._complete and not self._closing and not self.error: self._fail("识别连接提前关闭,未收到 task-finished") async def _send_bytes(self, data): self._check_error() try: await asyncio.wait_for( self._websocket.send_bytes(data), timeout=settings.asr_send_timeout_seconds, ) except Exception as exc: self._fail(f"发送音频失败: {type(exc).__name__}: {exc}") raise self.error from exc async def send_audio(self, audio_data: bytes): self._check_error() if interrupt_handler.is_interrupted((self.device_id, self.session_id)): raise ASRError("会话已中断") if not self.connected or self._ending: raise ASRError("识别任务未就绪或已结束") self.audio_buffer.extend(audio_data) while len(self.audio_buffer) >= self.buffer_size: chunk = bytes(self.audio_buffer[:self.buffer_size]) await self._send_bytes(chunk) del self.audio_buffer[:self.buffer_size] async def send_end(self): async with self._end_lock: self._check_error() if self._complete: return if not self.connected: raise ASRError("识别任务未就绪") self._ending = True try: if self.audio_buffer: await self._send_bytes(bytes(self.audio_buffer)) self.audio_buffer.clear() await asyncio.wait_for( self._websocket.send_json(self._command("finish-task", {"input": {}})), timeout=settings.asr_send_timeout_seconds, ) await asyncio.wait_for(self._finished.wait(), timeout=settings.asr_final_timeout_seconds) self._check_error() except asyncio.CancelledError: await self.close() raise except Exception as exc: self._fail(f"结束识别失败: {type(exc).__name__}: {exc}") raise self.error from exc async def receive_results(self): self._check_error() if not self._complete or self._pending: raise ASRError("识别未完整结束,拒绝使用不完整转录") self.transcript = "".join(self._sentences[key] for key in sorted(self._sentences)) session_logger.info( self.device_id, self.session_id, f"ASR最终结果 model={self.model} task_id={self.task_id}: {self.transcript}", ) return self.transcript async def close(self): async with self._close_lock: await self._close_connection() async def _close_connection(self): self._closing = True self.connected = False # Wake waiters if the session is interrupted during start or finish. if self.task_id and not self._complete and not self.error: self.error = ASRError("识别会话已关闭") self._ready.set() self._finished.set() if self._receiver: self._receiver.cancel() await asyncio.gather(self._receiver, return_exceptions=True) self._receiver = None try: if self._websocket and not self._websocket.closed: await asyncio.wait_for(self._websocket.close(), timeout=1.0) except (asyncio.TimeoutError, aiohttp.ClientError): pass finally: if self._client: await self._client.close() self._client = None self._websocket = None self.audio_buffer.clear()