Files
xiaozhi-esp32-server/main/xiaozhi-server/core/providers/tts/aliyun.py
T

219 lines
8.0 KiB
Python
Raw Normal View History

import os
import uuid
import json
2025-03-07 21:19:41 +08:00
import hmac
import hashlib
import base64
import requests
from datetime import datetime
2025-05-21 14:52:24 +08:00
from core.providers.tts.base import TTSProviderBase
from pydub import AudioSegment
2025-03-07 21:19:41 +08:00
import time
import uuid
from urllib import parse
from core.providers.tts.dto.dto import TTSMessageDTO, MsgType, SentenceType
2025-05-21 14:52:24 +08:00
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
2025-03-07 21:19:41 +08:00
class AccessToken:
@staticmethod
def _encode_text(text):
encoded_text = parse.quote_plus(text)
2025-04-01 14:27:17 +08:00
return encoded_text.replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
2025-03-07 21:19:41 +08:00
@staticmethod
def _encode_dict(dic):
keys = dic.keys()
dic_sorted = [(key, dic[key]) for key in sorted(keys)]
encoded_text = parse.urlencode(dic_sorted)
2025-04-01 14:27:17 +08:00
return encoded_text.replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
2025-03-07 21:19:41 +08:00
@staticmethod
def create_token(access_key_id, access_key_secret):
2025-04-01 14:27:17 +08:00
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",
}
2025-03-07 21:19:41 +08:00
# 构造规范化的请求字符串
query_string = AccessToken._encode_dict(parameters)
2025-05-21 14:52:24 +08:00
# print('规范化的请求字符串: %s' % query_string)
2025-03-07 21:19:41 +08:00
# 构造待签名字符串
2025-04-01 14:27:17 +08:00
string_to_sign = (
"GET"
+ "&"
+ AccessToken._encode_text("/")
+ "&"
+ AccessToken._encode_text(query_string)
)
2025-05-21 14:52:24 +08:00
# print('待签名的字符串: %s' % string_to_sign)
2025-03-07 21:19:41 +08:00
# 计算签名
2025-04-01 14:27:17 +08:00
secreted_string = hmac.new(
bytes(access_key_secret + "&", encoding="utf-8"),
bytes(string_to_sign, encoding="utf-8"),
hashlib.sha1,
).digest()
2025-03-07 21:19:41 +08:00
signature = base64.b64encode(secreted_string)
2025-05-21 14:52:24 +08:00
# print('签名: %s' % signature)
2025-03-07 21:19:41 +08:00
# 进行URL编码
signature = AccessToken._encode_text(signature)
2025-05-21 14:52:24 +08:00
# print('URL编码后的签名: %s' % signature)
2025-03-07 21:19:41 +08:00
# 调用服务
2025-04-01 14:27:17 +08:00
full_url = "http://nls-meta.cn-shanghai.aliyuncs.com/?Signature=%s&%s" % (
signature,
query_string,
)
2025-05-21 14:52:24 +08:00
# print('url: %s' % full_url)
2025-03-07 21:19:41 +08:00
# 提交HTTP GET请求
response = requests.get(full_url)
if response.ok:
root_obj = response.json()
2025-04-01 14:27:17 +08:00
key = "Token"
2025-03-07 21:19:41 +08:00
if key in root_obj:
2025-04-01 14:27:17 +08:00
token = root_obj[key]["Id"]
expire_time = root_obj[key]["ExpireTime"]
2025-03-07 21:19:41 +08:00
return token, expire_time
2025-05-21 14:52:24 +08:00
# print(response.text)
2025-03-07 21:19:41 +08:00
return None, None
class TTSProvider(TTSProviderBase):
2025-03-07 21:19:41 +08:00
def __init__(self, config, delete_audio_file):
super().__init__(config, delete_audio_file)
2025-04-01 14:27:17 +08:00
2025-03-07 21:19:41 +08:00
# 新增空值判断逻辑
2025-05-21 14:52:24 +08:00
self.access_key_id = config.get("access_key_id")
self.access_key_secret = config.get("access_key_secret")
2025-03-07 21:19:41 +08:00
self.appkey = config.get("appkey")
self.format = config.get("format", "wav")
self.sample_rate = config.get("sample_rate", 16000)
self.voice = config.get("voice", "xiaoyun")
self.volume = config.get("volume", 50)
self.speech_rate = config.get("speech_rate", 0)
self.pitch_rate = config.get("pitch_rate", 0)
self.host = config.get("host", "nls-gateway-cn-shanghai.aliyuncs.com")
self.api_url = f"https://{self.host}/stream/v1/tts"
2025-04-01 14:27:17 +08:00
self.header = {"Content-Type": "application/json"}
2025-05-21 14:52:24 +08:00
if self.access_key_id and self.access_key_secret:
# 使用密钥对生成临时token
self._refresh_token()
else:
# 直接使用预生成的长期token
self.token = config.get("token")
self.expire_time = None
def _refresh_token(self):
"""刷新Token并记录过期时间"""
if self.access_key_id and self.access_key_secret:
self.token, expire_time_str = AccessToken.create_token(
self.access_key_id, self.access_key_secret
)
if not expire_time_str:
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 Exception as e:
raise ValueError(f"无效的过期时间格式: {expire_str}") from e
else:
self.expire_time = None
if not self.token:
raise ValueError("无法获取有效的访问Token")
def _is_token_expired(self):
"""检查Token是否过期"""
if not self.expire_time:
return False # 长期Token不过期
# 新增调试日志
# current_time = time.time()
# remaining = self.expire_time - current_time
# print(f"Token过期检查: 当前时间 {datetime.fromtimestamp(current_time)} | "
# f"过期时间 {datetime.fromtimestamp(self.expire_time)} | "
# f"剩余 {remaining:.2f}秒")
return time.time() > self.expire_time
def generate_filename(self, extension=".wav"):
2025-04-01 14:27:17 +08:00
return os.path.join(
self.output_file,
f"tts-{__name__}{datetime.now().date()}@{uuid.uuid4().hex}{extension}",
)
async def text_to_speak(self, u_id, text, is_last_text=False, is_first_text=False):
2025-05-21 14:52:24 +08:00
if self._is_token_expired():
logger.bind(tag=TAG).warning("Token已过期,正在自动刷新...")
self._refresh_token()
request_json = {
"appkey": self.appkey,
"token": self.token,
"text": text,
"format": self.format,
"sample_rate": self.sample_rate,
"voice": self.voice,
"volume": self.volume,
"speech_rate": self.speech_rate,
2025-04-01 14:27:17 +08:00
"pitch_rate": self.pitch_rate,
}
print(self.api_url, json.dumps(request_json, ensure_ascii=False))
try:
2025-04-01 14:27:17 +08:00
resp = requests.post(
self.api_url, json.dumps(request_json), headers=self.header
)
2025-05-21 14:52:24 +08:00
if resp.status_code == 401: # Token过期特殊处理
self._refresh_token()
resp = requests.post(
self.api_url, json.dumps(request_json), headers=self.header
)
# 检查返回请求数据的mime类型是否是audio/***,是则保存到指定路径下;返回的是binary格式的
2025-05-21 14:52:24 +08:00
tmp_file = self.generate_filename()
2025-04-01 14:27:17 +08:00
if resp.headers["Content-Type"].startswith("audio/"):
with open(tmp_file, "wb") as f:
f.write(resp.content)
else:
2025-04-01 14:27:17 +08:00
raise Exception(
f"{__name__} status_code: {resp.status_code} response: {resp.content}"
)
2025-05-21 14:52:24 +08:00
# 使用 pydub 读取临时文件
audio = AudioSegment.from_file(tmp_file, format="wav")
audio = audio.set_channels(1).set_frame_rate(16000)
opus_datas = self.wav_to_opus_data_audio_raw(audio.raw_data)
2025-04-01 14:27:17 +08:00
yield TTSMessageDTO(
u_id=u_id,
msg_type=MsgType.TTS_TEXT_RESPONSE,
content=opus_datas,
tts_finish_text=text,
sentence_type=SentenceType.SENTENCE_START,
)
# 用完后删除临时文件
try:
os.remove(tmp_file)
except FileNotFoundError:
# 若文件不存在,忽略该异常
pass
2025-05-21 14:52:24 +08:00
except Exception as e:
raise Exception(f"{__name__} error: {e}")