|
@@ -0,0 +1,250 @@
|
|
|
+#!/usr/bin/env python
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
+import base64
|
|
|
+import hashlib
|
|
|
+import hmac
|
|
|
+import json
|
|
|
+import time
|
|
|
+import uuid
|
|
|
+from datetime import datetime
|
|
|
+from urllib import parse
|
|
|
+import requests
|
|
|
+from loguru import logger
|
|
|
+from utils.config import OssConfig
|
|
|
+
|
|
|
+
|
|
|
+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).decode('utf-8')
|
|
|
+ signature = AccessToken._encode_text(signature)
|
|
|
+
|
|
|
+ full_url = 'http://nls-meta.cn-shanghai.aliyuncs.com/?Signature=%s&%s' % (signature, query_string)
|
|
|
+
|
|
|
+ try:
|
|
|
+ response = requests.get(full_url)
|
|
|
+ response.raise_for_status()
|
|
|
+ root_obj = response.json()
|
|
|
+ if 'Token' in root_obj:
|
|
|
+ token = root_obj['Token']['Id']
|
|
|
+ expire_time = root_obj['Token']['ExpireTime']
|
|
|
+ return token, expire_time
|
|
|
+ except requests.exceptions.RequestException as e:
|
|
|
+ logger.error(f"获取Token失败: {e}")
|
|
|
+
|
|
|
+ logger.error(f"获取Token失败: {response.text}")
|
|
|
+ return None, None
|
|
|
+
|
|
|
+
|
|
|
+class TtsHeader:
|
|
|
+ def __init__(self, appkey, token):
|
|
|
+ self.appkey = appkey
|
|
|
+ self.token = token
|
|
|
+
|
|
|
+ def to_dict(self):
|
|
|
+ return {'appkey': self.appkey, 'token': self.token}
|
|
|
+
|
|
|
+
|
|
|
+class TtsContext:
|
|
|
+ def __init__(self, device_id):
|
|
|
+ self.device_id = device_id
|
|
|
+
|
|
|
+ def to_dict(self):
|
|
|
+ return {'device_id': self.device_id}
|
|
|
+
|
|
|
+
|
|
|
+class TtsRequest:
|
|
|
+ def __init__(self, voice, sample_rate, format, enable_subtitle, text):
|
|
|
+ self.voice = voice
|
|
|
+ self.sample_rate = sample_rate
|
|
|
+ self.format = format
|
|
|
+ self.enable_subtitle = enable_subtitle
|
|
|
+ self.text = text
|
|
|
+
|
|
|
+ def to_dict(self):
|
|
|
+ return {
|
|
|
+ 'voice': self.voice,
|
|
|
+ 'sample_rate': self.sample_rate,
|
|
|
+ 'format': self.format,
|
|
|
+ 'enable_subtitle': self.enable_subtitle,
|
|
|
+ 'text': self.text
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+class TtsPayload:
|
|
|
+ def __init__(self, enable_notify, notify_url, tts_request):
|
|
|
+ self.enable_notify = enable_notify
|
|
|
+ self.notify_url = notify_url
|
|
|
+ self.tts_request = tts_request
|
|
|
+
|
|
|
+ def to_dict(self):
|
|
|
+ return {
|
|
|
+ 'enable_notify': self.enable_notify,
|
|
|
+ 'notify_url': self.notify_url,
|
|
|
+ 'tts_request': self.tts_request.to_dict()
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+class TtsBody:
|
|
|
+ def __init__(self, tts_header, tts_context, tts_payload):
|
|
|
+ self.tts_header = tts_header
|
|
|
+ self.tts_context = tts_context
|
|
|
+ self.tts_payload = tts_payload
|
|
|
+
|
|
|
+ def to_dict(self):
|
|
|
+ return {
|
|
|
+ 'header': self.tts_header.to_dict(),
|
|
|
+ 'context': self.tts_context.to_dict(),
|
|
|
+ 'payload': self.tts_payload.to_dict()
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+class AliyunTTS:
|
|
|
+ def __init__(self):
|
|
|
+ self.access_key_id = OssConfig["OSS_ACCESS_KEY_ID"]
|
|
|
+ self.access_key_secret = OssConfig["OSS_ACCESS_KEY_SECRET"]
|
|
|
+ self.app_key = OssConfig["APP_KEY"]
|
|
|
+ self.token = None
|
|
|
+ self.expire_time = None
|
|
|
+
|
|
|
+ def get_token(self):
|
|
|
+ """获取并缓存访问令牌"""
|
|
|
+ if not self.token or time.time() + 60 > self.expire_time:
|
|
|
+ self.token, self.expire_time = AccessToken.create_token(
|
|
|
+ self.access_key_id, self.access_key_secret
|
|
|
+ )
|
|
|
+ if self.token:
|
|
|
+ logger.info(f"获取Token成功,有效期至: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(self.expire_time))}")
|
|
|
+ else:
|
|
|
+ logger.error("获取Token失败")
|
|
|
+ return self.token
|
|
|
+
|
|
|
+ def synthesize(self, text, voice="xiaoyun", format="mp3", sample_rate=16000, use_polling=True):
|
|
|
+ """
|
|
|
+ 当 use_polling=False 时,阿里云语音合成会采用 回调模式 而非轮询模式。此时,需要提供一个 公网可访问的回调 URL,
|
|
|
+ 阿里云会在语音合成完成后主动发送请求到该 URL,通知合成结果。
|
|
|
+ """
|
|
|
+ """长文本语音合成"""
|
|
|
+ token = self.get_token()
|
|
|
+ if not token:
|
|
|
+ return None
|
|
|
+
|
|
|
+ th = TtsHeader(self.app_key, token)
|
|
|
+ tc = TtsContext("mydevice")
|
|
|
+ tr = TtsRequest(voice, sample_rate, format, False, text)
|
|
|
+
|
|
|
+ notify_url = "" if use_polling else "http://your-public-server.com/tts-callback"
|
|
|
+ tp = TtsPayload(use_polling, notify_url, tr)
|
|
|
+ tb = TtsBody(th, tc, tp)
|
|
|
+
|
|
|
+ body = json.dumps(tb.to_dict())
|
|
|
+ polling_url = "https://nls-gateway.cn-shanghai.aliyuncs.com/rest/v1/tts/async"
|
|
|
+
|
|
|
+ return request_long_tts(body, self.app_key, token, use_polling, polling_url)
|
|
|
+
|
|
|
+
|
|
|
+def request_long_tts(tts_body, appkey, token, use_polling=True, polling_url=None):
|
|
|
+ """发送长文本语音合成请求"""
|
|
|
+ url = 'https://nls-gateway.cn-shanghai.aliyuncs.com/rest/v1/tts/async'
|
|
|
+ headers = {'Content-Type': 'application/json'}
|
|
|
+
|
|
|
+ try:
|
|
|
+ response = requests.post(url, data=tts_body, headers=headers)
|
|
|
+ response.raise_for_status()
|
|
|
+
|
|
|
+ json_data = response.json()
|
|
|
+ if "error_code" in json_data and json_data["error_code"] == 20000000:
|
|
|
+ task_id = json_data['data']['task_id']
|
|
|
+ request_id = json_data['request_id']
|
|
|
+ logger.info(f"语音合成任务已提交,task_id: {task_id}")
|
|
|
+
|
|
|
+ if use_polling and polling_url:
|
|
|
+ return wait_loop_for_complete(polling_url, appkey, token, task_id, request_id)
|
|
|
+
|
|
|
+ return task_id, request_id
|
|
|
+ else:
|
|
|
+ logger.error(f"请求失败: {json_data}")
|
|
|
+ return None, None
|
|
|
+
|
|
|
+ except requests.exceptions.RequestException as e:
|
|
|
+ logger.error(f'请求异常: {e}')
|
|
|
+ return None, None
|
|
|
+
|
|
|
+
|
|
|
+def wait_loop_for_complete(url, appkey, token, task_id, request_id, max_retries=30):
|
|
|
+ """轮询等待合成完成"""
|
|
|
+ full_url = f"{url}?appkey={appkey}&task_id={task_id}&token={token}&request_id={request_id}"
|
|
|
+ logger.info(f"开始轮询任务状态: {task_id}")
|
|
|
+
|
|
|
+ for retries in range(max_retries):
|
|
|
+ try:
|
|
|
+ response = requests.get(full_url)
|
|
|
+ response.raise_for_status()
|
|
|
+ json_data = response.json()
|
|
|
+
|
|
|
+ if "data" in json_data and "audio_address" in json_data["data"]:
|
|
|
+ audio_address = json_data["data"]["audio_address"]
|
|
|
+ if audio_address:
|
|
|
+ logger.info(f"合成完成! audio_address = {audio_address}")
|
|
|
+ return audio_address
|
|
|
+ else:
|
|
|
+ logger.info(f"第 {retries + 1}/{max_retries} 次轮询: 合成中...")
|
|
|
+ elif "error_code" in json_data and json_data["error_code"] != 20000000:
|
|
|
+ logger.error(f"合成失败: {json_data.get('error_message', '未知错误')}")
|
|
|
+ return None
|
|
|
+
|
|
|
+ except requests.exceptions.RequestException as e:
|
|
|
+ logger.warning(f"轮询请求异常: {e}")
|
|
|
+
|
|
|
+ time.sleep(10)
|
|
|
+
|
|
|
+ logger.warning(f"已达到最大轮询次数({max_retries}),任务可能仍在处理中")
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ # 准备请求文本
|
|
|
+ tts_text = """生活中总有一些故事能让我们感受到温暖和智慧,赵元任的传奇经历就是这样一个值得分享的好故事..."""
|
|
|
+ tts_client = AliyunTTS()
|
|
|
+
|
|
|
+ # 语音合成
|
|
|
+ logger.info("开始语音合成...")
|
|
|
+ mp3_url = tts_client.synthesize(tts_text)
|
|
|
+ logger.info(f"合成的url: {mp3_url}")
|
|
|
+ if not mp3_url:
|
|
|
+ logger.error("语音合成失败,程序退出")
|