From ffdd8eaff107049da38dfb07c353f9d2f14fb280 Mon Sep 17 00:00:00 2001 From: stu2not Date: Mon, 12 Oct 2026 16:48:47 +0800 Subject: [PATCH] feat(asr): switch to Qwen streaming and close interrupted providers --- talkingq-url/config.py | 11 + talkingq-url/docs/qwen-asr-rollout.md | 23 + .../handlers/audio_session_handler.py | 12 +- .../handlers/service_connection_handler.py | 159 ++++-- talkingq-url/implementations/aliyun_asr.py | 530 ++++++++---------- talkingq-url/scripts/smoke_ai_dialog.py | 232 ++++++++ talkingq-url/scripts/smoke_fun_asr.py | 78 +++ talkingq-url/services/audio_session.py | 1 + talkingq-url/services/factory.py | 2 +- talkingq-url/tests/fixtures/asr/english.mp3 | Bin 0 -> 6416 bytes talkingq-url/tests/fixtures/asr/manifest.json | 46 ++ talkingq-url/tests/fixtures/asr/multi.mp3 | Bin 0 -> 12320 bytes talkingq-url/tests/fixtures/asr/short.mp3 | Bin 0 -> 4616 bytes talkingq-url/tests/fixtures/asr/silence.mp3 | Bin 0 -> 6416 bytes talkingq-url/tests/fixtures/asr/slow.mp3 | Bin 0 -> 7352 bytes talkingq-url/tests/test_fun_asr.py | 456 +++++++++++++++ talkingq-url/tests/test_warmup_cleanup.py | 68 +++ 17 files changed, 1250 insertions(+), 368 deletions(-) create mode 100644 talkingq-url/docs/qwen-asr-rollout.md create mode 100644 talkingq-url/scripts/smoke_ai_dialog.py create mode 100644 talkingq-url/scripts/smoke_fun_asr.py create mode 100644 talkingq-url/tests/fixtures/asr/english.mp3 create mode 100644 talkingq-url/tests/fixtures/asr/manifest.json create mode 100644 talkingq-url/tests/fixtures/asr/multi.mp3 create mode 100644 talkingq-url/tests/fixtures/asr/short.mp3 create mode 100644 talkingq-url/tests/fixtures/asr/silence.mp3 create mode 100644 talkingq-url/tests/fixtures/asr/slow.mp3 create mode 100644 talkingq-url/tests/test_fun_asr.py create mode 100644 talkingq-url/tests/test_warmup_cleanup.py diff --git a/talkingq-url/config.py b/talkingq-url/config.py index acdf735..6778ff2 100644 --- a/talkingq-url/config.py +++ b/talkingq-url/config.py @@ -57,6 +57,17 @@ class Settings(BaseSettings): aliyun_api_key: str = Field(default="", validation_alias="ALIYUN_API_KEY") aliyun_vocabulary_id: str = Field(default="", validation_alias="ALIYUN_VOCABULARY_ID") + aliyun_asr_model: str = Field(default="qwen-audio-3.1-asr-flash-streaming", validation_alias="ALIYUN_ASR_MODEL") + aliyun_asr_url: str = Field( + default="wss://dashscope.aliyuncs.com/api-ws/v1/inference", + validation_alias="ALIYUN_ASR_URL", + ) + # Separate from the retired Gummy model's vocabulary ID. + aliyun_asr_vocabulary_id: str = Field(default="", validation_alias="ALIYUN_ASR_VOCABULARY_ID") + asr_connect_timeout_seconds: float = Field(default=5.0, gt=0, le=30, validation_alias="ASR_CONNECT_TIMEOUT_SECONDS") + asr_send_timeout_seconds: float = Field(default=5.0, gt=0, le=30, validation_alias="ASR_SEND_TIMEOUT_SECONDS") + asr_final_timeout_seconds: float = Field(default=8.0, gt=0, le=30, validation_alias="ASR_FINAL_TIMEOUT_SECONDS") + asr_queue_drain_timeout_seconds: float = Field(default=5.0, gt=0, le=30, validation_alias="ASR_QUEUE_DRAIN_TIMEOUT_SECONDS") assets_dir: str = Field(default="assets", validation_alias="ASSETS_DIR") session_timeout: int = Field(default=600, validation_alias="SESSION_TIMEOUT") diff --git a/talkingq-url/docs/qwen-asr-rollout.md b/talkingq-url/docs/qwen-asr-rollout.md new file mode 100644 index 0000000..b20d558 --- /dev/null +++ b/talkingq-url/docs/qwen-asr-rollout.md @@ -0,0 +1,23 @@ +# 千问 ASR 切换与部署验证 + +默认模型改为 `qwen-audio-3.1-asr-flash-streaming`,通过异步 DashScope recognition WebSocket 接收现有 16 kHz MP3 音频。`ALIYUN_ASR_MODEL` 可覆盖模型;`ALIYUN_ASR_URL` 可覆盖接口。旧 Gummy 热词表不自动复用,新热词表需显式配置 `ALIYUN_ASR_VOCABULARY_ID`。 + +结束录音时先排空发送队列和尾包,再等待最终结果;队列溢出、识别错误、未完成结果不进入正常回复链路。模型拒绝、启动/发送/结束等待均有超时。LLM/TTS 预热连接注册中断清理,覆盖连接完成后中断、连接期间中断、任务取消和已中断会话。 + +2026-10-12 本地验证: + +- 后端 tests:215 项通过,回归运行阻止非本机网络连接。 +- 真实千问 ASR:10/10 轮通过。 +- 原有 `/ws`、角色/语言选择、真实千问 ASR、火山 LLM、MiniMax TTS、音频 URL 下发、HTTP 下载/Range、MP3 解码及 MySQL 历史落库:10/10 轮通过(8 轮有声对话、2 轮静音提示),共 43 段生成回复和 2 段提示音。 +- 发现并修复录音中断时 LLM/TTS 客户端未关闭的问题;修复后两个角色的中断及后续恢复共 4/4 通过。 + +使用 `tests/fixtures/asr/manifest.json` 中的合成语音复现 ASR: + +```sh +.venv/bin/python scripts/smoke_fun_asr.py tests/fixtures/asr/manifest.json --output output/asr-retest.json +.venv/bin/python -m pytest tests -q -o asyncio_default_fixture_loop_scope=function +``` + +完整对话脚本 `scripts/smoke_ai_dialog.py` 要求独立 MySQL 监听 `127.0.0.1:13316`,并使用测试数据库 `qwen_dialog_test`、临时设备和 `127.0.0.1:18086` 服务。它使用真实云端 API,会产生用量;本地缺少 MiniMax key 时读取已有 `test/minimax.env`,不修改 `.env`。需要 macOS `afconvert` 做音频解码。运行 `--interrupt-only` 复测中断与下一轮恢复。 + +以上不等于实物设备麦克风/扬声器验收,也未验证回复播放中的打断。部署只更新六个运行文件,不迁移数据库,不替换 `.env`、音频或 OTA 固件;线上测试另存执行证据。 diff --git a/talkingq-url/handlers/audio_session_handler.py b/talkingq-url/handlers/audio_session_handler.py index fb8ee94..4a65f45 100644 --- a/talkingq-url/handlers/audio_session_handler.py +++ b/talkingq-url/handlers/audio_session_handler.py @@ -72,6 +72,8 @@ async def handle_start_packet(session: AudioSession, device_id: str, session_id: from handlers.service_connection_handler import prepare_asr_service await prepare_asr_service(session, device_id, session_id) + if not session.asr_service.connected: + return from handlers.service_connection_handler import ( prepare_llm_service, @@ -122,6 +124,7 @@ async def handle_audio_packet(session: AudioSession, audio_data: bytes): if session.send_queue.qsize() < 100: # 设置合理的上限 await session.send_queue.put(audio_data) else: + session.asr_error = "音频队列溢出,录音不完整" session_logger.warning("unknown", "unknown", "音频队列过大,丢弃部分数据") @@ -130,17 +133,12 @@ async def handle_end_packet(session: AudioSession, session_key: tuple): device_id, session_id = session_key session.end_time = time.perf_counter() session_logger.info(device_id, session_id, "会话结束") - if session.send_task: - session.send_task.cancel() - try: - await session.send_task - except asyncio.CancelledError: - pass from handlers.service_connection_handler import end_asr_session from handlers.transcription_handler import handle_transcription await end_asr_session(session) - await handle_transcription(session, device_id, session_id) + if not interrupt_handler.is_interrupted(session_key): + await handle_transcription(session, device_id, session_id) if session.llm_service and hasattr(session.llm_service, "close"): await session.llm_service.close() if session.tts_service: diff --git a/talkingq-url/handlers/service_connection_handler.py b/talkingq-url/handlers/service_connection_handler.py index 409dc41..23732e5 100644 --- a/talkingq-url/handlers/service_connection_handler.py +++ b/talkingq-url/handlers/service_connection_handler.py @@ -4,9 +4,34 @@ from services.task_manager import task_manager from utils.logger import session_logger from services.interrupt_handler import interrupt_handler import time +from config import settings + + +async def _connect_with_interrupt_cleanup(service, session_key): + """Own warmup connections even when interruption races with connect().""" + if interrupt_handler.is_interrupted(session_key): + return False + await interrupt_handler.register_cleanup_handler( + session_key, service.close, + priority=interrupt_handler.PRIORITY["NETWORK_CONNECTION"], + ) + try: + await service.connect() + if interrupt_handler.is_interrupted(session_key): + await service.close() + return False + return True + except asyncio.CancelledError: + await service.close() + raise + except Exception: + await service.close() + raise async def prepare_asr_service(session, device_id, session_id): + session.asr_error = None + session.asr_service.sample_rate = session.sample_rate if hasattr(session.asr_service, "device_id"): session.asr_service.device_id = device_id if hasattr(session.asr_service, "session_id"): @@ -20,7 +45,8 @@ async def prepare_llm_service(session, device_id, session_id, selected_role): if hasattr(session.llm_service, "session_id"): session.llm_service.session_id = session_id if hasattr(session.llm_service, "connect"): - await session.llm_service.connect() + if not await _connect_with_interrupt_cleanup(session.llm_service, (device_id, session_id)): + return from services.conversation_history import conversation_history_manager from config import settings @@ -58,12 +84,29 @@ async def establish_asr_connection(session: AudioSession): ) await session.asr_service.connect() if not session.asr_service.connected: - session_logger.error("unknown", "unknown", "语音识别 WebSocket 连接未建立") - return + raise RuntimeError("语音识别 WebSocket 连接未建立") + if session.device_id and session.session_id: + if interrupt_handler.is_interrupted((session.device_id, session.session_id)): + raise RuntimeError("连接期间会话已中断") + await interrupt_handler.register_cleanup_handler( + (session.device_id, session.session_id), session.asr_service.close, + priority=interrupt_handler.PRIORITY["NETWORK_CONNECTION"], + ) + await interrupt_handler.register_cleanup_handler( + (session.device_id, session.session_id), session.cancel_send_task, + priority=interrupt_handler.PRIORITY["TASK_CANCELLATION"], + ) session_logger.info( "unknown", "unknown", f"语音识别连接已建立,采样率: {session.sample_rate}Hz" ) + except asyncio.CancelledError: + await session.cancel_send_task() + await session.asr_service.close() + raise except Exception as e: + session.asr_error = f"{type(e).__name__}: {e}" + await session.cancel_send_task() + await session.asr_service.close() session_logger.error( "unknown", "unknown", f"建立语音识别 WebSocket 连接失败: {e}", exc_info=True ) @@ -80,7 +123,10 @@ async def establish_tts_connection(session: AudioSession): ) return - await session.tts_service.connect() + if not await _connect_with_interrupt_cleanup( + session.tts_service, (session.device_id, session.session_id) + ): + return if not session.tts_service.connected: session_logger.error( @@ -113,19 +159,24 @@ async def send_audio_task(session: AudioSession): session.device_id, session.session_id, "发送音频任务被中断" ) break - audio_data = await session.send_queue.get() - if audio_data is None: # 结束信号 - break - - await session.asr_service.send_audio(audio_data) + queue = session.send_queue + audio_data = await queue.get() + try: + if audio_data is None: # Drain all preceding audio before finish-task. + break + await session.asr_service.send_audio(audio_data) + finally: + queue.task_done() except asyncio.CancelledError: + session.asr_error = session.asr_error or "发送音频任务被取消" session_logger.info( session.device_id or "unknown", session.session_id or "unknown", "发送音频数据任务被取消", ) except Exception as e: + session.asr_error = str(e) session_logger.error( session.device_id or "unknown", session.session_id or "unknown", @@ -135,51 +186,51 @@ async def send_audio_task(session: AudioSession): async def end_asr_session(session: AudioSession): - - if session.asr_service.connected: - try: - asr_end_start_time = time.perf_counter() - await session.asr_service.send_end() - await session.asr_service.receive_results() - session.transcript = session.asr_service.transcript - asr_end_elapsed = time.perf_counter() - asr_end_start_time - session_logger.info( - session.device_id or "unknown", - session.session_id or "unknown", - f"ASR结束延迟: 从发送结束包到获取最终结果耗时 {asr_end_elapsed:.2f} 秒", + """Drain device audio, collect the complete transcript, always release ASR.""" + session.transcript = "" + try: + session_key = (session.device_id, session.session_id) + if interrupt_handler.is_interrupted(session_key): + raise RuntimeError("识别会话已中断") + if session.send_task and not session.send_task.done(): + await session.send_queue.put(None) + await asyncio.wait_for( + asyncio.shield(session.send_task), + timeout=settings.asr_queue_drain_timeout_seconds, ) - except Exception as e: - session_logger.error( - session.device_id or "unknown", - session.session_id or "unknown", - f"ASR结束处理出错: {e}", - exc_info=True, - ) - finally: - if session.send_task: - session.send_task.cancel() - try: - await asyncio.wait_for(session.send_task, timeout=2.0) - except (asyncio.CancelledError, asyncio.TimeoutError): - pass - except Exception as e: - session_logger.error( - session.device_id or "unknown", - session.session_id or "unknown", - f"取消发送任务时出错: {e}", - ) - finally: - session.send_task = None - - await session.asr_service.close() - session_logger.info( - session.device_id or "unknown", - session.session_id or "unknown", - "语音识别 WebSocket 连接和 ClientSession 已关闭", - ) - else: + if session.asr_error: + raise RuntimeError(session.asr_error) + if session.send_queue and not session.send_queue.empty(): + raise RuntimeError("发送任务提前退出,音频未完整发送") + if interrupt_handler.is_interrupted(session_key): + raise RuntimeError("识别会话已中断") + asr_end_start_time = time.perf_counter() + await session.asr_service.send_end() + if interrupt_handler.is_interrupted(session_key): + raise RuntimeError("识别会话已中断") + session.transcript = await session.asr_service.receive_results() + session_logger.info( + session.device_id or "unknown", session.session_id or "unknown", + f"ASR结束延迟: {time.perf_counter() - asr_end_start_time:.2f} 秒", + ) + except Exception as e: + session.asr_error = f"{type(e).__name__}: {e}" + session.transcript = "" session_logger.error( - session.device_id or "unknown", - session.session_id or "unknown", - "语音识别 WebSocket 连接未建立", + session.device_id or "unknown", session.session_id or "unknown", + f"ASR结束处理出错: {session.asr_error}", + ) + finally: + if session.send_task: + session.send_task.cancel() + try: + await asyncio.wait_for(session.send_task, timeout=2.0) + except (asyncio.CancelledError, asyncio.TimeoutError): + pass + finally: + session.send_task = None + await session.asr_service.close() + session_logger.info( + session.device_id or "unknown", session.session_id or "unknown", + "语音识别 WebSocket 连接和 ClientSession 已关闭", ) diff --git a/talkingq-url/implementations/aliyun_asr.py b/talkingq-url/implementations/aliyun_asr.py index 564f783..221782e 100644 --- a/talkingq-url/implementations/aliyun_asr.py +++ b/talkingq-url/implementations/aliyun_asr.py @@ -1,8 +1,11 @@ +"""Async Qwen-Audio/Fun-ASR adapter using the DashScope recognition protocol.""" + import asyncio -import threading -from dashscope.audio.asr import TranslationRecognizerChat -from dashscope.audio.asr import TranscriptionResult -from dashscope.audio.asr import TranslationRecognizerCallback +import json +import uuid + +import aiohttp + from config import settings from interfaces.asr import ASR from implementations.base_service import BaseService @@ -10,333 +13,248 @@ from services.interrupt_handler import interrupt_handler from utils.logger import session_logger -class GummyCallback(TranslationRecognizerCallback): - def __init__(self, device_id, session_id, asr_instance=None): - self.device_id = device_id - self.session_id = session_id - self.asr_instance = asr_instance - self.transcript = "" - self.transcript_event = asyncio.Event() - self.received_result = False - self.error_message = None - self.is_closed = False - self.session_end = False - self.reconnect_event = threading.Event() - self.is_rate_limit_error = False - - def on_open(self) -> None: - session_logger.info(self.device_id, self.session_id, "Gummy ASR连接建立成功") - - def on_event( - self, - request_id, - transcription_result: TranscriptionResult, - translation_result, - usage, - ) -> None: - if transcription_result: - if transcription_result.is_sentence_end: - self.transcript = transcription_result.text - self.received_result = True - session_logger.info( - self.device_id, - self.session_id, - f"Gummy ASR完整句子: {self.transcript}", - ) - self.transcript_event.set() - else: - self.transcript = transcription_result.text - - def on_complete(self) -> None: - session_logger.info(self.device_id, self.session_id, "Gummy ASR识别完成") - self.session_end = True - self.transcript_event.set() - - def on_error(self, result) -> None: - self.error_message = str(result) - error_str = str(result).lower() - self.is_rate_limit_error = "throttling" in error_str or "rate limit" in error_str - - if self.is_rate_limit_error: - session_logger.warning( - self.device_id, - self.session_id, - f"Gummy ASR限流错误,触发重连信号: {self.error_message}" - ) - self.reconnect_event.set() - if self.asr_instance: - self.asr_instance.need_reconnect = True - else: - session_logger.error( - self.device_id, self.session_id, f"Gummy ASR错误: {self.error_message}" - ) - self.transcript_event.set() - - def on_close(self) -> None: - session_logger.info(self.device_id, self.session_id, "Gummy ASR连接已关闭") - self.is_closed = True - self.transcript_event.set() +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.vocabulary_id = settings.aliyun_vocabulary_id - self.connected = False - self.transcript = "" + 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.sample_rate = 16000 + self.connected = False + self.transcript = "" + self.error = None + self.task_id = None self.audio_buffer = bytearray() - self.translator = None - self.callback = None self.buffer_size = 3200 - self.max_end_silence = 2000 - self.max_retries = 3 - self.retry_delay = 1.0 - self.need_reconnect = False + 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 self.connected and self.translator: - session_logger.info( - self.device_id, self.session_id, "Gummy ASR连接已存在,复用当前连接" - ) - return - - self.transcript = "" - - self.callback = GummyCallback(self.device_id, self.session_id, self) - - self.translator = TranslationRecognizerChat( - model="gummy-chat-v1", - format="mp3", - sample_rate=self.sample_rate, - callback=self.callback, - transcription_enabled=True, - source_language="auto", - semantic_punctuation_enabled=False, - max_end_silence=self.max_end_silence, - api_key=self.api_key, - vocabulary_id=self.vocabulary_id, + 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.translator.start() - self.callback.transcript_event.clear() - self.connected = True - self.need_reconnect = False - - except Exception as e: - session_logger.error( - self.device_id, self.session_id, f"建立Gummy ASR连接失败: {str(e)}" + 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, ) - await self._cleanup_connection() - - async def _cleanup_connection(self): - if self.translator: - try: - self.translator.stop() - except: - pass - self.translator = None - self.callback = None - self.connected = False - - async def _handle_reconnect(self): - if not self.need_reconnect: - return True - - session_logger.warning( - self.device_id, - self.session_id, - "检测到限流错误,准备重连..." - ) - - await self._cleanup_connection() - - current_delay = self.retry_delay - for attempt in range(self.max_retries): + 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"重连尝试 {attempt + 1}/{self.max_retries},等待{current_delay}秒" + self.device_id, self.session_id, + f"ASR任务已就绪 model={self.model} task_id={self.task_id}", ) - await asyncio.sleep(current_delay) - - try: - await self.connect() - if self.connected: - session_logger.info( - self.device_id, - self.session_id, - "重连成功" - ) - return True - except Exception as e: - session_logger.error( - self.device_id, - self.session_id, - f"重连失败: {str(e)}" - ) - - current_delay *= 2 - self.retry_delay = current_delay - - session_logger.error( - self.device_id, - self.session_id, - "重连失败,已达到最大重试次数" - ) - self.need_reconnect = False - return False + 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): - if self.need_reconnect: - reconnected = await self._handle_reconnect() - if not reconnected: - session_logger.error(self.device_id, self.session_id, "Gummy ASR重连失败,无法发送音频") - return - - if not self.connected or not self.translator: - session_logger.error(self.device_id, self.session_id, "Gummy ASR连接未建立") - return - - try: - self.audio_buffer.extend(audio_data) - - while len(self.audio_buffer) >= self.buffer_size: - if self.need_reconnect: - session_logger.info( - self.device_id, - self.session_id, - "检测到限流错误,暂停发送音频,准备重连" - ) - reconnected = await self._handle_reconnect() - if not reconnected: - return - if not self.connected or not self.translator: - return - continue - - chunk = bytes(self.audio_buffer[: self.buffer_size]) - self.audio_buffer = self.audio_buffer[self.buffer_size :] - - if self.device_id and self.session_id: - session_key = (self.device_id, self.session_id) - if interrupt_handler.is_interrupted(session_key): - session_logger.info( - self.device_id, - self.session_id, - "检测到中断,停止发送音频数据", - ) - return - - try: - self.translator.send_audio_frame(chunk) - except Exception as e: - session_logger.error( - self.device_id, - self.session_id, - f"发送音频帧到Gummy ASR出错: {str(e)}", - ) - - except Exception as e: - session_logger.error( - self.device_id, self.session_id, f"发送音频到Gummy ASR出错: {str(e)}" - ) + 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): - if not self.connected or not self.translator: - session_logger.error( - self.device_id, self.session_id, "Gummy ASR连接未建立,无法发送结束信号" - ) - return - - try: - if len(self.audio_buffer) > 0: - chunk = bytes(self.audio_buffer) - self.audio_buffer = bytearray() - self.translator.send_audio_frame(chunk) - - session_logger.info( - self.device_id, - self.session_id, - "客户端发送结束包,等待Gummy ASR处理完毕", - ) - - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, lambda: self._safe_stop_translator()) - - except Exception as e: - session_logger.error( - self.device_id, - self.session_id, - f"发送结束信号到Gummy ASR出错: {str(e)}", - ) + 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): - if not self.connected: - session_logger.error( - self.device_id, self.session_id, "Gummy ASR连接未建立,无法接收结果" - ) - return - - try: - try: - await asyncio.wait_for( - self.callback.transcript_event.wait(), timeout=0.5 - ) - except asyncio.TimeoutError: - session_logger.warning( - self.device_id, - self.session_id, - "接收Gummy ASR结果超时,使用最后的中间结果", - ) - - if self.callback.received_result or self.callback.transcript: - self.transcript = self.callback.transcript - - except Exception as e: - session_logger.error( - self.device_id, self.session_id, f"接收Gummy ASR结果出错: {str(e)}" - ) - + 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): - if self.connected: - try: - if self.translator is not None: - try: - self.translator.stop() - except Exception as e: - if "has stopped" not in str(e): - session_logger.warning( - self.device_id, - self.session_id, - f"关闭Gummy translator出错: {str(e)}" - ) - self.translator = None - self.connected = False - session_logger.info( - self.device_id, self.session_id, "Gummy ASR连接已关闭" - ) - except Exception as e: - session_logger.error( - self.device_id, self.session_id, f"关闭Gummy ASR连接出错: {str(e)}" - ) + async with self._close_lock: + await self._close_connection() - def _safe_stop_translator(self): - """安全停止translator,忽略已停止的异常""" + 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.translator: - self.translator.stop() - except Exception as e: - if "has stopped" not in str(e): - session_logger.warning( - self.device_id, - self.session_id, - f"停止translator时出现非标准错误: {str(e)}", - ) - return None + 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() diff --git a/talkingq-url/scripts/smoke_ai_dialog.py b/talkingq-url/scripts/smoke_ai_dialog.py new file mode 100644 index 0000000..01a68da --- /dev/null +++ b/talkingq-url/scripts/smoke_ai_dialog.py @@ -0,0 +1,232 @@ +"""Local real-provider dialogue acceptance using isolated MySQL and actual /ws. + +Requires an isolated MySQL on 127.0.0.1:13316 (root without password), +macOS afconvert, and existing local ASR/LLM/TTS test credentials. +Starts only the production WebSocket router and asset serving on loopback. +Never starts production MQTT or changes .env. Writes evidence under output/. +""" +import asyncio +import argparse +import hashlib +import json +import shutil +import struct +import sys +import time +import uuid +import wave +from pathlib import Path + +import aiohttp +import pymysql +import uvicorn +import websockets +import yaml +from dotenv import dotenv_values +from fastapi import FastAPI + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from config import settings + +OUT = ROOT / 'output/qwen-dialog-20261012' +OUT.mkdir(parents=True, exist_ok=True) +DEVICE_PREFIX = 'TalkingQ_E2E' + uuid.uuid4().hex[:6] +settings.db_host, settings.db_port = '127.0.0.1', 13316 +settings.db_user, settings.db_password, settings.db_name = 'root', '', 'qwen_dialog_test' +settings.server_host, settings.server_port = '127.0.0.1', 18086 +settings.assets_dir = str(OUT / 'assets') +settings.device_audio_cache_dir = str(OUT / 'device-audio') +settings.talkingq_mqtt_broker = '' +settings.selected_role_key = 'Banban' +tts_env = dotenv_values(ROOT / 'test/minimax.env') +settings.minimax_api_key = settings.minimax_api_key or tts_env.get('MINIMAX_API_KEY', '') +assert settings.minimax_api_key and settings.volcano_api_key and settings.aliyun_api_key +assert settings.aliyun_asr_model == 'qwen-audio-3.1-asr-flash-streaming' + +from database.models import Base, Role, RoleLanguage, DeviceAuth, DeviceConfig, ConversationHistory, ConversationMessage +from database.connection import get_db_manager +from services.role_manager import role_manager +from services.audio_session import audio_session_manager +from services.task_manager import task_manager +from api.websocket import router +from api.assets import configure_static_assets +from sqlalchemy import select + + +def packet(device, sid, seq, kind, audio=b''): + return device.encode() + b'\0' + sid.encode() + b'\0' + struct.pack(' 0 and any(pcm), 'empty/silent decoded reply' + async with http.get(url, headers={'Range': 'bytes=0-1023'}) as response: + assert response.status == 206 and await response.read() == body[:1024] + return {'url': url, 'file': str(local.relative_to(ROOT)), 'bytes': len(body), 'sha256': hashlib.sha256(body).hexdigest(), 'decoded_seconds': round(seconds, 3), 'http': 200, 'range': 206} + + +async def run_turn(ws, http, manager, device, role, case, interrupt=False): + sid = uuid.uuid4().hex + result = {'role': role, 'case': case['name'], 'session_id': sid, 'model': settings.aliyun_asr_model, + 'messages': [], 'audio': [], 'passed': False, 'interrupted': interrupt} + started = time.perf_counter() + try: + await ws.send(packet(device, sid, 0, 1)) + assert await asyncio.wait_for(ws.recv(), 15) == 'START_ACK' + result['messages'].append('START_ACK') + # Device START_ACK precedes ASR readiness. Real handler queues subsequent + # WebSocket frames while establishing the upstream connection. + audio = (ROOT / case['audio']).read_bytes() + for seq, offset in enumerate(range(0, len(audio), 640), 1): + await ws.send(packet(device, sid, seq, 0, audio[offset:offset + 640])) + await asyncio.sleep(len(audio[offset:offset + 640]) / 2000) + if interrupt and offset >= 1280: + break + ending = time.perf_counter() + await ws.send(packet(device, sid, seq + 1, 3 if interrupt else 2)) + while True: + message = await asyncio.wait_for(ws.recv(), 90) + result['messages'].append(message) + if message.startswith(('TTS_URL:', 'PROMPT_SOUND_URL:')): + if 'first_audio_seconds' not in result: + result['first_audio_seconds'] = round(time.perf_counter() - ending, 3) + result['audio'].append(await download(http, message.split(':', 1)[1], sid, len(result['audio']))) + if message.startswith('PROMPT_SOUND_URL:'): + break + if message == 'TTS_END' or (interrupt and message == 'INTERRUPT_ACK'): + break + if message == 'TTS_ERROR': + raise AssertionError('TTS_ERROR received') + # END may be delivered before the history transaction completes. + for _ in range(100): + session = await audio_session_manager.get_session((device, sid)) + if session and session.asr_service._client is None and (not session.llm_service.connected): + break + await asyncio.sleep(.05) + if session: + result['transcript'] = session.transcript + result['asr_error'] = session.asr_error + result['resource_state'] = { + 'asr_closed': session.asr_service._client is None and session.asr_service._receiver is None, + 'llm_closed': not session.llm_service.connected and (session.llm_service.session is None or session.llm_service.session.closed), + 'tts_closed': session.tts_service._client_session is None or session.tts_service._client_session.closed, + } + result['resources_released'] = all(result['resource_state'].values()) + async with await manager.get_session() as db: + query = select(ConversationMessage).join(ConversationHistory).where(ConversationHistory.device_id == device, + ConversationHistory.role_key == role).order_by(ConversationMessage.timestamp) + messages = (await db.execute(query)).scalars().all() + result['history_messages'] = [{'is_user': m.is_user, 'text': m.content} for m in messages] + if interrupt: + assert 'INTERRUPT_ACK' in result['messages'] and not result['audio'] + elif not case['expected_keywords']: + assert not result.get('transcript') and any(m.startswith('PROMPT_SOUND_URL:') for m in result['messages']) + else: + assert not result.get('asr_error') + assert all(word.lower() in result.get('transcript', '').lower() for word in case['expected_keywords']) + assert 'TTS_START' in result['messages'] and 'TTS_END' in result['messages'] and result['audio'] + assert messages and not messages[-1].is_user and messages[-1].content.strip(), 'assistant history missing' + assert len(messages) >= 2 and messages[-2].content == result['transcript'], 'latest history does not match current transcript' + result['reply'] = messages[-1].content + assert result.get('resources_released'), 'provider resources not released' + result['passed'] = True + except Exception as exc: + result['error'] = f'{type(exc).__name__}: {exc}' + result['total_seconds'] = round(time.perf_counter() - started, 3) + return result + + +async def main(interrupt_only=False): + manager = await setup(interrupt_only) + app = FastAPI() + configure_static_assets(app) + app.include_router(router) + server = uvicorn.Server(uvicorn.Config(app, host='127.0.0.1', port=18086, log_level='warning', lifespan='off')) + server_task = asyncio.create_task(server.serve()) + results = [] + try: + for _ in range(100): + if server.started: break + await asyncio.sleep(.05) + assert server.started + cases = json.loads((ROOT / 'tests/fixtures/asr/manifest.json').read_text()) + async with aiohttp.ClientSession() as http: + for index, role in enumerate(['Banban', 'Baikeboshi']): + device = f'{DEVICE_PREFIX}{index:02d}' + async with websockets.connect('ws://127.0.0.1:18086/ws', ping_interval=None) as ws: + await ws.send(json.dumps({'device_id': device, 'serial_number': f'LOCAL-E2E-{index}'})) + assert json.loads(await ws.recv())['status'] == 'authenticated' + plan = [(case, False) for case in cases] + if interrupt_only: + plan = [(cases[1], True), (cases[0], False)] + for case, interrupted in plan: + result = await asyncio.wait_for(run_turn(ws, http, manager, device, role, case, interrupt=interrupted), 150) + results.append(result) + (OUT / ('results-interrupt.json' if interrupt_only else 'results.json')).write_text(json.dumps(results, ensure_ascii=False, indent=2)) + print('E2E_RESULT', json.dumps(result, ensure_ascii=False), flush=True) + if not result['passed'] and not interrupt_only: + return False + return all(r['passed'] for r in results) + finally: + server.should_exit = True + await server_task + await task_manager.cleanup_all_tasks() + await manager.close() + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--interrupt-only', action='store_true') + args = parser.parse_args() + sys.exit(0 if asyncio.run(main(args.interrupt_only)) else 1) diff --git a/talkingq-url/scripts/smoke_fun_asr.py b/talkingq-url/scripts/smoke_fun_asr.py new file mode 100644 index 0000000..5f7e622 --- /dev/null +++ b/talkingq-url/scripts/smoke_fun_asr.py @@ -0,0 +1,78 @@ +"""Stream local MP3 fixtures through the backend path to the configured ASR model. + +Run from talkingq-url: .venv/bin/python scripts/smoke_fun_asr.py MANIFEST --output RESULT +Manifest: [{"name": "short", "audio": "path.mp3", "expected_keywords": ["你好"]}] +Uses the local ALIYUN_API_KEY. Does not start the app, connect to DB/MQTT, +invoke LLM/TTS, or send anything to a physical device. Audio must be 16 kbps MP3 +unless --bytes-per-second is supplied. Empty keywords means silence is expected. +""" +import argparse +import asyncio +import json +from pathlib import Path +import sys +import time + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from implementations.aliyun_asr import AliyunASR +from services.audio_session import AudioSession +from handlers.audio_session_handler import handle_audio_packet +from handlers.service_connection_handler import send_audio_task, end_asr_session + + +async def run(args): + cases = json.loads(args.manifest.read_text()) + results = [] + for role in ['Banban', 'Baikeboshi']: + for case in cases: + audio = Path(case['audio']).read_bytes() + asr = AliyunASR(selected_role={'role_key': role}) + asr.device_id = 'LOCAL_ASR_TEST' + asr.session_id = f"{role}-{case['name']}" + session = AudioSession(asr_service=asr) + session.device_id, session.session_id = asr.device_id, asr.session_id + session.send_queue = asyncio.Queue() + started = time.perf_counter() + result = {'role': role, 'case': case['name'], 'model': asr.model, 'bytes': len(audio)} + try: + await asr.connect() + result['ready_seconds'] = round(time.perf_counter() - started, 3) + session.send_task = asyncio.create_task(send_audio_task(session)) + for offset in range(0, len(audio), 640): + chunk = audio[offset:offset + 640] + await handle_audio_packet(session, chunk) + await asyncio.sleep(len(chunk) / args.bytes_per_second) + ending = time.perf_counter() + await end_asr_session(session) + result['finish_seconds'] = round(time.perf_counter() - ending, 3) + result['transcript'] = session.transcript + result['error'] = session.asr_error + expected = case['expected_keywords'] + result['passed'] = not session.asr_error and ( + all(word.casefold() in session.transcript.casefold() for word in expected) + if expected else session.transcript == '' + ) + except Exception as exc: + result.update(error=f'{type(exc).__name__}: {exc}', passed=False) + finally: + if session.send_task: + session.send_task.cancel() + await asyncio.gather(session.send_task, return_exceptions=True) + await asr.close() + result['task_id'] = asr.task_id + result['resources_released'] = asr._client is None and asr._receiver is None + results.append(result) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(results, ensure_ascii=False, indent=2)) + print(json.dumps(result, ensure_ascii=False), flush=True) + return all(item['passed'] and item['resources_released'] for item in results) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('manifest', type=Path) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--bytes-per-second', type=float, default=2000) + args = parser.parse_args() + sys.exit(0 if asyncio.run(run(args)) else 1) diff --git a/talkingq-url/services/audio_session.py b/talkingq-url/services/audio_session.py index 1d7b584..2707e32 100644 --- a/talkingq-url/services/audio_session.py +++ b/talkingq-url/services/audio_session.py @@ -21,6 +21,7 @@ class AudioSession: self.send_queue = None self.send_task = None self.transcript = "" + self.asr_error = None self.last_activity_time = asyncio.get_running_loop().time() self.websocket = None self.llm_service = llm_service diff --git a/talkingq-url/services/factory.py b/talkingq-url/services/factory.py index c881607..5397f36 100644 --- a/talkingq-url/services/factory.py +++ b/talkingq-url/services/factory.py @@ -14,7 +14,7 @@ async def get_asr(selected_role: dict = None) -> ASR: session_logger.info( "system", "factory", - f"ASR提供商: Aliyun (固定)" + f"ASR提供商: Aliyun, 模型: {settings.aliyun_asr_model}" ) return AliyunASR(selected_role=selected_role) diff --git a/talkingq-url/tests/fixtures/asr/english.mp3 b/talkingq-url/tests/fixtures/asr/english.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..c130a07a9b5fe4143b7ec73057585597458ad155 GIT binary patch literal 6416 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 literal 0 HcmV?d00001 diff --git a/talkingq-url/tests/fixtures/asr/manifest.json b/talkingq-url/tests/fixtures/asr/manifest.json new file mode 100644 index 0000000..9f13fce --- /dev/null +++ b/talkingq-url/tests/fixtures/asr/manifest.json @@ -0,0 +1,46 @@ +[ + { + "name": "short", + "audio": "tests/fixtures/asr/short.mp3", + "source_text": "你好,请讲一个故事。", + "expected_keywords": [ + "你好", + "故事" + ] + }, + { + "name": "multi", + "audio": "tests/fixtures/asr/multi.mp3", + "source_text": "今天天气很好。[[slnc 2500]]我想去公园散步。", + "expected_keywords": [ + "天气", + "公园", + "散步" + ] + }, + { + "name": "slow", + "audio": "tests/fixtures/asr/slow.mp3", + "source_text": "我今天有一点累,想听一首歌。", + "expected_keywords": [ + "今天", + "累", + "歌" + ] + }, + { + "name": "english", + "audio": "tests/fixtures/asr/english.mp3", + "source_text": "Hello, please tell me a story about a little rabbit.", + "expected_keywords": [ + "story", + "rabbit" + ] + }, + { + "name": "silence", + "audio": "tests/fixtures/asr/silence.mp3", + "source_text": "", + "expected_keywords": [] + } +] diff --git a/talkingq-url/tests/fixtures/asr/multi.mp3 b/talkingq-url/tests/fixtures/asr/multi.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..2171e45c413d917123b5fcfe3426888aa12f2de5 GIT binary patch literal 12320 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 udPfhbsU13I`QHh4&Y{P