import json
|
import time
|
import uuid
|
import hmac
|
import base64
|
import hashlib
|
import asyncio
|
import requests
|
import websockets
|
from urllib import parse
|
from datetime import datetime
|
from config.logger import setup_logging
|
from core.providers.asr.base import ASRProviderBase
|
from core.providers.asr.dto.dto import InterfaceType
|
from typing import TYPE_CHECKING
|
|
if TYPE_CHECKING:
|
from core.connection import ConnectionHandler
|
|
TAG = __name__
|
logger = setup_logging()
|
|
|
class AccessToken:
|
@staticmethod
|
def _encode_text(text):
|
encoded_text = parse.quote_plus(text)
|
return encoded_text.replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
|
|
@staticmethod
|
def _encode_dict(dic):
|
keys = dic.keys()
|
dic_sorted = [(key, dic[key]) for key in sorted(keys)]
|
encoded_text = parse.urlencode(dic_sorted)
|
return encoded_text.replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
|
|
@staticmethod
|
def create_token(access_key_id, access_key_secret):
|
parameters = {
|
"AccessKeyId": access_key_id,
|
"Action": "CreateToken",
|
"Format": "JSON",
|
"RegionId": "cn-shanghai",
|
"SignatureMethod": "HMAC-SHA1",
|
"SignatureNonce": str(uuid.uuid1()),
|
"SignatureVersion": "1.0",
|
"Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
"Version": "2019-02-28",
|
}
|
query_string = AccessToken._encode_dict(parameters)
|
string_to_sign = (
|
"GET" + "&" + AccessToken._encode_text("/") + "&" + AccessToken._encode_text(query_string)
|
)
|
secreted_string = hmac.new(
|
bytes(access_key_secret + "&", encoding="utf-8"),
|
bytes(string_to_sign, encoding="utf-8"),
|
hashlib.sha1,
|
).digest()
|
signature = base64.b64encode(secreted_string)
|
signature = AccessToken._encode_text(signature)
|
full_url = "http://nls-meta.cn-shanghai.aliyuncs.com/?Signature=%s&%s" % (signature, query_string)
|
response = requests.get(full_url)
|
if response.ok:
|
root_obj = response.json()
|
if "Token" in root_obj:
|
return root_obj["Token"]["Id"], root_obj["Token"]["ExpireTime"]
|
return None, None
|
|
|
class ASRProvider(ASRProviderBase):
|
def __init__(self, config, delete_audio_file):
|
super().__init__()
|
self.interface_type = InterfaceType.STREAM
|
self.config = config
|
self.text = ""
|
self.asr_ws = None
|
self.forward_task = None
|
self.is_processing = False
|
self.server_ready = False # 服务器准备状态
|
|
# 基础配置
|
self.access_key_id = config.get("access_key_id")
|
self.access_key_secret = config.get("access_key_secret")
|
self.appkey = config.get("appkey")
|
self.token = config.get("token")
|
self.host = config.get("host", "nls-gateway-cn-shanghai.aliyuncs.com")
|
# 如果配置的是内网地址(包含-internal.aliyuncs.com),则使用ws协议,默认是wss协议
|
if "-internal." in self.host:
|
self.ws_url = f"ws://{self.host}/ws/v1"
|
else:
|
# 默认使用wss协议
|
self.ws_url = f"wss://{self.host}/ws/v1"
|
|
self.max_sentence_silence = config.get("max_sentence_silence")
|
self.output_dir = config.get("output_dir", "./audio_output")
|
self.delete_audio_file = delete_audio_file
|
self.expire_time = None
|
|
self.task_id = uuid.uuid4().hex
|
|
# Token管理
|
if self.access_key_id and self.access_key_secret:
|
self._refresh_token()
|
elif not self.token:
|
raise ValueError("必须提供access_key_id+access_key_secret或者直接提供token")
|
|
def _refresh_token(self):
|
"""刷新Token"""
|
self.token, expire_time_str = AccessToken.create_token(self.access_key_id, self.access_key_secret)
|
if not self.token:
|
raise ValueError("无法获取有效的访问Token")
|
|
try:
|
expire_str = str(expire_time_str).strip()
|
if expire_str.isdigit():
|
expire_time = datetime.fromtimestamp(int(expire_str))
|
else:
|
expire_time = datetime.strptime(expire_str, "%Y-%m-%dT%H:%M:%SZ")
|
self.expire_time = expire_time.timestamp() - 60
|
except:
|
self.expire_time = None
|
|
def _is_token_expired(self):
|
"""检查Token是否过期"""
|
return self.expire_time and time.time() > self.expire_time
|
|
async def open_audio_channels(self, conn):
|
await super().open_audio_channels(conn)
|
|
async def receive_audio(self, conn, pcm_frame, audio_have_voice):
|
# 先调用父类方法处理基础逻辑
|
await super().receive_audio(conn, pcm_frame, audio_have_voice)
|
|
# 只在有声音且没有连接时建立连接(排除正在停止的情况)
|
if audio_have_voice and not self.is_processing and not self.asr_ws:
|
try:
|
await self._start_recognition(conn)
|
except Exception as e:
|
logger.bind(tag=TAG).error(f"开始识别失败: {str(e)}")
|
await self._cleanup()
|
return
|
|
if self.asr_ws and self.is_processing and self.server_ready:
|
try:
|
await self.asr_ws.send(pcm_frame)
|
except Exception as e:
|
logger.bind(tag=TAG).warning(f"发送音频失败: {str(e)}")
|
await self._cleanup()
|
|
async def _start_recognition(self, conn: "ConnectionHandler"):
|
"""开始识别会话"""
|
if self._is_token_expired():
|
self._refresh_token()
|
|
# 建立连接
|
headers = {"X-NLS-Token": self.token}
|
self.asr_ws = await websockets.connect(
|
self.ws_url,
|
additional_headers=headers,
|
max_size=1000000000,
|
ping_interval=None,
|
ping_timeout=None,
|
close_timeout=5,
|
)
|
|
self.task_id = uuid.uuid4().hex
|
|
logger.bind(tag=TAG).debug(f"WebSocket连接建立成功, task_id: {self.task_id}")
|
|
self.is_processing = True
|
self.server_ready = False # 重置服务器准备状态
|
self.forward_task = asyncio.create_task(self._forward_results(conn))
|
|
# 发送开始请求
|
start_request = {
|
"header": {
|
"namespace": "SpeechTranscriber",
|
"name": "StartTranscription",
|
"message_id": uuid.uuid4().hex,
|
"task_id": self.task_id,
|
"appkey": self.appkey
|
},
|
"payload": {
|
"format": "pcm",
|
"sample_rate": 16000,
|
"enable_intermediate_result": True,
|
"enable_punctuation_prediction": True,
|
"enable_inverse_text_normalization": True,
|
"max_sentence_silence": self.max_sentence_silence,
|
"enable_voice_detection": False,
|
}
|
}
|
await self.asr_ws.send(json.dumps(start_request, ensure_ascii=False))
|
logger.bind(tag=TAG).debug("已发送开始请求,等待服务器准备...")
|
|
async def _forward_results(self, conn: "ConnectionHandler"):
|
"""转发识别结果"""
|
try:
|
while not conn.stop_event.is_set():
|
# 获取当前连接的音频数据
|
audio_data = conn.asr_audio
|
try:
|
response = await self.asr_ws.recv()
|
result = json.loads(response)
|
|
header = result.get("header", {})
|
payload = result.get("payload", {})
|
message_name = header.get("name", "")
|
status = header.get("status", 0)
|
|
if status != 20000000:
|
if status == 40010004:
|
logger.bind(tag=TAG).warning(f"请在服务端响应完成后再关闭链接,状态码: {status}")
|
break
|
if status in [40000004, 40010003]: # 连接超时或客户端断开
|
logger.bind(tag=TAG).warning(f"连接问题,状态码: {status}")
|
break
|
elif status in [40270002, 40270003]: # 音频问题
|
logger.bind(tag=TAG).warning(f"音频处理问题,状态码: {status}")
|
continue
|
else:
|
logger.bind(tag=TAG).error(f"识别错误,状态码: {status}, 消息: {header.get('status_text', '')}")
|
continue
|
|
# 收到TranscriptionStarted表示服务器准备好接收音频数据
|
if message_name == "TranscriptionStarted":
|
self.server_ready = True
|
logger.bind(tag=TAG).debug("服务器已准备,开始发送缓存音频...")
|
|
# 发送缓存音频
|
if conn.asr_audio:
|
for cached_pcm in conn.asr_audio[-10:]:
|
try:
|
await self.asr_ws.send(cached_pcm)
|
except Exception as e:
|
logger.bind(tag=TAG).warning(f"发送缓存音频失败: {e}")
|
break
|
continue
|
elif message_name == "SentenceEnd":
|
# 句子结束(每个句子都会触发)
|
text = payload.get("result", "")
|
if text:
|
logger.bind(tag=TAG).info(f"识别到文本: {text}")
|
|
# 手动模式下累积识别结果
|
if conn.client_listen_mode == "manual":
|
if self.text:
|
self.text += text
|
else:
|
self.text = text
|
|
# 手动模式下,只有在收到stop信号后才触发处理(仅处理一次)
|
if conn.client_voice_stop:
|
logger.bind(tag=TAG).debug("收到最终识别结果,触发处理")
|
await self.handle_voice_stop(conn, audio_data)
|
break
|
else:
|
# 自动模式下直接覆盖
|
self.text = text
|
await self.handle_voice_stop(conn, audio_data)
|
break
|
|
except asyncio.TimeoutError:
|
logger.bind(tag=TAG).error("接收结果超时")
|
break
|
except websockets.ConnectionClosed:
|
logger.bind(tag=TAG).info("ASR服务连接已关闭")
|
self.is_processing = False
|
break
|
except Exception as e:
|
logger.bind(tag=TAG).error(f"处理结果失败: {str(e)}")
|
break
|
|
except Exception as e:
|
logger.bind(tag=TAG).error(f"结果转发失败: {str(e)}")
|
finally:
|
# 清理连接的音频缓存
|
await self._cleanup()
|
conn.reset_audio_states()
|
|
async def _send_stop_request(self):
|
"""发送停止识别请求(不关闭连接)"""
|
if self.asr_ws:
|
try:
|
# 先停止音频发送
|
self.is_processing = False
|
|
stop_msg = {
|
"header": {
|
"namespace": "SpeechTranscriber",
|
"name": "StopTranscription",
|
"message_id": uuid.uuid4().hex,
|
"task_id": self.task_id,
|
"appkey": self.appkey
|
}
|
}
|
logger.bind(tag=TAG).debug("停止识别请求已发送")
|
await self.asr_ws.send(json.dumps(stop_msg, ensure_ascii=False))
|
except Exception as e:
|
logger.bind(tag=TAG).error(f"发送停止识别请求失败: {e}")
|
|
async def _cleanup(self):
|
"""清理资源(关闭连接)"""
|
logger.bind(tag=TAG).debug(f"开始ASR会话清理 | 当前状态: processing={self.is_processing}, server_ready={self.server_ready}")
|
|
# 状态重置
|
self.is_processing = False
|
self.server_ready = False
|
logger.bind(tag=TAG).debug("ASR状态已重置")
|
|
# 关闭连接
|
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
|
|
# 清理任务引用
|
self.forward_task = None
|
|
logger.bind(tag=TAG).debug("ASR会话清理完成")
|
|
async def speech_to_text(self, opus_data, session_id, artifacts=None):
|
"""获取识别结果"""
|
result = self.text
|
self.text = ""
|
return result, None
|
|
async def close(self):
|
"""关闭资源"""
|
await self._cleanup()
|