上传后台修改
This commit is contained in:
431
talkingq-url/test/minimax_tts_gui.py
Normal file
431
talkingq-url/test/minimax_tts_gui.py
Normal file
@@ -0,0 +1,431 @@
|
||||
import os
|
||||
import asyncio
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
import aiohttp
|
||||
import aiofiles
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import platform
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger("minimax_tts_gui")
|
||||
|
||||
class MiniMaxTTSGUI:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.root.title("MiniMax TTS 工具")
|
||||
self.root.geometry("750x700")
|
||||
|
||||
self.current_audio_file = None
|
||||
self.synthesis_in_progress = False
|
||||
self.text_entries = []
|
||||
self.config_collapsed = False
|
||||
|
||||
self.load_env()
|
||||
self.setup_ui()
|
||||
|
||||
def load_env(self):
|
||||
env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "minimax.env")
|
||||
if os.path.exists(env_path):
|
||||
load_dotenv(env_path)
|
||||
logger.info(f"已加载环境变量文件: {env_path}")
|
||||
else:
|
||||
logger.warning(f"环境变量文件不存在: {env_path}")
|
||||
|
||||
def setup_ui(self):
|
||||
main_frame = ttk.Frame(self.root, padding="10")
|
||||
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
|
||||
self.root.columnconfigure(0, weight=1)
|
||||
self.root.rowconfigure(0, weight=1)
|
||||
main_frame.columnconfigure(1, weight=1)
|
||||
|
||||
row = 0
|
||||
|
||||
config_header_frame = ttk.Frame(main_frame)
|
||||
config_header_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 5))
|
||||
|
||||
ttk.Label(config_header_frame, text="配置信息", font=("Arial", 12, "bold")).pack(side=tk.LEFT)
|
||||
self.toggle_btn = ttk.Button(config_header_frame, text="▼", width=3, command=self.toggle_config)
|
||||
self.toggle_btn.pack(side=tk.RIGHT)
|
||||
|
||||
row += 1
|
||||
|
||||
self.config_frame = ttk.Frame(main_frame)
|
||||
self.config_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
|
||||
|
||||
config_row = 0
|
||||
|
||||
ttk.Label(self.config_frame, text="API Key:").grid(row=config_row, column=0, sticky=tk.W, pady=5)
|
||||
self.api_key_var = tk.StringVar(value=os.getenv("MINIMAX_API_KEY", ""))
|
||||
ttk.Entry(self.config_frame, textvariable=self.api_key_var, width=60).grid(row=config_row, column=1, sticky=(tk.W, tk.E), pady=5)
|
||||
config_row += 1
|
||||
|
||||
ttk.Label(self.config_frame, text="Group ID:").grid(row=config_row, column=0, sticky=tk.W, pady=5)
|
||||
self.group_id_var = tk.StringVar(value=os.getenv("MINIMAX_GROUP_ID", "1915292410024300630"))
|
||||
ttk.Entry(self.config_frame, textvariable=self.group_id_var, width=60).grid(row=config_row, column=1, sticky=(tk.W, tk.E), pady=5)
|
||||
config_row += 1
|
||||
|
||||
ttk.Label(self.config_frame, text="Base URL:").grid(row=config_row, column=0, sticky=tk.W, pady=5)
|
||||
self.base_url_var = tk.StringVar(value=os.getenv("MINIMAX_BASE_URL", "https://api.minimax.chat/v1/t2a_v2"))
|
||||
ttk.Entry(self.config_frame, textvariable=self.base_url_var, width=60).grid(row=config_row, column=1, sticky=(tk.W, tk.E), pady=5)
|
||||
config_row += 1
|
||||
|
||||
ttk.Label(self.config_frame, text="音色 ID:").grid(row=config_row, column=0, sticky=tk.W, pady=5)
|
||||
self.voice_id_var = tk.StringVar(value=os.getenv("MINIMAX_VOICE_ID", "cartoon-boy-01"))
|
||||
ttk.Entry(self.config_frame, textvariable=self.voice_id_var, width=60).grid(row=config_row, column=1, sticky=(tk.W, tk.E), pady=5)
|
||||
config_row += 1
|
||||
|
||||
self.config_frame.columnconfigure(1, weight=1)
|
||||
|
||||
row += 1
|
||||
|
||||
ttk.Separator(main_frame, orient='horizontal').grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10)
|
||||
row += 1
|
||||
|
||||
text_list_header = ttk.Frame(main_frame)
|
||||
text_list_header.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 5))
|
||||
|
||||
ttk.Label(text_list_header, text="文本列表", font=("Arial", 12, "bold")).pack(side=tk.LEFT)
|
||||
add_btn = ttk.Button(text_list_header, text="➕", width=3, command=self.add_text_entry)
|
||||
add_btn.pack(side=tk.RIGHT)
|
||||
row += 1
|
||||
|
||||
self.text_list_container = ttk.Frame(main_frame)
|
||||
self.text_list_container.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5)
|
||||
self.text_list_container.columnconfigure(1, weight=1)
|
||||
|
||||
self.add_text_entry()
|
||||
|
||||
row += 1
|
||||
|
||||
ttk.Separator(main_frame, orient='horizontal').grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10)
|
||||
row += 1
|
||||
|
||||
ttk.Label(main_frame, text="合成参数", font=("Arial", 12, "bold")).grid(row=row, column=0, columnspan=2, sticky=tk.W, pady=(0, 10))
|
||||
row += 1
|
||||
|
||||
ttk.Label(main_frame, text="输出目录:").grid(row=row, column=0, sticky=tk.W, pady=5)
|
||||
self.output_dir_var = tk.StringVar(value="assets")
|
||||
ttk.Entry(main_frame, textvariable=self.output_dir_var, width=50).grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5)
|
||||
row += 1
|
||||
|
||||
ttk.Label(main_frame, text="语速:").grid(row=row, column=0, sticky=tk.W, pady=5)
|
||||
self.speed_var = tk.DoubleVar(value=1.0)
|
||||
ttk.Scale(main_frame, from_=0.5, to=2.0, variable=self.speed_var, orient=tk.HORIZONTAL, length=300).grid(row=row, column=1, sticky=tk.W, pady=5)
|
||||
ttk.Label(main_frame, textvariable=self.speed_var).grid(row=row, column=1, sticky=tk.E, pady=5)
|
||||
row += 1
|
||||
|
||||
ttk.Label(main_frame, text="音量:").grid(row=row, column=0, sticky=tk.W, pady=5)
|
||||
self.vol_var = tk.DoubleVar(value=1.0)
|
||||
ttk.Scale(main_frame, from_=0.5, to=2.0, variable=self.vol_var, orient=tk.HORIZONTAL, length=300).grid(row=row, column=1, sticky=tk.W, pady=5)
|
||||
ttk.Label(main_frame, textvariable=self.vol_var).grid(row=row, column=1, sticky=tk.E, pady=5)
|
||||
row += 1
|
||||
|
||||
ttk.Label(main_frame, text="音调:").grid(row=row, column=0, sticky=tk.W, pady=5)
|
||||
self.pitch_var = tk.IntVar(value=0)
|
||||
ttk.Scale(main_frame, from_=-10, to=10, variable=self.pitch_var, orient=tk.HORIZONTAL, length=300).grid(row=row, column=1, sticky=tk.W, pady=5)
|
||||
ttk.Label(main_frame, textvariable=self.pitch_var).grid(row=row, column=1, sticky=tk.E, pady=5)
|
||||
row += 1
|
||||
|
||||
button_frame = ttk.Frame(main_frame)
|
||||
button_frame.grid(row=row, column=0, columnspan=2, pady=15)
|
||||
|
||||
self.synthesize_btn = ttk.Button(button_frame, text="开始合成", command=self.start_synthesis)
|
||||
self.synthesize_btn.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.play_btn = ttk.Button(button_frame, text="播放最后一个", command=self.play_audio, state=tk.DISABLED)
|
||||
self.play_btn.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.open_folder_btn = ttk.Button(button_frame, text="打开输出目录", command=self.open_output_folder)
|
||||
self.open_folder_btn.pack(side=tk.LEFT, padx=5)
|
||||
row += 1
|
||||
|
||||
ttk.Separator(main_frame, orient='horizontal').grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10)
|
||||
row += 1
|
||||
|
||||
ttk.Label(main_frame, text="日志", font=("Arial", 12, "bold")).grid(row=row, column=0, columnspan=2, sticky=tk.W, pady=(0, 10))
|
||||
row += 1
|
||||
|
||||
self.log_text = tk.Text(main_frame, height=8, width=70, state=tk.DISABLED)
|
||||
self.log_text.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5)
|
||||
|
||||
log_scrollbar = ttk.Scrollbar(main_frame, orient=tk.VERTICAL, command=self.log_text.yview)
|
||||
log_scrollbar.grid(row=row, column=2, sticky=(tk.N, tk.S))
|
||||
self.log_text['yscrollcommand'] = log_scrollbar.set
|
||||
|
||||
main_frame.rowconfigure(row, weight=1)
|
||||
|
||||
def toggle_config(self):
|
||||
self.config_collapsed = not self.config_collapsed
|
||||
if self.config_collapsed:
|
||||
self.config_frame.grid_remove()
|
||||
self.toggle_btn.config(text="▶")
|
||||
else:
|
||||
self.config_frame.grid()
|
||||
self.toggle_btn.config(text="▼")
|
||||
|
||||
def add_text_entry(self):
|
||||
entry_frame = ttk.Frame(self.text_list_container)
|
||||
entry_row = len(self.text_entries)
|
||||
|
||||
ttk.Label(entry_frame, text=f"#{entry_row + 1}").grid(row=0, column=0, sticky=tk.W, padx=2)
|
||||
|
||||
ttk.Label(entry_frame, text="文件名:").grid(row=0, column=1, sticky=tk.W, padx=2)
|
||||
filename_var = tk.StringVar(value=f"text_{entry_row + 1}")
|
||||
filename_entry = ttk.Entry(entry_frame, textvariable=filename_var, width=15)
|
||||
filename_entry.grid(row=0, column=2, sticky=tk.W, padx=2)
|
||||
|
||||
ttk.Label(entry_frame, text="文本:").grid(row=0, column=3, sticky=tk.W, padx=2)
|
||||
text_var = tk.StringVar(value="")
|
||||
text_entry = ttk.Entry(entry_frame, textvariable=text_var, width=35)
|
||||
text_entry.grid(row=0, column=4, sticky=(tk.W, tk.E), padx=2)
|
||||
|
||||
remove_btn = ttk.Button(entry_frame, text="✕", width=3, command=lambda: self.remove_text_entry(entry_frame))
|
||||
remove_btn.grid(row=0, column=5, sticky=tk.W, padx=2)
|
||||
|
||||
entry_frame.columnconfigure(4, weight=1)
|
||||
entry_frame.grid(row=entry_row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=3)
|
||||
|
||||
self.text_entries.append({
|
||||
'frame': entry_frame,
|
||||
'filename_var': filename_var,
|
||||
'text_var': text_var
|
||||
})
|
||||
|
||||
def remove_text_entry(self, frame):
|
||||
if len(self.text_entries) <= 1:
|
||||
messagebox.showwarning("提示", "至少保留一个文本条目")
|
||||
return
|
||||
|
||||
for i, entry in enumerate(self.text_entries):
|
||||
if entry['frame'] == frame:
|
||||
frame.destroy()
|
||||
self.text_entries.pop(i)
|
||||
self.reorder_entries()
|
||||
break
|
||||
|
||||
def reorder_entries(self):
|
||||
for i, entry in enumerate(self.text_entries):
|
||||
entry['frame'].grid(row=i, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=3)
|
||||
for widget in entry['frame'].winfo_children():
|
||||
if isinstance(widget, ttk.Label) and widget.cget("text").startswith("#"):
|
||||
widget.config(text=f"#{i + 1}")
|
||||
break
|
||||
|
||||
def log_message(self, message):
|
||||
self.log_text.config(state=tk.NORMAL)
|
||||
self.log_text.insert(tk.END, message + "\n")
|
||||
self.log_text.see(tk.END)
|
||||
self.log_text.config(state=tk.DISABLED)
|
||||
logger.info(message)
|
||||
|
||||
def start_synthesis(self):
|
||||
if self.synthesis_in_progress:
|
||||
messagebox.showwarning("提示", "合成正在进行中,请稍候...")
|
||||
return
|
||||
|
||||
text_list = []
|
||||
for entry in self.text_entries:
|
||||
text = entry['text_var'].get().strip()
|
||||
filename = entry['filename_var'].get().strip()
|
||||
if text:
|
||||
text_list.append({
|
||||
'text': text,
|
||||
'filename': filename or "output"
|
||||
})
|
||||
|
||||
if not text_list:
|
||||
messagebox.showwarning("提示", "请输入要合成的文本")
|
||||
return
|
||||
|
||||
api_key = self.api_key_var.get().strip()
|
||||
group_id = self.group_id_var.get().strip()
|
||||
if not api_key or not group_id:
|
||||
messagebox.showwarning("提示", "请填写 API Key 和 Group ID")
|
||||
return
|
||||
|
||||
self.synthesis_in_progress = True
|
||||
self.synthesize_btn.config(state=tk.DISABLED)
|
||||
self.play_btn.config(state=tk.DISABLED)
|
||||
|
||||
thread = threading.Thread(target=self.run_batch_synthesis, args=(text_list,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
def run_batch_synthesis(self, text_list):
|
||||
try:
|
||||
output_dir = Path(self.output_dir_var.get())
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.log_message(f"开始批量合成 {len(text_list)} 个文本...")
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
success_count = 0
|
||||
last_file = None
|
||||
|
||||
for i, item in enumerate(text_list):
|
||||
output_file = output_dir / f"{item['filename']}.mp3"
|
||||
self.log_message(f"[{i+1}/{len(text_list)}] 正在合成: {item['text'][:30]}...")
|
||||
|
||||
result = loop.run_until_complete(
|
||||
self.synthesize_speech(
|
||||
text=item['text'],
|
||||
output_file=str(output_file),
|
||||
api_key=self.api_key_var.get(),
|
||||
group_id=self.group_id_var.get(),
|
||||
base_url=self.base_url_var.get(),
|
||||
voice_id=self.voice_id_var.get(),
|
||||
speed=self.speed_var.get(),
|
||||
vol=self.vol_var.get(),
|
||||
pitch=self.pitch_var.get()
|
||||
)
|
||||
)
|
||||
|
||||
if result:
|
||||
success_count += 1
|
||||
last_file = result
|
||||
self.log_message(f" ✓ 成功: {result}")
|
||||
else:
|
||||
self.log_message(f" ✗ 失败")
|
||||
|
||||
loop.close()
|
||||
|
||||
if success_count > 0:
|
||||
self.current_audio_file = last_file
|
||||
self.log_message(f"\n批量合成完成!成功 {success_count}/{len(text_list)} 个")
|
||||
self.root.after(0, lambda: self.play_btn.config(state=tk.NORMAL))
|
||||
self.root.after(0, lambda: messagebox.showinfo(
|
||||
"合成完成",
|
||||
f"批量合成完成!\n成功 {success_count}/{len(text_list)} 个\n输出目录: {output_dir.resolve()}"
|
||||
))
|
||||
else:
|
||||
self.log_message("所有合成都失败了")
|
||||
self.root.after(0, lambda: messagebox.showerror("错误", "所有合成都失败了,请查看日志"))
|
||||
|
||||
except Exception as e:
|
||||
self.log_message(f"批量合成出错: {str(e)}")
|
||||
self.root.after(0, lambda: messagebox.showerror("错误", f"批量合成出错: {str(e)}"))
|
||||
finally:
|
||||
self.synthesis_in_progress = False
|
||||
self.root.after(0, lambda: self.synthesize_btn.config(state=tk.NORMAL))
|
||||
|
||||
async def synthesize_speech(self, text, output_file, api_key, group_id, base_url, voice_id, speed, vol, pitch):
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": "speech-02-turbo",
|
||||
"text": text,
|
||||
"stream": True,
|
||||
"voice_setting": {
|
||||
"voice_id": voice_id,
|
||||
"speed": speed,
|
||||
"vol": vol,
|
||||
"pitch": pitch
|
||||
},
|
||||
"audio_setting": {
|
||||
"sample_rate": 16000,
|
||||
"bitrate": 32000,
|
||||
"format": "mp3",
|
||||
"channel": 1
|
||||
}
|
||||
}
|
||||
|
||||
url = f"{base_url}?GroupId={group_id}"
|
||||
|
||||
try:
|
||||
audio_buffer = bytearray()
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, json=payload, headers=headers) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
self.log_message(f" 请求失败: {response.status}, {error_text}")
|
||||
return None
|
||||
|
||||
async for line in response.content:
|
||||
line = line.strip()
|
||||
if line.startswith(b'data:'):
|
||||
try:
|
||||
json_str = line[5:].decode('utf-8').strip()
|
||||
if not json_str:
|
||||
continue
|
||||
|
||||
data_json = json.loads(json_str)
|
||||
|
||||
if "data" in data_json and "audio" in data_json["data"]:
|
||||
status = data_json["data"].get("status", 1)
|
||||
audio_hex = data_json["data"]["audio"]
|
||||
|
||||
if status == 1:
|
||||
if audio_hex:
|
||||
audio_binary = binascii.unhexlify(audio_hex)
|
||||
audio_buffer.extend(audio_binary)
|
||||
elif status == 2:
|
||||
break
|
||||
except json.JSONDecodeError as e:
|
||||
self.log_message(f" JSON解析失败: {e}")
|
||||
except Exception as e:
|
||||
self.log_message(f" 处理响应出错: {str(e)}")
|
||||
|
||||
if len(audio_buffer) == 0:
|
||||
self.log_message(" 音频缓冲区为空")
|
||||
return None
|
||||
|
||||
async with aiofiles.open(output_file, 'wb') as f:
|
||||
await f.write(audio_buffer)
|
||||
|
||||
return output_file
|
||||
|
||||
except Exception as e:
|
||||
self.log_message(f" 合成失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def play_audio(self):
|
||||
if not self.current_audio_file or not os.path.exists(self.current_audio_file):
|
||||
messagebox.showwarning("提示", "请先合成音频")
|
||||
return
|
||||
|
||||
try:
|
||||
if platform.system() == "Darwin":
|
||||
subprocess.run(["open", self.current_audio_file])
|
||||
elif platform.system() == "Windows":
|
||||
os.startfile(self.current_audio_file)
|
||||
else:
|
||||
subprocess.run(["xdg-open", self.current_audio_file])
|
||||
self.log_message(f"正在播放: {self.current_audio_file}")
|
||||
except Exception as e:
|
||||
messagebox.showerror("错误", f"播放失败: {str(e)}")
|
||||
|
||||
def open_output_folder(self):
|
||||
output_dir = self.output_dir_var.get()
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
if platform.system() == "Darwin":
|
||||
subprocess.run(["open", output_dir])
|
||||
elif platform.system() == "Windows":
|
||||
os.startfile(output_dir)
|
||||
else:
|
||||
subprocess.run(["xdg-open", output_dir])
|
||||
except Exception as e:
|
||||
messagebox.showerror("错误", f"打开目录失败: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
root = tk.Tk()
|
||||
app = MiniMaxTTSGUI(root)
|
||||
root.mainloop()
|
||||
260
talkingq-url/test/minimax_tts_more.py
Normal file
260
talkingq-url/test/minimax_tts_more.py
Normal file
@@ -0,0 +1,260 @@
|
||||
import os
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import aiofiles
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger("minimax_tts_test")
|
||||
|
||||
# 加载环境变量
|
||||
env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "minimax.env")
|
||||
if os.path.exists(env_path):
|
||||
load_dotenv(env_path)
|
||||
logger.info(f"已加载环境变量文件: {env_path}")
|
||||
else:
|
||||
logger.warning(f"环境变量文件不存在: {env_path}")
|
||||
|
||||
async def test_minimax_tts():
|
||||
# 获取配置
|
||||
api_key = os.getenv("MINIMAX_API_KEY", "")
|
||||
group_id = os.getenv("MINIMAX_GROUP_ID", "")
|
||||
base_url = os.getenv("MINIMAX_BASE_URL", "https://api.minimax.chat/v1/t2a_v2")
|
||||
voice_id = os.getenv("MINIMAX_VOICE_ID", "cartoon-boy-01")
|
||||
|
||||
if not api_key or not group_id:
|
||||
logger.error("缺少必要的配置: MINIMAX_API_KEY 或 MINIMAX_GROUP_ID")
|
||||
return
|
||||
|
||||
# 定义中英文短语对应表
|
||||
phrases_dict = {
|
||||
# "upgrading": {
|
||||
# "zh": "升级中,请勿断电",
|
||||
# "en": "Upgrading, please do not power off"
|
||||
# },
|
||||
# "upgrade_success": {
|
||||
# "zh": "升级成功,正在重启设备",
|
||||
# "en": "Upgrade successful, restarting device"
|
||||
# },
|
||||
# "upgrade_failed": {
|
||||
# "zh": "升级失败,正在重启设备",
|
||||
# "en": "Upgrade failed, restarting device"
|
||||
# },
|
||||
# "low_energy": {
|
||||
# "zh": "感觉没能量了,罢工",
|
||||
# "en": "Feeling out of energy, going on strike"
|
||||
# },
|
||||
# "network_connected": {
|
||||
# "zh": "连上网络开始上班了",
|
||||
# "en": "Connected to network, starting work"
|
||||
# },
|
||||
# "network_lost": {
|
||||
# "zh": "没网了,下班了",
|
||||
# "en": "No network, time to get off work"
|
||||
# },
|
||||
# "volume_down": {
|
||||
# "zh": "音量已调小一点",
|
||||
# "en": "Volume turned down a bit"
|
||||
# },
|
||||
# "max_volume": {
|
||||
# "zh": "音量已最大",
|
||||
# "en": "Volume is at maximum"
|
||||
# },
|
||||
# "volume_up": {
|
||||
# "zh": "音量已调大一点",
|
||||
# "en": "Volume turned up a bit"
|
||||
# },
|
||||
# "wakeup": {
|
||||
# "zh": "我起床了",
|
||||
# "en": "I'm awake now"
|
||||
# },
|
||||
# "enter_network_config": {
|
||||
# "zh": "进入配网",
|
||||
# "en": "Entering network configuration"
|
||||
# },
|
||||
# "exit_network_config": {
|
||||
# "zh": "退出配网",
|
||||
# "en": "Exiting network configuration"
|
||||
# }
|
||||
# "error_card":{
|
||||
# "zh": "你好,卡片有误,请使用本人卡片收听留言"
|
||||
# },
|
||||
# "no_message":{
|
||||
# "zh": "你好,已没可读留言"
|
||||
# },
|
||||
# "new_message":{
|
||||
# "zh": "您有新的留言请注意查收"
|
||||
# }
|
||||
# "test":{
|
||||
# "zh": "橙子我们今天去动物园玩吧?"
|
||||
# }
|
||||
# "welcome":{
|
||||
# "zh": "你好,小朋友,想要和你的好朋友说些什么呢?"
|
||||
# }
|
||||
# "tts_error":{
|
||||
# "zh": "没听到你的声音喔,本轮对话结束"
|
||||
# },
|
||||
# "message_ok":{
|
||||
# "zh": "留言已收到"
|
||||
# }
|
||||
# "audio_save_fail":{
|
||||
# "zh": "留言已收到,但是无法发送。"
|
||||
# }
|
||||
# "network_ok":{
|
||||
# "zh": "已连上网络,可以按键或刷卡跟我对话喔。"
|
||||
# },
|
||||
# "network_connect":{
|
||||
# "zh": "系统启动中,正在等待网络连接。"
|
||||
# },
|
||||
# "have_rest":{
|
||||
# "zh": "小憩一下,待会儿见。"
|
||||
# }
|
||||
"bind_nfc_ready":{
|
||||
"zh": "现在开始刷卡绑定设备吧。"
|
||||
},
|
||||
"bind_nfc_finish":{
|
||||
"zh": "卡片绑定成功。"
|
||||
}
|
||||
}
|
||||
|
||||
# 创建输出目录
|
||||
assets_dir = os.getenv("ASSETS_DIR", "assets")
|
||||
output_dir = Path(assets_dir) / (voice_id + "_tts")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(f"开始测试MiniMax TTS服务,音色: {voice_id}")
|
||||
|
||||
# 遍历所有短语进行合成
|
||||
for phrase_key, phrases in phrases_dict.items():
|
||||
for lang, text in phrases.items():
|
||||
await synthesize_speech(
|
||||
text=text,
|
||||
language=lang,
|
||||
file_prefix=f"{phrase_key}_{lang}",
|
||||
output_dir=output_dir,
|
||||
api_key=api_key,
|
||||
group_id=group_id,
|
||||
base_url=base_url,
|
||||
voice_id=voice_id
|
||||
)
|
||||
|
||||
async def synthesize_speech(text, language, file_prefix, output_dir, api_key, group_id, base_url, voice_id):
|
||||
"""合成单个语音文件"""
|
||||
output_file = output_dir / f"{file_prefix}.mp3"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# 语言映射
|
||||
lang_mapping = {
|
||||
"zh": "Chinese",
|
||||
# "en": "English",
|
||||
# "fr": "French",
|
||||
# "de": "German",
|
||||
# "es": "Spanish",
|
||||
# "yue": "Chinese,Yue"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": "speech-02-turbo",
|
||||
"text": text,
|
||||
"stream": True,
|
||||
"voice_setting": {
|
||||
"voice_id": voice_id,
|
||||
"speed": 1.0,
|
||||
"vol": 1.0,
|
||||
"pitch": 0
|
||||
},
|
||||
"audio_setting": {
|
||||
"sample_rate": 16000,
|
||||
"bitrate": 32000,
|
||||
"format": "mp3",
|
||||
"channel": 1
|
||||
},
|
||||
"language_boost": lang_mapping.get(language, "auto")
|
||||
}
|
||||
|
||||
url = f"{base_url}?GroupId={group_id}"
|
||||
|
||||
try:
|
||||
logger.info(f"开始合成语音: [{language}] {text}")
|
||||
|
||||
audio_buffer = bytearray()
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, json=payload, headers=headers) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
logger.error(f"MiniMax TTS请求失败: {response.status}, {error_text}")
|
||||
return None
|
||||
|
||||
logger.info("开始接收MiniMax TTS流式响应")
|
||||
|
||||
line_count = 0
|
||||
async for line in response.content:
|
||||
line_count += 1
|
||||
logger.debug(f"收到第{line_count}行原始数据: {line}")
|
||||
|
||||
line = line.strip() # 去除换行符
|
||||
if line.startswith(b'data:'):
|
||||
try:
|
||||
# 解析JSON数据
|
||||
json_str = line[5:].decode('utf-8').strip()
|
||||
if not json_str:
|
||||
logger.debug("空的JSON字符串,跳过")
|
||||
continue
|
||||
|
||||
data_json = json.loads(json_str)
|
||||
|
||||
if "data" in data_json and "audio" in data_json["data"]:
|
||||
status = data_json["data"].get("status", 1)
|
||||
audio_hex = data_json["data"]["audio"]
|
||||
|
||||
if status == 1: # 只处理status=1(合成中)的音频数据,忽略status=2(合成结束)的汇总数据
|
||||
if audio_hex:
|
||||
try:
|
||||
audio_binary = binascii.unhexlify(audio_hex)
|
||||
audio_buffer.extend(audio_binary)
|
||||
except binascii.Error as e:
|
||||
logger.error(f"音频数据解码失败: {e}")
|
||||
elif status == 2: # 合成结束
|
||||
logger.info("MiniMax TTS流式合成完成")
|
||||
break
|
||||
else:
|
||||
logger.warning(f"响应中没有音频数据: {data_json}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"JSON解析失败: {e}, 原始数据: {line}")
|
||||
except Exception as e:
|
||||
logger.error(f"处理MiniMax TTS流式响应出错: {str(e)}")
|
||||
else:
|
||||
logger.debug(f"非data行,跳过: {line}")
|
||||
|
||||
|
||||
# 检查音频缓冲区大小
|
||||
if len(audio_buffer) == 0:
|
||||
logger.error("音频缓冲区为空,合成可能失败")
|
||||
return None
|
||||
|
||||
logger.info(f"音频合成完成,总大小: {len(audio_buffer)} 字节")
|
||||
|
||||
# 保存音频文件
|
||||
async with aiofiles.open(output_file, 'wb') as f:
|
||||
await f.write(audio_buffer)
|
||||
|
||||
logger.info(f"TTS合成成功! 保存音频到: {output_file}")
|
||||
return str(output_file)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MiniMax TTS合成失败: {str(e)}")
|
||||
return None
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_minimax_tts())
|
||||
Reference in New Issue
Block a user