Files
banban/talkingq-url/implementations/aliyun_asr.py
T

261 lines
10 KiB
Python

"""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()