diff --git a/main/manager-api/src/main/resources/db/changelog/202509191732.sql b/main/manager-api/src/main/resources/db/changelog/202509191732.sql new file mode 100644 index 00000000..92e0ef0a --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202509191732.sql @@ -0,0 +1,26 @@ +-- 添加讯飞流式语音识别服务配置 +delete from `ai_model_provider` where id = 'SYSTEM_ASR_XunfeiStream'; +INSERT INTO `ai_model_provider` (`id`, `model_type`, `provider_code`, `name`, `fields`, `sort`, `creator`, `create_date`, `updater`, `update_date`) VALUES +('SYSTEM_ASR_XunfeiStream', 'ASR', 'xunfei_stream', '讯飞流式语音识别', '[{"key":"app_id","label":"应用ID","type":"string"},{"key":"api_key","label":"API密钥","type":"password"},{"key":"api_secret","label":"API密钥","type":"password"},{"key":"domain","label":"识别领域","type":"string"},{"key":"language","label":"识别语言","type":"string"},{"key":"accent","label":"方言","type":"string"},{"key":"dwa","label":"动态修正","type":"string"},{"key":"output_dir","label":"输出目录","type":"string"}]', 18, 1, NOW(), 1, NOW()); + +delete from `ai_model_config` where id = 'ASR_XunfeiStream'; +INSERT INTO `ai_model_config` VALUES ('ASR_XunfeiStream', 'ASR', '讯飞流式语音识别', '讯飞流式语音识别服务', 0, 1, '{"type": "xunfei_stream", "app_id": "", "api_key": "", "api_secret": "", "domain": "slm", "language": "zh_cn", "accent": "mandarin", "dwa": "wpgs", "output_dir": "tmp/"}', 'https://www.xfyun.cn/doc/spark/spark_zh_iat.html', '支持实时流式语音识别,适用于中文普通话及多种方言识别', 21, NULL, NULL, NULL, NULL); + +-- 更新讯飞流式语音识别模型配置的说明文档 +UPDATE `ai_model_config` SET +`doc_link` = 'https://www.xfyun.cn/doc/spark/spark_zh_iat.html', +`remark` = '讯飞流式语音识别配置说明: +1. 登录讯飞开放平台 https://www.xfyun.cn/ +2. 创建语音识别应用获取APPID、APISecret、APIKey +3. 参数说明: + - app_id: 应用ID,在讯飞开放平台创建应用后获得 + - api_key: API密钥,用于接口鉴权 + - api_secret: API密钥,用于生成签名 + - domain: 识别领域,默认slm(智能化语音转写) + - language: 识别语言,默认zh_cn(中文) + - accent: 方言类型,默认mandarin(普通话),支持cantonese(粤语)等 + - dwa: 动态修正,默认wpgs(开启动态修正) + - output_dir: 音频文件输出目录,默认tmp/ +4. 支持实时流式识别,适用于实时语音交互场景 +5. 支持多种方言和语言识别 +' WHERE `id` = 'ASR_XunfeiStream'; \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/202509191739.sql b/main/manager-api/src/main/resources/db/changelog/202509191739.sql new file mode 100644 index 00000000..94d267ea --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202509191739.sql @@ -0,0 +1,20 @@ +delete from `ai_model_config` where id = 'LLM_XunfeiSparkLLM'; +INSERT INTO `ai_model_config` VALUES ('LLM_XunfeiSparkLLM', 'LLM', '讯飞星火认知大模型', '讯飞星火认知大模型', 0, 1, '{"type": "openai", "model_name": "generalv3.5", "base_url": "https://spark-api-open.xf-yun.com/v1", "api_key": "你的api_key", "temperature": 0.5, "max_tokens": 2048, "top_p": 1.0, "frequency_penalty": 0.0}', 'https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html', '讯飞星火认知大模型,支持多轮对话、文本生成等功能', 14, NULL, NULL, NULL, NULL); + +-- 更新讯飞星火认知大模型配置的说明文档 +UPDATE `ai_model_config` SET +`doc_link` = 'https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html', +`remark` = '讯飞星火认知大模型配置说明: +1. 登录讯飞开放平台 https://www.xfyun.cn/ +2. 创建星火认知大模型应用获取API Key +3. 参数说明: + - api_key: API密钥,在讯飞开放平台创建应用后获得 + - model_name: 模型名称,支持generalv3.5、generalv3等版本 + - base_url: API地址,默认https://spark-api-open.xf-yun.com/v1 + - temperature: 温度参数,控制生成随机性,范围0-1,默认0.5 + - max_tokens: 最大输出token数,默认2048 + - top_p: 核心采样参数,控制词汇多样性,默认1.0 + - frequency_penalty: 频率惩罚,降低重复内容,默认0.0 +4. 支持多轮对话、文本生成、知识问答等功能 +5. 兼容OpenAI接口格式,可直接使用现有的OpenAI Provider +' WHERE `id` = 'LLM_XunfeiSparkLLM'; \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index c82ec415..b183980b 100755 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -366,3 +366,18 @@ databaseChangeLog: - sqlFile: encoding: utf8 path: classpath:db/changelog/202509191545.sql + - changeSet: + id: 202509191732 + author: fyb + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202509191732.sql + + - changeSet: + id: 202509191739 + author: fyb + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202509191739.sql diff --git a/main/manager-web/package-lock.json b/main/manager-web/package-lock.json index c104b60c..afd543b1 100644 --- a/main/manager-web/package-lock.json +++ b/main/manager-web/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "core-js": "^3.41.0", "cross-env": "^7.0.3", + "dotenv": "^16.5.0", "element-ui": "^2.15.14", "flyio": "^0.6.14", "normalize.css": "^8.0.1", @@ -17,6 +18,7 @@ "opus-recorder": "^8.0.5", "vue": "^2.6.14", "vue-axios": "^3.5.2", + "vue-i18n": "^8.28.2", "vue-router": "^3.6.5", "vuex": "^3.6.2", "xiaozhi": "file:" @@ -2540,6 +2542,16 @@ } } }, + "node_modules/@vue/cli-service/node_modules/dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, "node_modules/@vue/cli-shared-utils": { "version": "5.0.8", "resolved": "https://registry.npmmirror.com/@vue/cli-shared-utils/-/cli-shared-utils-5.0.8.tgz", @@ -4776,12 +4788,15 @@ } }, "node_modules/dotenv": { - "version": "10.0.0", - "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-10.0.0.tgz", - "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", - "dev": true, + "version": "16.6.1", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", "engines": { - "node": ">=10" + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, "node_modules/dotenv-expand": { @@ -11131,6 +11146,13 @@ "integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==", "dev": true }, + "node_modules/vue-i18n": { + "version": "8.28.2", + "resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-8.28.2.tgz", + "integrity": "sha512-C5GZjs1tYlAqjwymaaCPDjCyGo10ajUphiwA922jKt9n7KPpqR7oM1PCwYzhB/E7+nT3wfdG3oRre5raIT1rKA==", + "deprecated": "Vue I18n v8.x has reached EOL and is no longer actively maintained. About maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html", + "license": "MIT" + }, "node_modules/vue-loader": { "version": "17.4.2", "resolved": "https://registry.npmmirror.com/vue-loader/-/vue-loader-17.4.2.tgz", diff --git a/main/xiaozhi-server/config.yaml b/main/xiaozhi-server/config.yaml index 832ca13e..836fc673 100644 --- a/main/xiaozhi-server/config.yaml +++ b/main/xiaozhi-server/config.yaml @@ -427,7 +427,26 @@ ASR: enable_itn: true # 逆文本归一化 #language: "zh" # 语种,支持zh、en、ja、ko等 context: "" # 上下文信息,用于提高识别准确率,不超过10000 Token - + XunfeiStreamASR: + # 讯飞流式语音识别服务 + # 需要先在讯飞开放平台创建应用,获取以下认证信息 + # 讯飞开放平台地址:https://www.xfyun.cn/ + # 创建应用后,在"我的应用"中获取: + # - APPID + # - APISecret + # - APIKey + type: xunfei_stream + # 必填参数 - 讯飞开放平台应用信息 + app_id: 你的APPID + api_key: 你的APIKey + api_secret: 你的APISecret + # 识别参数配置 + domain: slm # 识别领域,iat:日常用语,medical:医疗,finance:金融等 + language: zh_cn # 语言,zh_cn:中文,en_us:英文 + accent: mandarin # 方言,mandarin:普通话 + dwa: wpgs # 动态修正,wpgs:实时返回中间结果 + # 调整音频处理参数以提高长语音识别质量 + output_dir: tmp/ VAD: SileroVAD: diff --git a/main/xiaozhi-server/core/providers/asr/xunfei_stream.py b/main/xiaozhi-server/core/providers/asr/xunfei_stream.py new file mode 100644 index 00000000..7f696428 --- /dev/null +++ b/main/xiaozhi-server/core/providers/asr/xunfei_stream.py @@ -0,0 +1,442 @@ +import json +import hmac +import base64 +import hashlib +import asyncio +import websockets +import opuslib_next +from time import mktime +from datetime import datetime +from urllib.parse import urlencode +from typing import List +from config.logger import setup_logging +from wsgiref.handlers import format_date_time +from core.providers.asr.base import ASRProviderBase +from core.providers.asr.dto.dto import InterfaceType + +TAG = __name__ +logger = setup_logging() + +# 帧状态常量 +STATUS_FIRST_FRAME = 0 # 第一帧的标识 +STATUS_CONTINUE_FRAME = 1 # 中间帧标识 +STATUS_LAST_FRAME = 2 # 最后一帧的标识 + +class ASRProvider(ASRProviderBase): + def __init__(self, config, delete_audio_file): + super().__init__() + self.interface_type = InterfaceType.STREAM + self.config = config + self.text = "" + self.decoder = opuslib_next.Decoder(16000, 1) + self.asr_ws = None + self.forward_task = None + self.is_processing = False + self.server_ready = False + self.last_frame_sent = False # 标记是否已发送最终帧 + self.best_text = "" # 保存最佳识别结果 + + # 讯飞配置 + self.app_id = config.get("app_id") + self.api_key = config.get("api_key") + self.api_secret = config.get("api_secret") + + if not all([self.app_id, self.api_key, self.api_secret]): + raise ValueError("必须提供app_id、api_key和api_secret") + + # 识别参数 + self.iat_params = { + "domain": config.get("domain", "slm"), + "language": config.get("language", "zh_cn"), + "accent": config.get("accent", "mandarin"), + "dwa": config.get("dwa", "wpgs"), + "result": { + "encoding": "utf8", + "compress": "raw", + "format": "plain" + } + } + + self.output_dir = config.get("output_dir", "tmp/") + self.delete_audio_file = delete_audio_file + + def create_url(self) -> str: + """生成认证URL""" + url = 'ws://iat.cn-huabei-1.xf-yun.com/v1' + # 生成RFC1123格式的时间戳 + now = datetime.now() + date = format_date_time(mktime(now.timetuple())) + + # 拼接字符串 + signature_origin = "host: " + "iat.cn-huabei-1.xf-yun.com" + "\n" + signature_origin += "date: " + date + "\n" + signature_origin += "GET " + "/v1 " + "HTTP/1.1" + + # 进行hmac-sha256进行加密 + signature_sha = hmac.new(self.api_secret.encode('utf-8'), signature_origin.encode('utf-8'), + digestmod=hashlib.sha256).digest() + signature_sha = base64.b64encode(signature_sha).decode(encoding='utf-8') + + authorization_origin = "api_key=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"" % ( + self.api_key, "hmac-sha256", "host date request-line", signature_sha) + authorization = base64.b64encode(authorization_origin.encode('utf-8')).decode(encoding='utf-8') + + # 将请求的鉴权参数组合为字典 + v = { + "authorization": authorization, + "date": date, + "host": "iat.cn-huabei-1.xf-yun.com" + } + + # 拼接鉴权参数,生成url + url = url + '?' + urlencode(v) + return url + + async def open_audio_channels(self, conn): + await super().open_audio_channels(conn) + + async def receive_audio(self, conn, audio, audio_have_voice): + # 先调用父类方法处理基础逻辑 + await super().receive_audio(conn, audio, audio_have_voice) + + # 存储音频数据用于声纹识别 + if not hasattr(conn, 'asr_audio_for_voiceprint'): + conn.asr_audio_for_voiceprint = [] + conn.asr_audio_for_voiceprint.append(audio) + + # 如果本次有声音,且之前没有建立连接 + if audio_have_voice and self.asr_ws is None and not self.is_processing: + try: + await self._start_recognition(conn) + except Exception as e: + logger.bind(tag=TAG).error(f"建立ASR连接失败: {str(e)}") + await self._cleanup(conn) + return + + # 发送当前音频数据 + if self.asr_ws and self.is_processing and self.server_ready: + try: + pcm_frame = self.decoder.decode(audio, 960) + await self._send_audio_frame(pcm_frame, STATUS_CONTINUE_FRAME) + except Exception as e: + logger.bind(tag=TAG).warning(f"发送音频数据时发生错误: {e}") + await self._cleanup(conn) + + async def _start_recognition(self, conn): + """开始识别会话""" + try: + self.is_processing = True + # 建立WebSocket连接 + ws_url = self.create_url() + logger.bind(tag=TAG).info(f"正在连接ASR服务: {ws_url[:50]}...") + + self.asr_ws = await websockets.connect( + ws_url, + max_size=1000000000, + ping_interval=None, + ping_timeout=None, + close_timeout=10, + ) + + logger.bind(tag=TAG).info("ASR WebSocket连接已建立") + self.server_ready = False + self.last_frame_sent = False + self.best_text = "" + self.forward_task = asyncio.create_task(self._forward_results(conn)) + + # 发送首帧音频 + if conn.asr_audio and len(conn.asr_audio) > 0: + first_audio = conn.asr_audio[-1] if conn.asr_audio else b'' + pcm_frame = self.decoder.decode(first_audio, 960) if first_audio else b'' + await self._send_audio_frame(pcm_frame, STATUS_FIRST_FRAME) + self.server_ready = True + logger.bind(tag=TAG).info("已发送首帧,开始识别") + + # 发送缓存的音频数据 + for cached_audio in conn.asr_audio[-10:]: + try: + pcm_frame = self.decoder.decode(cached_audio, 960) + await self._send_audio_frame(pcm_frame, STATUS_CONTINUE_FRAME) + except Exception as e: + logger.bind(tag=TAG).info(f"发送缓存音频数据时发生错误: {e}") + break + + except Exception as e: + logger.bind(tag=TAG).error(f"建立ASR连接失败: {str(e)}") + if hasattr(e, "__cause__") and e.__cause__: + logger.bind(tag=TAG).error(f"错误原因: {str(e.__cause__)}") + if self.asr_ws: + await self.asr_ws.close() + self.asr_ws = None + self.is_processing = False + raise + + async def _send_audio_frame(self, audio_data: bytes, status: int): + """发送音频帧""" + if not self.asr_ws: + return + + audio_b64 = base64.b64encode(audio_data).decode('utf-8') + + frame_data = { + "header": { + "status": status, + "app_id": self.app_id + }, + "parameter": { + "iat": self.iat_params + }, + "payload": { + "audio": { + "audio": audio_b64, + "sample_rate": 16000, + "encoding": "raw" + } + } + } + + await self.asr_ws.send(json.dumps(frame_data, ensure_ascii=False)) + + # 标记是否发送了最终帧 + if status == STATUS_LAST_FRAME: + self.last_frame_sent = True + logger.bind(tag=TAG).info("标记最终帧已发送") + + async def _forward_results(self, conn): + """转发识别结果""" + try: + while self.asr_ws and not conn.stop_event.is_set(): + # 获取当前连接的音频数据 + audio_data = getattr(conn, 'asr_audio_for_voiceprint', []) + try: + # 如果已发送最终帧,增加超时时间等待完整结果 + timeout = 3.0 if self.last_frame_sent else 30.0 + response = await asyncio.wait_for(self.asr_ws.recv(), timeout=timeout) + result = json.loads(response) + logger.bind(tag=TAG).debug(f"收到ASR结果: {result}") + + header = result.get("header", {}) + payload = result.get("payload", {}) + code = header.get("code", 0) + status = header.get("status", 0) + + if code != 0: + logger.bind(tag=TAG).error(f"识别错误,错误码: {code}, 消息: {header.get('message', '')}") + if code in [10114, 10160]: # 连接问题 + break + continue + + # 处理识别结果 + if payload and "result" in payload: + text_data = payload["result"]["text"] + if text_data: + # 解码base64文本 + decoded_text = base64.b64decode(text_data).decode('utf-8') + text_json = json.loads(decoded_text) + + # 提取文本内容 + text_ws = text_json.get('ws', []) + result_text = '' + for i in text_ws: + for j in i.get("cw", []): + w = j.get("w", "") + result_text += w + + # 更新识别文本 - 实时更新策略 + if result_text and result_text.strip() not in ['', '。', '.', ',', ',']: + # 实时更新:正常情况下都更新,提高响应速度 + should_update = True + + # 保存最佳文本(最长的有意义文本) + if (len(result_text) > len(self.best_text) and + result_text.strip() not in ['?', '?', '。', '.']): + self.best_text = result_text + logger.bind(tag=TAG).debug(f"保存最佳文本: {self.best_text}") + + # 如果已发送最终帧,只过滤明显的无效结果 + if self.last_frame_sent: + # 最终帧后拒绝问号等明显错误的结果 + if result_text.strip() in ['?', '?', '。', '.']: + should_update = False + logger.bind(tag=TAG).warning(f"最终帧后拒绝无效文本: {result_text}") + + if should_update: + self.text = result_text + logger.bind(tag=TAG).info(f"实时更新识别文本: {self.text} (最终帧已发送: {self.last_frame_sent})") + + # 识别完成,但如果还没发送最终帧,继续等待 + if status == 2: + logger.bind(tag=TAG).info(f"识别完成状态已到达,当前识别文本: {self.text}") + + # 如果还没发送最终帧,继续等待 + if not self.last_frame_sent: + logger.bind(tag=TAG).info("识别完成但最终帧未发送,继续等待...") + continue + + # 已发送最终帧且收到完成状态,使用最佳文本作为最终结果 + if self.best_text and len(self.best_text) > len(self.text): + logger.bind(tag=TAG).info(f"使用最佳文本作为最终结果: {self.text} -> {self.best_text}") + self.text = self.best_text + + logger.bind(tag=TAG).info(f"获取到最终完整文本: {self.text}") + conn.reset_vad_states() + if len(audio_data) > 15: # 确保有足够音频数据 + # 准备处理结果 + pass + break + + except asyncio.TimeoutError: + if self.last_frame_sent: + # 超时时也使用最佳文本 + if self.best_text and len(self.best_text) > len(self.text): + logger.bind(tag=TAG).info(f"超时,使用最佳文本: {self.text} -> {self.best_text}") + self.text = self.best_text + logger.bind(tag=TAG).info(f"最终帧后超时,使用结果: {self.text}") + break + # 如果还没发送最终帧,继续等待 + continue + except websockets.ConnectionClosed: + logger.bind(tag=TAG).info("ASR服务连接已关闭") + self.is_processing = False + break + except Exception as e: + logger.bind(tag=TAG).error(f"处理ASR结果时发生错误: {str(e)}") + if hasattr(e, "__cause__") and e.__cause__: + logger.bind(tag=TAG).error(f"错误原因: {str(e.__cause__)}") + self.is_processing = False + break + + except Exception as e: + logger.bind(tag=TAG).error(f"ASR结果转发任务发生错误: {str(e)}") + if hasattr(e, "__cause__") and e.__cause__: + logger.bind(tag=TAG).error(f"错误原因: {str(e.__cause__)}") + finally: + if self.asr_ws: + await self.asr_ws.close() + self.asr_ws = None + self.is_processing = False + if conn: + if hasattr(conn, 'asr_audio_for_voiceprint'): + conn.asr_audio_for_voiceprint = [] + if hasattr(conn, 'asr_audio'): + conn.asr_audio = [] + if hasattr(conn, 'has_valid_voice'): + conn.has_valid_voice = False + + async def handle_voice_stop(self, conn, asr_audio_task: List[bytes]): + """处理语音停止,发送最后一帧并处理识别结果""" + try: + # 先发送最后一帧表示音频结束 + if self.asr_ws and self.is_processing: + try: + # 取最后一个有效的音频帧作为最后一帧数据 + last_frame = b'' + if asr_audio_task: + last_audio = asr_audio_task[-1] + last_frame = self.decoder.decode(last_audio, 960) + await self._send_audio_frame(last_frame, STATUS_LAST_FRAME) + logger.bind(tag=TAG).info("已发送最后一帧") + + # 发送最终帧后,给_forward_results适当时间处理最终结果 + # 减少等待时间,提高响应速度 + await asyncio.sleep(0.1) # 从3秒减少到1秒 + + logger.bind(tag=TAG).info(f"准备处理最终识别结果: {self.text}") + except Exception as e: + logger.bind(tag=TAG).error(f"发送最后一帧失败: {e}") + + # 调用父类的handle_voice_stop方法处理识别结果 + await super().handle_voice_stop(conn, asr_audio_task) + except Exception as e: + logger.bind(tag=TAG).error(f"处理语音停止失败: {e}") + import traceback + logger.bind(tag=TAG).debug(f"异常详情: {traceback.format_exc()}") + + def stop_ws_connection(self): + if self.asr_ws: + asyncio.create_task(self.asr_ws.close()) + self.asr_ws = None + self.is_processing = False + + async def _cleanup(self, conn): + """清理资源""" + logger.bind(tag=TAG).info(f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}") + + # 发送最后一帧 + if self.asr_ws and self.is_processing: + try: + await self._send_audio_frame(b'', STATUS_LAST_FRAME) + await asyncio.sleep(0.1) + logger.bind(tag=TAG).info("已发送最后一帧") + except Exception as e: + logger.bind(tag=TAG).error(f"发送最后一帧失败: {e}") + + # 状态重置 + self.is_processing = False + self.server_ready = False + self.last_frame_sent = False + self.best_text = "" + logger.bind(tag=TAG).info("ASR状态已重置") + + # 清理任务 + if self.forward_task and not self.forward_task.done(): + self.forward_task.cancel() + try: + await asyncio.wait_for(self.forward_task, timeout=1.0) + except asyncio.CancelledError: + pass + except Exception as e: + logger.bind(tag=TAG).debug(f"forward_task取消异常: {e}") + finally: + self.forward_task = None + + # 关闭连接 + if self.asr_ws: + try: + logger.bind(tag=TAG).debug("正在关闭WebSocket连接") + await asyncio.wait_for(self.asr_ws.close(), timeout=2.0) + logger.bind(tag=TAG).debug("WebSocket连接已关闭") + except Exception as e: + logger.bind(tag=TAG).error(f"关闭WebSocket连接失败: {e}") + finally: + self.asr_ws = None + + # 清理连接的音频缓存 + if conn: + if hasattr(conn, 'asr_audio_for_voiceprint'): + conn.asr_audio_for_voiceprint = [] + if hasattr(conn, 'asr_audio'): + conn.asr_audio = [] + if hasattr(conn, 'has_valid_voice'): + conn.has_valid_voice = False + + logger.bind(tag=TAG).info("ASR会话清理完成") + + async def speech_to_text(self, opus_data, session_id, audio_format): + """获取识别结果""" + result = self.text + self.text = "" + return result, None + + async def close(self): + """资源清理方法""" + if self.asr_ws: + await self.asr_ws.close() + self.asr_ws = None + if self.forward_task: + self.forward_task.cancel() + try: + await self.forward_task + except asyncio.CancelledError: + pass + self.forward_task = None + self.is_processing = False + # 清理所有连接的音频缓冲区 + if hasattr(self, '_connections'): + for conn in self._connections.values(): + if hasattr(conn, 'asr_audio_for_voiceprint'): + conn.asr_audio_for_voiceprint = [] + if hasattr(conn, 'asr_audio'): + conn.asr_audio = [] + if hasattr(conn, 'has_valid_voice'): + conn.has_valid_voice = False \ No newline at end of file