Files
MoneyPrinterTurbo/app/services/voice.py
2026-06-12 14:59:20 +08:00

2352 lines
67 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import base64
import io
import inspect
import math
import os
import queue
import re
import shutil
import threading
import time
from datetime import datetime
from typing import Union
from xml.sax.saxutils import unescape
import edge_tts
import requests
from edge_tts import SubMaker, submaker
from loguru import logger
from moviepy.video.tools import subtitles
from moviepy.audio.io.AudioFileClip import AudioFileClip
from openai import OpenAI
from app.config import config
from app.utils import utils
_DEFAULT_EDGE_TTS_TIMEOUT_SECONDS = 30.0
_MIMO_DEFAULT_BASE_URL = "https://api.xiaomimimo.com/v1"
_MIMO_DEFAULT_TTS_MODEL = "mimo-v2.5-tts"
def _configure_pydub_ffmpeg(audio_segment_cls):
configured_ffmpeg = os.environ.get("IMAGEIO_FFMPEG_EXE") or shutil.which("ffmpeg")
if not configured_ffmpeg:
try:
import imageio_ffmpeg
configured_ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
except Exception as exc:
logger.warning(f"failed to resolve bundled ffmpeg binary: {str(exc)}")
if configured_ffmpeg:
audio_segment_cls.converter = configured_ffmpeg
def mktimestamp(time_unit: float) -> str:
"""
将 edge_tts 使用的 100 纳秒时间单位转换为字幕时间戳。
edge_tts 7.x 不再导出旧版本里的 `mktimestamp`,但项目里旧字幕链路
还需要这个格式化函数来兼容 Azure v2、Gemini、SiliconFlow 这些
手工构造的字幕时间轴,因此这里内置一个等价实现。
"""
hour = math.floor(time_unit / 10**7 / 3600)
minute = math.floor((time_unit / 10**7 / 60) % 60)
seconds = (time_unit / 10**7) % 60
return f"{hour:02d}:{minute:02d}:{seconds:06.3f}"
def get_siliconflow_voices() -> list[str]:
"""
获取硅基流动的声音列表
Returns:
声音列表,格式为 ["siliconflow:FunAudioLLM/CosyVoice2-0.5B:alex", ...]
"""
# 硅基流动的声音列表和对应的性别(用于显示)
voices_with_gender = [
("FunAudioLLM/CosyVoice2-0.5B", "alex", "Male"),
("FunAudioLLM/CosyVoice2-0.5B", "anna", "Female"),
("FunAudioLLM/CosyVoice2-0.5B", "bella", "Female"),
("FunAudioLLM/CosyVoice2-0.5B", "benjamin", "Male"),
("FunAudioLLM/CosyVoice2-0.5B", "charles", "Male"),
("FunAudioLLM/CosyVoice2-0.5B", "claire", "Female"),
("FunAudioLLM/CosyVoice2-0.5B", "david", "Male"),
("FunAudioLLM/CosyVoice2-0.5B", "diana", "Female"),
]
# 添加siliconflow:前缀,并格式化为显示名称
return [
f"siliconflow:{model}:{voice}-{gender}"
for model, voice, gender in voices_with_gender
]
def get_gemini_voices() -> list[str]:
"""
获取Gemini TTS的声音列表
Returns:
声音列表,格式为 ["gemini:Zephyr-Female", "gemini:Puck-Male", ...]
"""
# Gemini TTS支持的语音列表
voices_with_gender = [
("Zephyr", "Female"),
("Puck", "Male"),
("Charon", "Male"),
("Kore", "Female"),
("Fenrir", "Male"),
("Aoede", "Female"),
("Thalia", "Female"),
("Sage", "Male"),
("Echo", "Female"),
("Harmony", "Female"),
("Lux", "Female"),
("Nova", "Female"),
("Vale", "Male"),
("Orion", "Male"),
("Atlas", "Male"),
]
# 添加gemini:前缀,并格式化为显示名称
return [
f"gemini:{voice}-{gender}"
for voice, gender in voices_with_gender
]
def get_mimo_voices() -> list[str]:
"""
获取 Xiaomi MiMo V2.5 TTS 的预置音色列表。
当前只接入官方文档里的 `mimo-v2.5-tts` 预置音色模式。音色设计
`mimo-v2.5-tts-voicedesign` 和音色复刻 `mimo-v2.5-tts-voiceclone`
需要额外的输入表单和素材上传流程,先不混入普通 TTS 下拉框,避免
用户误以为选择一个 voice id 就能完成所有高级能力。
"""
voices_with_gender = [
("mimo_default", "Female"),
("冰糖", "Female"),
("茉莉", "Female"),
("苏打", "Male"),
("白桦", "Male"),
("Mia", "Female"),
("Chloe", "Female"),
("Milo", "Male"),
("Dean", "Male"),
]
return [f"mimo:{voice}-{gender}" for voice, gender in voices_with_gender]
def get_all_azure_voices(filter_locals=None) -> list[str]:
azure_voices_str = """
Name: af-ZA-AdriNeural
Gender: Female
Name: af-ZA-WillemNeural
Gender: Male
Name: am-ET-AmehaNeural
Gender: Male
Name: am-ET-MekdesNeural
Gender: Female
Name: ar-AE-FatimaNeural
Gender: Female
Name: ar-AE-HamdanNeural
Gender: Male
Name: ar-BH-AliNeural
Gender: Male
Name: ar-BH-LailaNeural
Gender: Female
Name: ar-DZ-AminaNeural
Gender: Female
Name: ar-DZ-IsmaelNeural
Gender: Male
Name: ar-EG-SalmaNeural
Gender: Female
Name: ar-EG-ShakirNeural
Gender: Male
Name: ar-IQ-BasselNeural
Gender: Male
Name: ar-IQ-RanaNeural
Gender: Female
Name: ar-JO-SanaNeural
Gender: Female
Name: ar-JO-TaimNeural
Gender: Male
Name: ar-KW-FahedNeural
Gender: Male
Name: ar-KW-NouraNeural
Gender: Female
Name: ar-LB-LaylaNeural
Gender: Female
Name: ar-LB-RamiNeural
Gender: Male
Name: ar-LY-ImanNeural
Gender: Female
Name: ar-LY-OmarNeural
Gender: Male
Name: ar-MA-JamalNeural
Gender: Male
Name: ar-MA-MounaNeural
Gender: Female
Name: ar-OM-AbdullahNeural
Gender: Male
Name: ar-OM-AyshaNeural
Gender: Female
Name: ar-QA-AmalNeural
Gender: Female
Name: ar-QA-MoazNeural
Gender: Male
Name: ar-SA-HamedNeural
Gender: Male
Name: ar-SA-ZariyahNeural
Gender: Female
Name: ar-SY-AmanyNeural
Gender: Female
Name: ar-SY-LaithNeural
Gender: Male
Name: ar-TN-HediNeural
Gender: Male
Name: ar-TN-ReemNeural
Gender: Female
Name: ar-YE-MaryamNeural
Gender: Female
Name: ar-YE-SalehNeural
Gender: Male
Name: az-AZ-BabekNeural
Gender: Male
Name: az-AZ-BanuNeural
Gender: Female
Name: bg-BG-BorislavNeural
Gender: Male
Name: bg-BG-KalinaNeural
Gender: Female
Name: bn-BD-NabanitaNeural
Gender: Female
Name: bn-BD-PradeepNeural
Gender: Male
Name: bn-IN-BashkarNeural
Gender: Male
Name: bn-IN-TanishaaNeural
Gender: Female
Name: bs-BA-GoranNeural
Gender: Male
Name: bs-BA-VesnaNeural
Gender: Female
Name: ca-ES-EnricNeural
Gender: Male
Name: ca-ES-JoanaNeural
Gender: Female
Name: cs-CZ-AntoninNeural
Gender: Male
Name: cs-CZ-VlastaNeural
Gender: Female
Name: cy-GB-AledNeural
Gender: Male
Name: cy-GB-NiaNeural
Gender: Female
Name: da-DK-ChristelNeural
Gender: Female
Name: da-DK-JeppeNeural
Gender: Male
Name: de-AT-IngridNeural
Gender: Female
Name: de-AT-JonasNeural
Gender: Male
Name: de-CH-JanNeural
Gender: Male
Name: de-CH-LeniNeural
Gender: Female
Name: de-DE-AmalaNeural
Gender: Female
Name: de-DE-ConradNeural
Gender: Male
Name: de-DE-FlorianMultilingualNeural
Gender: Male
Name: de-DE-KatjaNeural
Gender: Female
Name: de-DE-KillianNeural
Gender: Male
Name: de-DE-SeraphinaMultilingualNeural
Gender: Female
Name: el-GR-AthinaNeural
Gender: Female
Name: el-GR-NestorasNeural
Gender: Male
Name: en-AU-NatashaNeural
Gender: Female
Name: en-AU-WilliamNeural
Gender: Male
Name: en-CA-ClaraNeural
Gender: Female
Name: en-CA-LiamNeural
Gender: Male
Name: en-GB-LibbyNeural
Gender: Female
Name: en-GB-MaisieNeural
Gender: Female
Name: en-GB-RyanNeural
Gender: Male
Name: en-GB-SoniaNeural
Gender: Female
Name: en-GB-ThomasNeural
Gender: Male
Name: en-HK-SamNeural
Gender: Male
Name: en-HK-YanNeural
Gender: Female
Name: en-IE-ConnorNeural
Gender: Male
Name: en-IE-EmilyNeural
Gender: Female
Name: en-IN-NeerjaExpressiveNeural
Gender: Female
Name: en-IN-NeerjaNeural
Gender: Female
Name: en-IN-PrabhatNeural
Gender: Male
Name: en-KE-AsiliaNeural
Gender: Female
Name: en-KE-ChilembaNeural
Gender: Male
Name: en-NG-AbeoNeural
Gender: Male
Name: en-NG-EzinneNeural
Gender: Female
Name: en-NZ-MitchellNeural
Gender: Male
Name: en-NZ-MollyNeural
Gender: Female
Name: en-PH-JamesNeural
Gender: Male
Name: en-PH-RosaNeural
Gender: Female
Name: en-SG-LunaNeural
Gender: Female
Name: en-SG-WayneNeural
Gender: Male
Name: en-TZ-ElimuNeural
Gender: Male
Name: en-TZ-ImaniNeural
Gender: Female
Name: en-US-AnaNeural
Gender: Female
Name: en-US-AndrewMultilingualNeural
Gender: Male
Name: en-US-AndrewNeural
Gender: Male
Name: en-US-AriaNeural
Gender: Female
Name: en-US-AvaMultilingualNeural
Gender: Female
Name: en-US-AvaNeural
Gender: Female
Name: en-US-BrianMultilingualNeural
Gender: Male
Name: en-US-BrianNeural
Gender: Male
Name: en-US-ChristopherNeural
Gender: Male
Name: en-US-EmmaMultilingualNeural
Gender: Female
Name: en-US-EmmaNeural
Gender: Female
Name: en-US-EricNeural
Gender: Male
Name: en-US-GuyNeural
Gender: Male
Name: en-US-JennyNeural
Gender: Female
Name: en-US-MichelleNeural
Gender: Female
Name: en-US-RogerNeural
Gender: Male
Name: en-US-SteffanNeural
Gender: Male
Name: en-ZA-LeahNeural
Gender: Female
Name: en-ZA-LukeNeural
Gender: Male
Name: es-AR-ElenaNeural
Gender: Female
Name: es-AR-TomasNeural
Gender: Male
Name: es-BO-MarceloNeural
Gender: Male
Name: es-BO-SofiaNeural
Gender: Female
Name: es-CL-CatalinaNeural
Gender: Female
Name: es-CL-LorenzoNeural
Gender: Male
Name: es-CO-GonzaloNeural
Gender: Male
Name: es-CO-SalomeNeural
Gender: Female
Name: es-CR-JuanNeural
Gender: Male
Name: es-CR-MariaNeural
Gender: Female
Name: es-CU-BelkysNeural
Gender: Female
Name: es-CU-ManuelNeural
Gender: Male
Name: es-DO-EmilioNeural
Gender: Male
Name: es-DO-RamonaNeural
Gender: Female
Name: es-EC-AndreaNeural
Gender: Female
Name: es-EC-LuisNeural
Gender: Male
Name: es-ES-AlvaroNeural
Gender: Male
Name: es-ES-ElviraNeural
Gender: Female
Name: es-ES-XimenaNeural
Gender: Female
Name: es-GQ-JavierNeural
Gender: Male
Name: es-GQ-TeresaNeural
Gender: Female
Name: es-GT-AndresNeural
Gender: Male
Name: es-GT-MartaNeural
Gender: Female
Name: es-HN-CarlosNeural
Gender: Male
Name: es-HN-KarlaNeural
Gender: Female
Name: es-MX-DaliaNeural
Gender: Female
Name: es-MX-JorgeNeural
Gender: Male
Name: es-NI-FedericoNeural
Gender: Male
Name: es-NI-YolandaNeural
Gender: Female
Name: es-PA-MargaritaNeural
Gender: Female
Name: es-PA-RobertoNeural
Gender: Male
Name: es-PE-AlexNeural
Gender: Male
Name: es-PE-CamilaNeural
Gender: Female
Name: es-PR-KarinaNeural
Gender: Female
Name: es-PR-VictorNeural
Gender: Male
Name: es-PY-MarioNeural
Gender: Male
Name: es-PY-TaniaNeural
Gender: Female
Name: es-SV-LorenaNeural
Gender: Female
Name: es-SV-RodrigoNeural
Gender: Male
Name: es-US-AlonsoNeural
Gender: Male
Name: es-US-PalomaNeural
Gender: Female
Name: es-UY-MateoNeural
Gender: Male
Name: es-UY-ValentinaNeural
Gender: Female
Name: es-VE-PaolaNeural
Gender: Female
Name: es-VE-SebastianNeural
Gender: Male
Name: et-EE-AnuNeural
Gender: Female
Name: et-EE-KertNeural
Gender: Male
Name: fa-IR-DilaraNeural
Gender: Female
Name: fa-IR-FaridNeural
Gender: Male
Name: fi-FI-HarriNeural
Gender: Male
Name: fi-FI-NooraNeural
Gender: Female
Name: fil-PH-AngeloNeural
Gender: Male
Name: fil-PH-BlessicaNeural
Gender: Female
Name: fr-BE-CharlineNeural
Gender: Female
Name: fr-BE-GerardNeural
Gender: Male
Name: fr-CA-AntoineNeural
Gender: Male
Name: fr-CA-JeanNeural
Gender: Male
Name: fr-CA-SylvieNeural
Gender: Female
Name: fr-CA-ThierryNeural
Gender: Male
Name: fr-CH-ArianeNeural
Gender: Female
Name: fr-CH-FabriceNeural
Gender: Male
Name: fr-FR-DeniseNeural
Gender: Female
Name: fr-FR-EloiseNeural
Gender: Female
Name: fr-FR-HenriNeural
Gender: Male
Name: fr-FR-RemyMultilingualNeural
Gender: Male
Name: fr-FR-VivienneMultilingualNeural
Gender: Female
Name: ga-IE-ColmNeural
Gender: Male
Name: ga-IE-OrlaNeural
Gender: Female
Name: gl-ES-RoiNeural
Gender: Male
Name: gl-ES-SabelaNeural
Gender: Female
Name: gu-IN-DhwaniNeural
Gender: Female
Name: gu-IN-NiranjanNeural
Gender: Male
Name: he-IL-AvriNeural
Gender: Male
Name: he-IL-HilaNeural
Gender: Female
Name: hi-IN-MadhurNeural
Gender: Male
Name: hi-IN-SwaraNeural
Gender: Female
Name: hr-HR-GabrijelaNeural
Gender: Female
Name: hr-HR-SreckoNeural
Gender: Male
Name: hu-HU-NoemiNeural
Gender: Female
Name: hu-HU-TamasNeural
Gender: Male
Name: id-ID-ArdiNeural
Gender: Male
Name: id-ID-GadisNeural
Gender: Female
Name: is-IS-GudrunNeural
Gender: Female
Name: is-IS-GunnarNeural
Gender: Male
Name: it-IT-DiegoNeural
Gender: Male
Name: it-IT-ElsaNeural
Gender: Female
Name: it-IT-GiuseppeMultilingualNeural
Gender: Male
Name: it-IT-IsabellaNeural
Gender: Female
Name: iu-Cans-CA-SiqiniqNeural
Gender: Female
Name: iu-Cans-CA-TaqqiqNeural
Gender: Male
Name: iu-Latn-CA-SiqiniqNeural
Gender: Female
Name: iu-Latn-CA-TaqqiqNeural
Gender: Male
Name: ja-JP-KeitaNeural
Gender: Male
Name: ja-JP-NanamiNeural
Gender: Female
Name: jv-ID-DimasNeural
Gender: Male
Name: jv-ID-SitiNeural
Gender: Female
Name: ka-GE-EkaNeural
Gender: Female
Name: ka-GE-GiorgiNeural
Gender: Male
Name: kk-KZ-AigulNeural
Gender: Female
Name: kk-KZ-DauletNeural
Gender: Male
Name: km-KH-PisethNeural
Gender: Male
Name: km-KH-SreymomNeural
Gender: Female
Name: kn-IN-GaganNeural
Gender: Male
Name: kn-IN-SapnaNeural
Gender: Female
Name: ko-KR-HyunsuMultilingualNeural
Gender: Male
Name: ko-KR-InJoonNeural
Gender: Male
Name: ko-KR-SunHiNeural
Gender: Female
Name: lo-LA-ChanthavongNeural
Gender: Male
Name: lo-LA-KeomanyNeural
Gender: Female
Name: lt-LT-LeonasNeural
Gender: Male
Name: lt-LT-OnaNeural
Gender: Female
Name: lv-LV-EveritaNeural
Gender: Female
Name: lv-LV-NilsNeural
Gender: Male
Name: mk-MK-AleksandarNeural
Gender: Male
Name: mk-MK-MarijaNeural
Gender: Female
Name: ml-IN-MidhunNeural
Gender: Male
Name: ml-IN-SobhanaNeural
Gender: Female
Name: mn-MN-BataaNeural
Gender: Male
Name: mn-MN-YesuiNeural
Gender: Female
Name: mr-IN-AarohiNeural
Gender: Female
Name: mr-IN-ManoharNeural
Gender: Male
Name: ms-MY-OsmanNeural
Gender: Male
Name: ms-MY-YasminNeural
Gender: Female
Name: mt-MT-GraceNeural
Gender: Female
Name: mt-MT-JosephNeural
Gender: Male
Name: my-MM-NilarNeural
Gender: Female
Name: my-MM-ThihaNeural
Gender: Male
Name: nb-NO-FinnNeural
Gender: Male
Name: nb-NO-PernilleNeural
Gender: Female
Name: ne-NP-HemkalaNeural
Gender: Female
Name: ne-NP-SagarNeural
Gender: Male
Name: nl-BE-ArnaudNeural
Gender: Male
Name: nl-BE-DenaNeural
Gender: Female
Name: nl-NL-ColetteNeural
Gender: Female
Name: nl-NL-FennaNeural
Gender: Female
Name: nl-NL-MaartenNeural
Gender: Male
Name: pl-PL-MarekNeural
Gender: Male
Name: pl-PL-ZofiaNeural
Gender: Female
Name: ps-AF-GulNawazNeural
Gender: Male
Name: ps-AF-LatifaNeural
Gender: Female
Name: pt-BR-AntonioNeural
Gender: Male
Name: pt-BR-FranciscaNeural
Gender: Female
Name: pt-BR-ThalitaMultilingualNeural
Gender: Female
Name: pt-PT-DuarteNeural
Gender: Male
Name: pt-PT-RaquelNeural
Gender: Female
Name: ro-RO-AlinaNeural
Gender: Female
Name: ro-RO-EmilNeural
Gender: Male
Name: ru-RU-DmitryNeural
Gender: Male
Name: ru-RU-SvetlanaNeural
Gender: Female
Name: si-LK-SameeraNeural
Gender: Male
Name: si-LK-ThiliniNeural
Gender: Female
Name: sk-SK-LukasNeural
Gender: Male
Name: sk-SK-ViktoriaNeural
Gender: Female
Name: sl-SI-PetraNeural
Gender: Female
Name: sl-SI-RokNeural
Gender: Male
Name: so-SO-MuuseNeural
Gender: Male
Name: so-SO-UbaxNeural
Gender: Female
Name: sq-AL-AnilaNeural
Gender: Female
Name: sq-AL-IlirNeural
Gender: Male
Name: sr-RS-NicholasNeural
Gender: Male
Name: sr-RS-SophieNeural
Gender: Female
Name: su-ID-JajangNeural
Gender: Male
Name: su-ID-TutiNeural
Gender: Female
Name: sv-SE-MattiasNeural
Gender: Male
Name: sv-SE-SofieNeural
Gender: Female
Name: sw-KE-RafikiNeural
Gender: Male
Name: sw-KE-ZuriNeural
Gender: Female
Name: sw-TZ-DaudiNeural
Gender: Male
Name: sw-TZ-RehemaNeural
Gender: Female
Name: ta-IN-PallaviNeural
Gender: Female
Name: ta-IN-ValluvarNeural
Gender: Male
Name: ta-LK-KumarNeural
Gender: Male
Name: ta-LK-SaranyaNeural
Gender: Female
Name: ta-MY-KaniNeural
Gender: Female
Name: ta-MY-SuryaNeural
Gender: Male
Name: ta-SG-AnbuNeural
Gender: Male
Name: ta-SG-VenbaNeural
Gender: Female
Name: te-IN-MohanNeural
Gender: Male
Name: te-IN-ShrutiNeural
Gender: Female
Name: th-TH-NiwatNeural
Gender: Male
Name: th-TH-PremwadeeNeural
Gender: Female
Name: tr-TR-AhmetNeural
Gender: Male
Name: tr-TR-EmelNeural
Gender: Female
Name: uk-UA-OstapNeural
Gender: Male
Name: uk-UA-PolinaNeural
Gender: Female
Name: ur-IN-GulNeural
Gender: Female
Name: ur-IN-SalmanNeural
Gender: Male
Name: ur-PK-AsadNeural
Gender: Male
Name: ur-PK-UzmaNeural
Gender: Female
Name: uz-UZ-MadinaNeural
Gender: Female
Name: uz-UZ-SardorNeural
Gender: Male
Name: vi-VN-HoaiMyNeural
Gender: Female
Name: vi-VN-NamMinhNeural
Gender: Male
Name: zh-CN-XiaoxiaoNeural
Gender: Female
Name: zh-CN-XiaoyiNeural
Gender: Female
Name: zh-CN-YunjianNeural
Gender: Male
Name: zh-CN-YunxiNeural
Gender: Male
Name: zh-CN-YunxiaNeural
Gender: Male
Name: zh-CN-YunyangNeural
Gender: Male
Name: zh-CN-liaoning-XiaobeiNeural
Gender: Female
Name: zh-CN-shaanxi-XiaoniNeural
Gender: Female
Name: zh-HK-HiuGaaiNeural
Gender: Female
Name: zh-HK-HiuMaanNeural
Gender: Female
Name: zh-HK-WanLungNeural
Gender: Male
Name: zh-TW-HsiaoChenNeural
Gender: Female
Name: zh-TW-HsiaoYuNeural
Gender: Female
Name: zh-TW-YunJheNeural
Gender: Male
Name: zu-ZA-ThandoNeural
Gender: Female
Name: zu-ZA-ThembaNeural
Gender: Male
Name: en-US-AvaMultilingualNeural-V2
Gender: Female
Name: en-US-AndrewMultilingualNeural-V2
Gender: Male
Name: en-US-EmmaMultilingualNeural-V2
Gender: Female
Name: en-US-BrianMultilingualNeural-V2
Gender: Male
Name: de-DE-FlorianMultilingualNeural-V2
Gender: Male
Name: de-DE-SeraphinaMultilingualNeural-V2
Gender: Female
Name: fr-FR-RemyMultilingualNeural-V2
Gender: Male
Name: fr-FR-VivienneMultilingualNeural-V2
Gender: Female
Name: zh-CN-XiaoxiaoMultilingualNeural-V2
Gender: Female
""".strip()
voices = []
# 定义正则表达式模式,用于匹配 Name 和 Gender 行
pattern = re.compile(r"Name:\s*(.+)\s*Gender:\s*(.+)\s*", re.MULTILINE)
# 使用正则表达式查找所有匹配项
matches = pattern.findall(azure_voices_str)
for name, gender in matches:
# 应用过滤条件
if filter_locals and any(
name.lower().startswith(fl.lower()) for fl in filter_locals
):
voices.append(f"{name}-{gender}")
elif not filter_locals:
voices.append(f"{name}-{gender}")
voices.sort()
return voices
def parse_voice_name(name: str):
# zh-CN-XiaoyiNeural-Female
# zh-CN-YunxiNeural-Male
# zh-CN-XiaoxiaoMultilingualNeural-V2-Female
name = name.replace("-Female", "").replace("-Male", "").strip()
return name
def is_azure_v2_voice(voice_name: str):
voice_name = parse_voice_name(voice_name)
if voice_name.endswith("-V2"):
return voice_name.replace("-V2", "").strip()
return ""
def is_siliconflow_voice(voice_name: str):
"""检查是否是硅基流动的声音"""
return voice_name.startswith("siliconflow:")
def is_gemini_voice(voice_name: str):
"""检查是否是Gemini TTS的声音"""
return voice_name.startswith("gemini:")
def is_mimo_voice(voice_name: str):
"""检查是否是 Xiaomi MiMo TTS 的声音"""
return voice_name.startswith("mimo:")
def tts(
text: str,
voice_name: str,
voice_rate: float,
voice_file: str,
voice_volume: float = 1.0,
) -> Union[SubMaker, None]:
if is_azure_v2_voice(voice_name):
return azure_tts_v2(text, voice_name, voice_file)
elif is_siliconflow_voice(voice_name):
# 从voice_name中提取模型和声音
# 格式: siliconflow:model:voice-Gender
parts = voice_name.split(":")
if len(parts) >= 3:
model = parts[1]
# 移除性别后缀,例如 "alex-Male" -> "alex"
voice_with_gender = parts[2]
voice = voice_with_gender.split("-")[0]
# 构建完整的voice参数格式为 "model:voice"
full_voice = f"{model}:{voice}"
return siliconflow_tts(
text, model, full_voice, voice_rate, voice_file, voice_volume
)
else:
logger.error(f"Invalid siliconflow voice name format: {voice_name}")
return None
elif is_gemini_voice(voice_name):
# 从voice_name中提取声音名称
# 格式: gemini:voice-Gender
parts = voice_name.split(":")
if len(parts) >= 2:
# 移除性别后缀,例如 "Zephyr-Female" -> "Zephyr"
voice_with_gender = parts[1]
voice = voice_with_gender.split("-")[0]
return gemini_tts(text, voice, voice_rate, voice_file, voice_volume)
else:
logger.error(f"Invalid gemini voice name format: {voice_name}")
return None
elif is_mimo_voice(voice_name):
# 从voice_name中提取声音名称
# 格式: mimo:voice-Gender如果调用方已执行 parse_voice_name
# 则可能是 mimo:voice。两种格式都兼容。
parts = voice_name.split(":")
if len(parts) >= 2:
voice_with_gender = parts[1]
voice = voice_with_gender.split("-")[0]
return mimo_tts(text, voice, voice_rate, voice_file, voice_volume)
else:
logger.error(f"Invalid mimo voice name format: {voice_name}")
return None
return azure_tts_v1(text, voice_name, voice_rate, voice_file)
def convert_rate_to_percent(rate: float) -> str:
# edge-tts requires a sign-prefixed percentage (e.g. "+0%", "-20%").
# Rounding can yield 0 for rates near but not equal to 1.0 (e.g. 1.004,
# 0.997); those must still be returned as "+0%", not the unsigned "0%"
# which edge-tts rejects with ValueError: Invalid rate '0%'.
percent = round((rate - 1.0) * 100)
if percent >= 0:
return f"+{percent}%"
return f"{percent}%"
def ensure_file_path_exists(file_path: str) -> None:
"""
确保输出文件所在目录一定存在。
这里单独做一层兜底,是因为 edge_tts 7.x 在真正发起网络请求之前,
就会先打开目标音频文件;如果目录不存在,会直接因为本地文件路径报错,
从而掩盖真正的 TTS 行为结果。
"""
dir_path = os.path.dirname(file_path)
if dir_path:
os.makedirs(dir_path, exist_ok=True)
def ensure_legacy_submaker_fields(sub_maker: SubMaker) -> SubMaker:
"""
为项目里仍然沿用旧字幕结构的调用方补齐兼容字段。
edge_tts 7.x 的 `SubMaker` 主要暴露 `cues/get_srt()`,但项目里 Azure v2、
Gemini、SiliconFlow 这些路径仍然会直接读写 `subs/offset`。这里统一补齐,
避免升级 edge_tts 后这些非 edge 路径被连带破坏。
"""
if not hasattr(sub_maker, "subs"):
sub_maker.subs = []
if not hasattr(sub_maker, "offset"):
sub_maker.offset = []
return sub_maker
def populate_legacy_submaker_with_full_text(
sub_maker: SubMaker, text: str, audio_duration_seconds: float
) -> SubMaker:
"""
用整段文本填充项目历史沿用的 `subs/offset` 字幕结构。
背景:
1. edge_tts 7.x 的 `SubMaker` 不再提供旧版本里的 `create_sub()`
2. 项目里 Gemini、SiliconFlow 等非 edge 路径依然需要返回一个
带 `subs/offset` 的对象,供后续统一计算音频时长和生成字幕;
3. 对于拿不到逐词边界的 TTS 服务,需要至少按脚本断句切成多个片段,
这样后续 `subtitle_provider=edge` 的聚合逻辑才能继续工作,而不是
因为整段文本无法和脚本断句逐行匹配而回退 Whisper。
Args:
sub_maker: 需要写入兼容字段的字幕对象
text: 原始脚本文本
audio_duration_seconds: 音频总时长,单位秒
Returns:
已填充兼容字幕数据的 SubMaker 对象
"""
sub_maker = ensure_legacy_submaker_fields(sub_maker)
# 清空旧值,避免调用方重复复用对象时出现脏数据叠加。
sub_maker.subs = []
sub_maker.offset = []
normalized_text = (text or "").strip()
if not normalized_text:
return sub_maker
audio_duration_100ns = max(int(audio_duration_seconds * 10000000), 1)
# Gemini / SiliconFlow 这类路径拿不到逐词边界时,仍然尽量沿用项目
# 原来的“按标点断句 + 按字符数比例分配时长”的策略。这样既能让
# create_subtitle() 匹配脚本断句,也能避免再次回退 Whisper。
sentences = utils.split_string_by_punctuations(normalized_text)
if not sentences:
sentences = [normalized_text]
total_chars = sum(len(sentence) for sentence in sentences)
if total_chars <= 0:
sub_maker.subs.append(normalized_text)
sub_maker.offset.append((0, audio_duration_100ns))
return sub_maker
current_offset = 0
for index, sentence in enumerate(sentences):
cleaned_sentence = sentence.strip()
if not cleaned_sentence:
continue
# 前面的句子按字符数比例分配时长,最后一句兜底吃掉剩余时长,
# 避免整数取整导致总时长丢失或字幕结束时间短于音频。
if index == len(sentences) - 1:
sentence_end = audio_duration_100ns
else:
sentence_chars = len(cleaned_sentence)
sentence_duration = max(
int(audio_duration_100ns * (sentence_chars / total_chars)),
1,
)
sentence_end = min(current_offset + sentence_duration, audio_duration_100ns)
sub_maker.subs.append(cleaned_sentence)
sub_maker.offset.append((current_offset, sentence_end))
current_offset = sentence_end
return sub_maker
def create_edge_tts_communicate(
text: str, voice_name: str, rate_str: str
) -> edge_tts.Communicate:
"""
按当前已安装的 edge_tts 版本构造 Communicate 对象。
背景:
1. 主线代码已经升级到 edge_tts 7.x并使用 `boundary` 参数拿到更细的边界事件;
2. 但 Windows 便携包如果更新失败,现场环境可能仍然停留在旧版 edge_tts
3. 旧版 `Communicate.__init__()` 不接受 `boundary`,会直接抛出
`unexpected keyword argument 'boundary'`,导致整个 TTS 链路失败。
因此这里先根据构造函数签名探测当前版本支持的参数,再决定是否传入
`boundary`,让同一份代码同时兼容旧版和新版依赖。
"""
communicate_kwargs = {"rate": rate_str}
communicate_signature = inspect.signature(edge_tts.Communicate)
if "boundary" in communicate_signature.parameters:
communicate_kwargs["boundary"] = "WordBoundary"
return edge_tts.Communicate(text, voice_name, **communicate_kwargs)
def get_edge_tts_timeout_seconds() -> Union[float, None]:
"""
获取 Azure TTS V1 单次流式请求的超时时间。
背景:
Edge consumer TTS 在网络不通、服务端限流、voice 与文本语言不匹配等场景下,
可能长时间卡在 `stream_sync()` 内部,日志只停留在 `start`。这里提供一个
默认超时,避免 WebUI 任务长期无反馈。
使用方式:
- 默认 30 秒,覆盖常见短视频脚本的首包等待时间;
- 如用户处于慢网络或代理环境,可在 `config.toml` 里设置
`edge_tts_timeout = 60`
- 设置为 0 或负数表示显式禁用超时,保留完全向后兼容。
"""
raw_timeout = config.app.get(
"edge_tts_timeout", _DEFAULT_EDGE_TTS_TIMEOUT_SECONDS
)
try:
timeout_seconds = float(raw_timeout)
except (TypeError, ValueError):
logger.warning(
"invalid edge_tts_timeout: "
f"{raw_timeout}, fallback to {_DEFAULT_EDGE_TTS_TIMEOUT_SECONDS}s"
)
timeout_seconds = _DEFAULT_EDGE_TTS_TIMEOUT_SECONDS
if timeout_seconds <= 0:
return None
return timeout_seconds
def _stream_edge_tts_sync_with_timeout(
communicate, on_chunk, timeout_seconds: float
) -> None:
"""
带总超时地消费 edge_tts 7.x 的同步流。
实现原因:
`stream_sync()` 本身是阻塞迭代器,网络层卡住时主线程无法及时恢复。
这里把阻塞迭代放到 daemon 线程中,主线程通过 Queue 获取 chunk
到达超时时间后直接抛出 TimeoutError让外层重试和错误日志继续工作。
注意:
daemon 线程只作为兜底保护使用,最多随 Azure TTS V1 的 3 次重试产生
少量残留线程;进程退出时会自动回收。相比 WebUI 任务永久卡住,这是
更可控的失败模式。
"""
stream_queue = queue.Queue()
done_marker = object()
def _produce_chunks():
try:
for chunk in communicate.stream_sync():
stream_queue.put(("chunk", chunk))
stream_queue.put(("done", done_marker))
except Exception as e:
stream_queue.put(("error", e))
thread = threading.Thread(target=_produce_chunks, daemon=True)
thread.start()
deadline = time.monotonic() + timeout_seconds
while True:
remaining_seconds = deadline - time.monotonic()
if remaining_seconds <= 0:
raise TimeoutError(
f"edge_tts stream timed out after {timeout_seconds:g}s"
)
try:
item_type, payload = stream_queue.get(
timeout=min(0.5, remaining_seconds)
)
except queue.Empty:
continue
if item_type == "chunk":
on_chunk(payload)
elif item_type == "error":
raise payload
elif item_type == "done":
return
def stream_edge_tts_chunks(
communicate, on_chunk, timeout_seconds: Union[float, None] = None
) -> None:
"""
统一消费 edge_tts 的同步流和旧版异步流。
edge_tts 7.x 提供 `stream_sync()`,可以在同步函数里直接迭代;
更早的版本通常只有异步 `stream()`。为了让 `azure_tts_v1()` 在
旧依赖残留场景下仍能继续工作,这里统一做一层流式兼容。
Args:
communicate: edge_tts.Communicate 实例
on_chunk: 每拿到一个事件块时执行的回调
timeout_seconds: 单次流式请求总超时;为 None 时不启用超时。
"""
if hasattr(communicate, "stream_sync"):
if timeout_seconds:
_stream_edge_tts_sync_with_timeout(
communicate, on_chunk, timeout_seconds
)
return
for chunk in communicate.stream_sync():
on_chunk(chunk)
return
if not hasattr(communicate, "stream"):
raise AttributeError("edge_tts communicate object has no stream method")
async def _consume_async_stream():
async for chunk in communicate.stream():
on_chunk(chunk)
# 这里显式创建独立事件循环,而不是复用外部上下文,目的是避免
# 在同步调用栈里遇到“当前线程没有事件循环”或跨线程复用循环的问题。
loop = asyncio.new_event_loop()
try:
if timeout_seconds:
loop.run_until_complete(
asyncio.wait_for(_consume_async_stream(), timeout=timeout_seconds)
)
else:
loop.run_until_complete(_consume_async_stream())
finally:
loop.close()
def azure_tts_v1(
text: str, voice_name: str, voice_rate: float, voice_file: str
) -> Union[SubMaker, None]:
voice_name = parse_voice_name(voice_name)
text = text.strip()
rate_str = convert_rate_to_percent(voice_rate)
for i in range(3):
try:
logger.info(f"start, voice name: {voice_name}, try: {i + 1}")
# 这里同时兼容 edge_tts 7.x 和旧版便携包里可能残留的老依赖:
# 1. 新版支持 `boundary` + `stream_sync()`
# 2. 旧版不支持 `boundary`,且通常只暴露异步 `stream()`
ensure_file_path_exists(voice_file)
communicate = create_edge_tts_communicate(text, voice_name, rate_str)
sub_maker = edge_tts.SubMaker()
timeout_seconds = get_edge_tts_timeout_seconds()
with open(voice_file, "wb") as file:
def _handle_chunk(chunk):
chunk_type = chunk["type"]
if chunk_type == "audio":
file.write(chunk["data"])
elif chunk_type in ["WordBoundary", "SentenceBoundary"]:
# 无论来自 7.x 的同步流,还是旧版异步流,只要事件结构
# 里仍有边界信息,就统一喂给 SubMaker保证后续字幕链路
# 仍然走项目现有逻辑。
sub_maker.feed(chunk)
stream_edge_tts_chunks(
communicate, _handle_chunk, timeout_seconds=timeout_seconds
)
if not sub_maker.get_srt():
logger.warning("failed, sub_maker.get_srt() is empty")
continue
logger.info(f"completed, output file: {voice_file}")
return sub_maker
except Exception as e:
logger.error(f"failed, error: {str(e)}")
# TTS 流式写入如果在首包前超时或网络异常,会留下 0 字节音频文件。
# 这种文件既不可播放,也可能误导后续排查,因此失败后只清理空文件;
# 如果已经写入了部分数据,则保留现场文件,便于分析服务端返回内容。
if os.path.exists(voice_file) and os.path.getsize(voice_file) == 0:
try:
os.remove(voice_file)
except Exception as remove_error:
logger.warning(
"failed to remove empty tts file: "
f"{voice_file}, error: {str(remove_error)}"
)
return None
def siliconflow_tts(
text: str,
model: str,
voice: str,
voice_rate: float,
voice_file: str,
voice_volume: float = 1.0,
) -> Union[SubMaker, None]:
"""
使用硅基流动的API生成语音
Args:
text: 要转换为语音的文本
model: 模型名称,如 "FunAudioLLM/CosyVoice2-0.5B"
voice: 声音名称,如 "FunAudioLLM/CosyVoice2-0.5B:alex"
voice_rate: 语音速度,范围[0.25, 4.0]
voice_file: 输出的音频文件路径
voice_volume: 语音音量,范围[0.6, 5.0],需要转换为硅基流动的增益范围[-10, 10]
Returns:
SubMaker对象或None
"""
text = text.strip()
api_key = config.siliconflow.get("api_key", "")
if not api_key:
logger.error("SiliconFlow API key is not set")
return None
# 将voice_volume转换为硅基流动的增益范围
# 默认voice_volume为1.0对应gain为0
gain = voice_volume - 1.0
# 确保gain在[-10, 10]范围内
gain = max(-10, min(10, gain))
url = "https://api.siliconflow.cn/v1/audio/speech"
payload = {
"model": model,
"input": text,
"voice": voice,
"response_format": "mp3",
"sample_rate": 32000,
"stream": False,
"speed": voice_rate,
"gain": gain,
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
for i in range(3): # 尝试3次
try:
logger.info(
f"start siliconflow tts, model: {model}, voice: {voice}, try: {i + 1}"
)
response = requests.post(url, json=payload, headers=headers)
if response.status_code == 200:
# 保存音频文件
with open(voice_file, "wb") as f:
f.write(response.content)
# 这里仍然沿用项目原有的字幕结构,因此需要补齐旧字段。
sub_maker = ensure_legacy_submaker_fields(SubMaker())
# 获取音频文件的实际长度
try:
# 尝试使用moviepy获取音频长度
from moviepy import AudioFileClip
audio_clip = AudioFileClip(voice_file)
audio_duration = audio_clip.duration
audio_clip.close()
# 将音频长度转换为100纳秒单位与edge_tts兼容
audio_duration_100ns = int(audio_duration * 10000000)
# 使用文本分割来创建更准确的字幕
# 将文本按标点符号分割成句子
sentences = utils.split_string_by_punctuations(text)
if sentences:
# 计算每个句子的大致时长(按字符数比例分配)
total_chars = sum(len(s) for s in sentences)
char_duration = (
audio_duration_100ns / total_chars if total_chars > 0 else 0
)
current_offset = 0
for sentence in sentences:
if not sentence.strip():
continue
# 计算当前句子的时长
sentence_chars = len(sentence)
sentence_duration = int(sentence_chars * char_duration)
# 添加到SubMaker
sub_maker.subs.append(sentence)
sub_maker.offset.append(
(current_offset, current_offset + sentence_duration)
)
# 更新偏移量
current_offset += sentence_duration
else:
# 如果无法分割,则使用整个文本作为一个字幕
sub_maker.subs = [text]
sub_maker.offset = [(0, audio_duration_100ns)]
except Exception as e:
logger.warning(f"Failed to create accurate subtitles: {str(e)}")
# 回退到简单的字幕
sub_maker.subs = [text]
# 使用音频文件的实际长度如果无法获取则假设为10秒
sub_maker.offset = [
(
0,
audio_duration_100ns
if "audio_duration_100ns" in locals()
else 10000000,
)
]
logger.success(f"siliconflow tts succeeded: {voice_file}")
logger.debug(
"siliconflow subtitle timeline generated, "
f"subs: {len(sub_maker.subs)}, offsets: {len(sub_maker.offset)}"
)
return sub_maker
else:
logger.error(
f"siliconflow tts failed with status code {response.status_code}: {response.text}"
)
except Exception as e:
logger.error(f"siliconflow tts failed: {str(e)}")
return None
def azure_tts_v2(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]:
voice_name = is_azure_v2_voice(voice_name)
if not voice_name:
logger.error(f"invalid voice name: {voice_name}")
raise ValueError(f"invalid voice name: {voice_name}")
text = text.strip()
def _format_duration_to_offset(duration) -> int:
if isinstance(duration, str):
time_obj = datetime.strptime(duration, "%H:%M:%S.%f")
milliseconds = (
(time_obj.hour * 3600000)
+ (time_obj.minute * 60000)
+ (time_obj.second * 1000)
+ (time_obj.microsecond // 1000)
)
return milliseconds * 10000
if isinstance(duration, int):
return duration
return 0
for i in range(3):
try:
logger.info(f"start, voice name: {voice_name}, try: {i + 1}")
import azure.cognitiveservices.speech as speechsdk
sub_maker = ensure_legacy_submaker_fields(SubMaker())
def speech_synthesizer_word_boundary_cb(evt: speechsdk.SessionEventArgs):
# print('WordBoundary event:')
# print('\tBoundaryType: {}'.format(evt.boundary_type))
# print('\tAudioOffset: {}ms'.format((evt.audio_offset + 5000)))
# print('\tDuration: {}'.format(evt.duration))
# print('\tText: {}'.format(evt.text))
# print('\tTextOffset: {}'.format(evt.text_offset))
# print('\tWordLength: {}'.format(evt.word_length))
duration = _format_duration_to_offset(str(evt.duration))
offset = _format_duration_to_offset(evt.audio_offset)
sub_maker.subs.append(evt.text)
sub_maker.offset.append((offset, offset + duration))
# Creates an instance of a speech config with specified subscription key and service region.
speech_key = config.azure.get("speech_key", "")
service_region = config.azure.get("speech_region", "")
if not speech_key or not service_region:
logger.error("Azure speech key or region is not set")
return None
audio_config = speechsdk.audio.AudioOutputConfig(
filename=voice_file, use_default_speaker=True
)
speech_config = speechsdk.SpeechConfig(
subscription=speech_key, region=service_region
)
speech_config.speech_synthesis_voice_name = voice_name
# speech_config.set_property(property_id=speechsdk.PropertyId.SpeechServiceResponse_RequestSentenceBoundary,
# value='true')
speech_config.set_property(
property_id=speechsdk.PropertyId.SpeechServiceResponse_RequestWordBoundary,
value="true",
)
speech_config.set_speech_synthesis_output_format(
speechsdk.SpeechSynthesisOutputFormat.Audio48Khz192KBitRateMonoMp3
)
speech_synthesizer = speechsdk.SpeechSynthesizer(
audio_config=audio_config, speech_config=speech_config
)
speech_synthesizer.synthesis_word_boundary.connect(
speech_synthesizer_word_boundary_cb
)
result = speech_synthesizer.speak_text_async(text).get()
if result.reason == speechsdk.ResultReason.SynthesizingAudioCompleted:
logger.success(f"azure v2 speech synthesis succeeded: {voice_file}")
return sub_maker
elif result.reason == speechsdk.ResultReason.Canceled:
cancellation_details = result.cancellation_details
logger.error(
f"azure v2 speech synthesis canceled: {cancellation_details.reason}"
)
if cancellation_details.reason == speechsdk.CancellationReason.Error:
logger.error(
f"azure v2 speech synthesis error: {cancellation_details.error_details}"
)
logger.info(f"completed, output file: {voice_file}")
except Exception as e:
logger.error(f"failed, error: {str(e)}")
return None
def gemini_tts(
text: str,
voice_name: str,
voice_rate: float,
voice_file: str,
voice_volume: float = 1.0,
) -> Union[SubMaker, None]:
"""
使用Google Gemini TTS生成语音
Args:
text: 要转换的文本
voice_name: 语音名称,如 "Zephyr", "Puck"
voice_rate: 语音速率(当前未使用)
voice_file: 输出音频文件路径
voice_volume: 音频音量(当前未使用)
Returns:
SubMaker对象或None
"""
import base64
import json
import io
from pydub import AudioSegment
import google.generativeai as genai
_configure_pydub_ffmpeg(AudioSegment)
try:
# 配置Gemini API
api_key = config.app.get("gemini_api_key", "")
if not api_key:
logger.error("Gemini API key is not set")
return None
genai.configure(api_key=api_key)
logger.info(f"start, voice name: {voice_name}, try: 1")
# 使用Gemini TTS API
model = genai.GenerativeModel("gemini-2.5-flash-preview-tts")
generation_config = {
"response_modalities": ["AUDIO"],
"speech_config": {
"voice_config": {
"prebuilt_voice_config": {
"voice_name": voice_name
}
}
}
}
response = model.generate_content(
contents=text,
generation_config=generation_config
)
# 检查响应
if not response.candidates or not response.candidates[0].content:
logger.error("No audio content received from Gemini TTS")
return None
# 获取音频数据
audio_data = None
for part in response.candidates[0].content.parts:
if hasattr(part, 'inline_data') and part.inline_data:
audio_data = part.inline_data.data
break
if not audio_data:
logger.error("No audio data found in response")
return None
# 音频数据已经是原始字节不需要base64解码
if isinstance(audio_data, str):
# 如果是字符串则需要base64解码
audio_bytes = base64.b64decode(audio_data)
else:
# 如果已经是字节,直接使用
audio_bytes = audio_data
# 尝试不同的音频格式 - Gemini可能返回不同的格式
audio_segment = None
# Gemini返回Linear PCM格式按照文档参数解析
try:
audio_segment = AudioSegment.from_file(
io.BytesIO(audio_bytes),
format="raw",
frame_rate=24000, # Gemini TTS默认采样率
channels=1, # 单声道
sample_width=2 # 16-bit
)
except Exception as e:
logger.error(f"Failed to load PCM audio: {e}")
return None
# 导出为MP3格式
audio_segment.export(voice_file, format="mp3")
logger.info(f"completed, output file: {voice_file}")
# Gemini 拿不到 edge_tts 那种逐词边界事件,因此这里退回到
# 项目原有的 `subs/offset` 兼容结构,至少保证后续字幕与时长
# 计算链路可继续工作。
sub_maker = ensure_legacy_submaker_fields(SubMaker())
audio_duration = len(audio_segment) / 1000.0 # 转换为秒
return populate_legacy_submaker_with_full_text(
sub_maker=sub_maker,
text=text,
audio_duration_seconds=audio_duration,
)
except ImportError as e:
logger.error(f"Missing required package for Gemini TTS: {str(e)}. Please install: pip install pydub")
return None
except Exception as e:
logger.error(f"Gemini TTS failed, error: {str(e)}")
return None
def mimo_tts(
text: str,
voice_name: str,
voice_rate: float,
voice_file: str,
voice_volume: float = 1.0,
) -> Union[SubMaker, None]:
"""
使用 Xiaomi MiMo V2.5 TTS 生成语音。
官方接口兼容 OpenAI Chat Completions但 TTS 有两个关键差异:
1. 待合成文本必须放在 `assistant` 消息里;
2. 音频以 `message.audio.data` 的 base64 字符串返回。
MiMo 当前没有返回逐词时间轴,因此这里复用项目已有的 legacy
SubMaker 兜底方案:根据最终音频时长和脚本文本断句生成字幕时间轴。
"""
from pydub import AudioSegment
text = (text or "").strip()
if not text:
logger.error("MiMo TTS text is empty")
return None
api_key = config.app.get("mimo_api_key", "")
if not api_key:
logger.error("MiMo API key is not set")
return None
base_url = config.app.get("mimo_base_url", "") or _MIMO_DEFAULT_BASE_URL
model_name = config.app.get("mimo_tts_model_name", "") or _MIMO_DEFAULT_TTS_MODEL
style_prompt = config.app.get(
"mimo_tts_style_prompt",
"请用自然、清晰、适合短视频旁白的语气朗读。",
)
_configure_pydub_ffmpeg(AudioSegment)
for i in range(3):
try:
logger.info(
f"start mimo tts, model: {model_name}, voice: {voice_name}, try: {i + 1}"
)
ensure_file_path_exists(voice_file)
client = OpenAI(api_key=api_key, base_url=base_url)
completion = client.chat.completions.create(
model=model_name,
messages=[
{"role": "user", "content": style_prompt},
{"role": "assistant", "content": text},
],
audio={
"format": "wav",
"voice": voice_name,
},
)
if not completion or not getattr(completion, "choices", None):
raise ValueError("MiMo TTS returned empty response")
message = completion.choices[0].message
audio = getattr(message, "audio", None)
audio_data = None
if isinstance(audio, dict):
audio_data = audio.get("data")
elif audio is not None:
audio_data = getattr(audio, "data", None)
if not audio_data:
raise ValueError("MiMo TTS returned empty audio data")
audio_bytes = base64.b64decode(audio_data)
audio_segment = AudioSegment.from_file(io.BytesIO(audio_bytes), format="wav")
output_format = utils.parse_extension(voice_file) or "mp3"
if output_format == "wav":
with open(voice_file, "wb") as f:
f.write(audio_bytes)
else:
audio_segment.export(voice_file, format=output_format)
audio_duration = len(audio_segment) / 1000.0
sub_maker = ensure_legacy_submaker_fields(SubMaker())
logger.success(f"mimo tts succeeded: {voice_file}")
logger.debug(
"mimo subtitle timeline generated, "
f"duration: {audio_duration:.3f}s, output_format: {output_format}"
)
return populate_legacy_submaker_with_full_text(
sub_maker=sub_maker,
text=text,
audio_duration_seconds=audio_duration,
)
except Exception as e:
logger.error(f"mimo tts failed: {str(e)}")
return None
def _format_text(text: str) -> str:
# text = text.replace("\n", " ")
text = text.replace("[", " ")
text = text.replace("]", " ")
text = text.replace("(", " ")
text = text.replace(")", " ")
text = text.replace("{", " ")
text = text.replace("}", " ")
text = text.strip()
return text
def _build_subtitle_formatter():
"""
返回统一的 SRT 行格式化函数。
这里单独拆成一个小工具,是为了让 edge_tts 7.x 的 cues 路径
和项目原有的 legacy `subs/offset` 路径共用同一套字幕落盘格式,
避免两套逻辑各自产生细微格式差异。
"""
def formatter(idx: int, start_time: float, end_time: float, sub_text: str) -> str:
start_t = mktimestamp(start_time).replace(".", ",")
end_t = mktimestamp(end_time).replace(".", ",")
return f"{idx}\n{start_t} --> {end_t}\n{sub_text}\n"
return formatter
def _match_script_line(script_lines: list[str], current_text: str, sub_index: int) -> str:
"""
尝试把当前累计的字幕文本,与脚本中的某一条标准断句匹配起来。
这里复用了项目原有的“按标点拆脚本,再逐段比对”的思路:
1. 优先精确匹配;
2. 再做一次去常规标点后的匹配;
3. 最后做一次更激进的非单词字符清洗匹配。
这样可以兼容:
- TTS 返回里可能缺失或单独拆分的标点;
- 中文场景下词边界和脚本文本不完全一一对应的情况。
"""
if len(script_lines) <= sub_index:
return ""
target_line = script_lines[sub_index]
if current_text == target_line:
return target_line.strip()
current_text_normalized = re.sub(r"[^\w\s]", "", current_text)
target_line_normalized = re.sub(r"[^\w\s]", "", target_line)
if current_text_normalized == target_line_normalized:
return target_line.strip()
current_text_normalized = re.sub(r"\W+", "", current_text)
target_line_normalized = re.sub(r"\W+", "", target_line)
if current_text_normalized == target_line_normalized:
return target_line.strip()
return ""
def _write_subtitle_items(sub_items: list[str], subtitle_file: str) -> bool:
"""
将已经聚合好的字幕段写入到 SRT 文件,并做一次基本可读性验证。
返回值:
- `True`:字幕文件成功落盘且可被 moviepy 解析;
- `False`:字幕文件写入或解析失败。
"""
try:
ensure_file_path_exists(subtitle_file)
with open(subtitle_file, "w", encoding="utf-8") as file:
file.write("\n".join(sub_items) + "\n")
sbs = subtitles.file_to_subtitles(subtitle_file, encoding="utf-8")
duration = max([tb for ((ta, tb), txt) in sbs]) if sbs else 0
logger.info(
f"completed, subtitle file created: {subtitle_file}, duration: {duration}"
)
return True
except Exception as e:
logger.error(f"failed, error: {str(e)}")
if os.path.exists(subtitle_file):
os.remove(subtitle_file)
return False
def _build_subtitle_items_from_edge_cues(
sub_maker: SubMaker, script_lines: list[str]
) -> list[str]:
"""
将 edge_tts 7.x 的细粒度 `cues` 聚合为按脚本断句的 SRT 片段。
背景:
edge_tts 7.x 的 `SubMaker.get_srt()` 更偏向逐词/逐短语的时间轴。
对英文做逐词高亮尚可,但中文短视频字幕如果直接照搬,会出现
“金钱 / 是 / 一种 / 社会 / 工具” 这种阅读体验很差的效果。
实现策略:
1. 逐个消费 cues 中的 `content`
2. 累积成一段候选文本;
3. 当候选文本与脚本里当前目标断句匹配时,收敛为一个完整字幕段;
4. 使用第一条 cue 的开始时间和最后一条 cue 的结束时间,保证时间轴连续。
"""
formatter = _build_subtitle_formatter()
sub_items = []
sub_index = 0
current_text = ""
current_start_time = None
for cue in sub_maker.cues:
cue_text = unescape(cue.content)
if current_start_time is None:
current_start_time = int(cue.start.total_seconds() * 10000000)
current_end_time = int(cue.end.total_seconds() * 10000000)
current_text += cue_text
matched_text = _match_script_line(script_lines, current_text, sub_index)
if not matched_text:
continue
sub_index += 1
sub_items.append(
formatter(
idx=sub_index,
start_time=current_start_time,
end_time=current_end_time,
sub_text=matched_text,
)
)
current_text = ""
current_start_time = None
if current_text.strip():
logger.warning(
f"edge cues still have unmatched text after aggregation: {current_text}"
)
return sub_items
def _build_subtitle_items_from_legacy_submaker(
sub_maker: SubMaker, script_lines: list[str]
) -> list[str]:
"""
将项目原有 `subs/offset` 结构聚合为按脚本断句的 SRT 片段。
这部分保留了原来的核心思路,只是拆成独立函数,便于与 edge_tts 7.x
的 cues 聚合逻辑共享同一套断句匹配与落盘流程。
"""
formatter = _build_subtitle_formatter()
start_time = -1.0
sub_items = []
sub_index = 0
sub_line = ""
legacy_offsets = getattr(sub_maker, "offset", [])
legacy_subs = getattr(sub_maker, "subs", [])
for _, (offset, sub) in enumerate(zip(legacy_offsets, legacy_subs)):
current_start_time, current_end_time = offset
if start_time < 0:
start_time = current_start_time
sub_line += unescape(sub)
matched_text = _match_script_line(script_lines, sub_line, sub_index)
if not matched_text:
continue
sub_index += 1
sub_items.append(
formatter(
idx=sub_index,
start_time=start_time,
end_time=current_end_time,
sub_text=matched_text,
)
)
start_time = -1.0
sub_line = ""
if sub_line.strip():
logger.warning(
f"legacy subtitle items still have unmatched text after aggregation: {sub_line}"
)
return sub_items
def create_subtitle(sub_maker: SubMaker, text: str, subtitle_file: str):
"""
优化字幕文件
1. 将字幕文件按照标点符号分割成多行
2. 逐行匹配字幕文件中的文本
3. 生成新的字幕文件
"""
text = _format_text(text)
script_lines = utils.split_string_by_punctuations(text)
try:
if hasattr(sub_maker, "cues") and sub_maker.cues:
sub_items = _build_subtitle_items_from_edge_cues(sub_maker, script_lines)
else:
sub_items = _build_subtitle_items_from_legacy_submaker(
sub_maker, script_lines
)
if len(sub_items) != len(script_lines):
logger.warning(
f"failed, sub_items len: {len(sub_items)}, script_lines len: {len(script_lines)}"
)
return
_write_subtitle_items(sub_items, subtitle_file)
except Exception as e:
logger.error(f"failed, error: {str(e)}")
def _get_audio_duration_from_submaker(sub_maker: SubMaker):
"""
获取音频时长
"""
# 优先兼容 edge_tts 7.x 的 cues 结构;
# 如果是项目里其他 TTS 手工填充的旧结构,则继续读取 offset。
if hasattr(sub_maker, "cues") and sub_maker.cues:
return sub_maker.cues[-1].end.total_seconds()
legacy_offsets = getattr(sub_maker, "offset", [])
if not legacy_offsets:
return 0.0
return legacy_offsets[-1][1] / 10000000
def _get_audio_duration_from_mp3(mp3_file: str) -> float:
"""
获取MP3音频时长
"""
if not os.path.exists(mp3_file):
logger.error(f"MP3 file does not exist: {mp3_file}")
return 0.0
try:
# Use moviepy to get the duration of the MP3 file
with AudioFileClip(mp3_file) as audio:
return audio.duration # Duration in seconds
except Exception as e:
logger.error(f"Failed to get audio duration from MP3: {str(e)}")
return 0.0
def get_audio_duration(target: Union[str, SubMaker]) -> float:
"""
获取音频时长
如果是SubMaker对象则从SubMaker中获取时长
如果是MP3文件则从MP3文件中获取时长
"""
if isinstance(target, SubMaker):
return _get_audio_duration_from_submaker(target)
elif isinstance(target, str) and target.endswith(".mp3"):
return _get_audio_duration_from_mp3(target)
else:
logger.error(f"Invalid target type: {type(target)}")
return 0.0
if __name__ == "__main__":
voice_name = "zh-CN-XiaoxiaoMultilingualNeural-V2-Female"
voice_name = parse_voice_name(voice_name)
voice_name = is_azure_v2_voice(voice_name)
print(voice_name)
voices = get_all_azure_voices()
print(len(voices))
async def _do():
temp_dir = utils.storage_dir("temp")
voice_names = [
"zh-CN-XiaoxiaoMultilingualNeural",
# 女性
"zh-CN-XiaoxiaoNeural",
"zh-CN-XiaoyiNeural",
# 男性
"zh-CN-YunyangNeural",
"zh-CN-YunxiNeural",
]
text = """
静夜思是唐代诗人李白创作的一首五言古诗。这首诗描绘了诗人在寂静的夜晚,看到窗前的明月,不禁想起远方的家乡和亲人,表达了他对家乡和亲人的深深思念之情。全诗内容是:“床前明月光,疑是地上霜。举头望明月,低头思故乡。”在这短短的四句诗中,诗人通过“明月”和“思故乡”的意象,巧妙地表达了离乡背井人的孤独与哀愁。首句“床前明月光”设景立意,通过明亮的月光引出诗人的遐想;“疑是地上霜”增添了夜晚的寒冷感,加深了诗人的孤寂之情;“举头望明月”和“低头思故乡”则是情感的升华,展现了诗人内心深处的乡愁和对家的渴望。这首诗简洁明快,情感真挚,是中国古典诗歌中非常著名的一首,也深受后人喜爱和推崇。
"""
text = """
What is the meaning of life? This question has puzzled philosophers, scientists, and thinkers of all kinds for centuries. Throughout history, various cultures and individuals have come up with their interpretations and beliefs around the purpose of life. Some say it's to seek happiness and self-fulfillment, while others believe it's about contributing to the welfare of others and making a positive impact in the world. Despite the myriad of perspectives, one thing remains clear: the meaning of life is a deeply personal concept that varies from one person to another. It's an existential inquiry that encourages us to reflect on our values, desires, and the essence of our existence.
"""
text = """
预计未来3天深圳冷空气活动频繁未来两天持续阴天有小雨出门带好雨具
10-11日持续阴天有小雨日温差小气温在13-17℃之间体感阴凉
12日天气短暂好转早晚清凉
"""
text = "[Opening scene: A sunny day in a suburban neighborhood. A young boy named Alex, around 8 years old, is playing in his front yard with his loyal dog, Buddy.]\n\n[Camera zooms in on Alex as he throws a ball for Buddy to fetch. Buddy excitedly runs after it and brings it back to Alex.]\n\nAlex: Good boy, Buddy! You're the best dog ever!\n\n[Buddy barks happily and wags his tail.]\n\n[As Alex and Buddy continue playing, a series of potential dangers loom nearby, such as a stray dog approaching, a ball rolling towards the street, and a suspicious-looking stranger walking by.]\n\nAlex: Uh oh, Buddy, look out!\n\n[Buddy senses the danger and immediately springs into action. He barks loudly at the stray dog, scaring it away. Then, he rushes to retrieve the ball before it reaches the street and gently nudges it back towards Alex. Finally, he stands protectively between Alex and the stranger, growling softly to warn them away.]\n\nAlex: Wow, Buddy, you're like my superhero!\n\n[Just as Alex and Buddy are about to head inside, they hear a loud crash from a nearby construction site. They rush over to investigate and find a pile of rubble blocking the path of a kitten trapped underneath.]\n\nAlex: Oh no, Buddy, we have to help!\n\n[Buddy barks in agreement and together they work to carefully move the rubble aside, allowing the kitten to escape unharmed. The kitten gratefully nuzzles against Buddy, who responds with a friendly lick.]\n\nAlex: We did it, Buddy! We saved the day again!\n\n[As Alex and Buddy walk home together, the sun begins to set, casting a warm glow over the neighborhood.]\n\nAlex: Thanks for always being there to watch over me, Buddy. You're not just my dog, you're my best friend.\n\n[Buddy barks happily and nuzzles against Alex as they disappear into the sunset, ready to face whatever adventures tomorrow may bring.]\n\n[End scene.]"
text = "大家好,我是乔哥,一个想帮你把信用卡全部还清的家伙!\n今天我们要聊的是信用卡的取现功能。\n你是不是也曾经因为一时的资金紧张而拿着信用卡到ATM机取现如果是那你得好好看看这个视频了。\n现在都2024年了我以为现在不会再有人用信用卡取现功能了。前几天一个粉丝发来一张图片取现1万。\n信用卡取现有三个弊端。\n信用卡取现功能代价可不小。会先收取一个取现手续费比如这个粉丝取现1万按2.5%收取手续费收取了250元。\n信用卡正常消费有最长56天的免息期但取现不享受免息期。从取现那一天开始每天按照万5收取利息这个粉丝用了11天收取了55元利息。\n三,频繁的取现行为,银行会认为你资金紧张,会被标记为高风险用户,影响你的综合评分和额度。\n那么,如果你资金紧张了,该怎么办呢?\n乔哥给你支一招用破思机摩擦信用卡只需要少量的手续费而且还可以享受最长56天的免息期。\n最后,如果你对玩卡感兴趣,可以找乔哥领取一本《卡神秘籍》,用卡过程中遇到任何疑惑,也欢迎找乔哥交流。\n别忘了关注乔哥回复用卡技巧免费领取《2024用卡技巧》让我们一起成为用卡高手"
text = """
2023全年业绩速览
公司全年累计实现营业收入1476.94亿元同比增长19.01%归母净利润747.34亿元同比增长19.16%。EPS达到59.49元。第四季度单季营业收入444.25亿元同比增长20.26%环比增长31.86%归母净利润218.58亿元同比增长19.33%环比增长29.37%。这一阶段
的业绩表现不仅突显了公司的增长动力和盈利能力,也反映出公司在竞争激烈的市场环境中保持了良好的发展势头。
2023年Q4业绩速览
第四季度营业收入贡献主要增长点销售费用高增致盈利能力承压税金同比上升27%,扰动净利率表现。
业绩解读
利润方面2023全年贵州茅台>归母净利润增速为19%其中营业收入正贡献18%,营业成本正贡献百分之一,管理费用正贡献百分之一点四。(注:归母净利润增速值=营业收入增速+各科目贡献,展示贡献/拖累的前四名科目,且要求贡献值/净利润增速>15%)
"""
text = "静夜思是唐代诗人李白创作的一首五言古诗。这首诗描绘了诗人在寂静的夜晚,看到窗前的明月,不禁想起远方的家乡和亲人"
text = _format_text(text)
lines = utils.split_string_by_punctuations(text)
print(lines)
for voice_name in voice_names:
voice_file = f"{temp_dir}/tts-{voice_name}.mp3"
subtitle_file = f"{temp_dir}/tts.mp3.srt"
sub_maker = azure_tts_v2(
text=text, voice_name=voice_name, voice_file=voice_file
)
create_subtitle(sub_maker=sub_maker, text=text, subtitle_file=subtitle_file)
audio_duration = get_audio_duration(sub_maker)
print(f"voice: {voice_name}, audio duration: {audio_duration}s")
loop = asyncio.get_event_loop_policy().get_event_loop()
try:
loop.run_until_complete(_do())
finally:
loop.close()