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

488 lines
20 KiB
Python
Raw Permalink 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 unittest
import os
import sys
import tempfile
import time
from datetime import timedelta
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
# add project root to python path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from app.utils import utils
from app.services import voice as vs
from app.services import task as task_service
from pydub import AudioSegment
temp_dir = utils.storage_dir("temp")
text_en = """
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_zh = """
预计未来3天深圳冷空气活动频繁未来两天持续阴天有小雨出门带好雨具
10-11日持续阴天有小雨日温差小气温在13-17℃之间体感阴凉
12日天气短暂好转早晚清凉
"""
voice_rate=1.0
voice_volume=1.0
class TestVoiceService(unittest.TestCase):
def setUp(self):
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
def tearDown(self):
self.loop.close()
def test_siliconflow(self):
# SiliconFlow 的 API Key 存在 [siliconflow].api_key 中,运行时代码也是从
# config.siliconflow 读取;这里必须使用同一配置源,避免正确配置凭据时
# 测试仍然被误跳过。
if not vs.config.siliconflow.get("api_key"):
self.skipTest("siliconflow_api_key is not configured")
voice_name = "siliconflow:FunAudioLLM/CosyVoice2-0.5B:alex-Male"
voice_name = vs.parse_voice_name(voice_name)
async def _do():
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}"
voice_file = f"{temp_dir}/tts-siliconflow-{voice}.mp3"
subtitle_file = f"{temp_dir}/tts-siliconflow-{voice}.srt"
sub_maker = vs.siliconflow_tts(
text=text_zh, model=model, voice=full_voice, voice_file=voice_file, voice_rate=voice_rate, voice_volume=voice_volume
)
if not sub_maker:
self.fail("siliconflow tts failed")
vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file)
audio_duration = vs.get_audio_duration(sub_maker)
print(f"voice: {voice_name}, audio duration: {audio_duration}s")
else:
self.fail("siliconflow invalid voice name")
self.loop.run_until_complete(_do())
def test_azure_tts_v1(self):
voice_name = "zh-CN-XiaoyiNeural-Female"
voice_name = vs.parse_voice_name(voice_name)
print(voice_name)
voice_file = f"{temp_dir}/tts-azure-v1-{voice_name}.mp3"
subtitle_file = f"{temp_dir}/tts-azure-v1-{voice_name}.srt"
sub_maker = vs.azure_tts_v1(
text=text_zh, voice_name=voice_name, voice_file=voice_file, voice_rate=voice_rate
)
if not sub_maker:
self.fail("azure tts v1 failed")
vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file)
audio_duration = vs.get_audio_duration(sub_maker)
print(f"voice: {voice_name}, audio duration: {audio_duration}s")
def test_azure_tts_v1_supports_legacy_edge_tts_without_boundary(self):
"""
验证 Azure TTS V1 在旧版 edge_tts 依赖残留时仍可继续工作。
这个回归场景对应 Windows 便携包更新失败后,现场环境还停留在旧版
edge_tts 的情况:
1. `Communicate.__init__()` 不接受 `boundary`
2. 只有异步 `stream()`,没有 `stream_sync()`
"""
class _LegacyCommunicate:
def __init__(self, text, voice, rate="+0%"):
self.text = text
self.voice = voice
self.rate = rate
async def stream(self):
yield {"type": "audio", "data": b"legacy-audio"}
yield {
"type": "WordBoundary",
"offset": 0,
"duration": 10000000,
"text": "legacy",
}
class _FakeSubMaker:
def __init__(self):
self.events = []
def feed(self, chunk):
self.events.append(chunk)
def get_srt(self):
if not self.events:
return ""
return "1\n00:00:00,000 --> 00:00:01,000\nlegacy\n"
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
vs.edge_tts, "Communicate", _LegacyCommunicate
), patch.object(vs.edge_tts, "SubMaker", _FakeSubMaker):
voice_file = str(Path(tmp_dir) / "legacy-edge-tts.mp3")
sub_maker = vs.azure_tts_v1(
text="legacy edge tts compatibility",
voice_name="zh-CN-XiaoyiNeural-Female",
voice_file=voice_file,
voice_rate=1.0,
)
self.assertIsNotNone(sub_maker)
self.assertEqual(Path(voice_file).read_bytes(), b"legacy-audio")
self.assertEqual(len(sub_maker.events), 1)
self.assertEqual(sub_maker.events[0]["type"], "WordBoundary")
def test_azure_tts_v1_times_out_hanging_stream_sync(self):
"""
验证 Azure TTS V1 在 edge_tts 同步流卡住时能够快速失败。
真实现场里网络异常、服务端限流、voice 语言与文本不匹配时,
`stream_sync()` 可能长时间不返回,导致 WebUI 任务只停在
`start, voice name...`。这里用阻塞的 fake stream 复现该场景,
确认超时保护会让函数结束并返回 None。
"""
class _HangingCommunicate:
def __init__(self, text, voice, rate="+0%", boundary=None):
self.text = text
self.voice = voice
self.rate = rate
self.boundary = boundary
def stream_sync(self):
time.sleep(10)
yield {"type": "audio", "data": b"unreachable"}
class _FakeSubMaker:
def feed(self, chunk):
return None
def get_srt(self):
return ""
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
vs.edge_tts, "Communicate", _HangingCommunicate
), patch.object(vs.edge_tts, "SubMaker", _FakeSubMaker), patch.object(
vs.config,
"app",
dict(vs.config.app, edge_tts_timeout=0.05),
):
voice_file = Path(tmp_dir) / "hanging-edge-tts.mp3"
started_at = time.monotonic()
sub_maker = vs.azure_tts_v1(
text="帮我生成一个花开花落的视频",
voice_name="en-AU-NatashaNeural-Female",
voice_file=str(voice_file),
voice_rate=1.0,
)
elapsed = time.monotonic() - started_at
self.assertFalse(voice_file.exists())
self.assertIsNone(sub_maker)
self.assertLess(elapsed, 2)
def test_azure_tts_v2(self):
if not vs.config.azure.get("speech_key") or not vs.config.azure.get("speech_region"):
self.skipTest("Azure speech key or region is not configured")
voice_name = "zh-CN-XiaoxiaoMultilingualNeural-V2-Female"
voice_name = vs.parse_voice_name(voice_name)
print(voice_name)
async def _do():
voice_file = f"{temp_dir}/tts-azure-v2-{voice_name}.mp3"
subtitle_file = f"{temp_dir}/tts-azure-v2-{voice_name}.srt"
sub_maker = vs.azure_tts_v2(
text=text_zh, voice_name=voice_name, voice_file=voice_file
)
if not sub_maker:
self.fail("azure tts v2 failed")
vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file)
audio_duration = vs.get_audio_duration(sub_maker)
print(f"voice: {voice_name}, audio duration: {audio_duration}s")
self.loop.run_until_complete(_do())
def test_gemini_tts_uses_legacy_submaker_fields(self):
"""
验证 Gemini TTS 在 edge_tts 7.x 环境下仍会返回项目兼容的字幕结构,
并且可以被 `subtitle_provider=edge` 的字幕生成链路直接消费,
避免再次回退 Whisper。
"""
class _InlineData:
def __init__(self, data):
self.data = data
class _Part:
def __init__(self, data):
self.inline_data = _InlineData(data)
class _Content:
def __init__(self, data):
self.parts = [_Part(data)]
class _Candidate:
def __init__(self, data):
self.content = _Content(data)
class _Response:
def __init__(self, data):
self.candidates = [_Candidate(data)]
class _FakeModel:
def __init__(self, name):
self.name = name
def generate_content(self, contents, generation_config):
tone = (
AudioSegment.silent(duration=1800)
.set_frame_rate(24000)
.set_channels(1)
.set_sample_width(2)
)
return _Response(tone.raw_data)
voice_file = f"{temp_dir}/tts-gemini-Zephyr.mp3"
subtitle_file = f"{temp_dir}/tts-gemini-Zephyr.srt"
text = "Gemini subtitle generation should work now. Testing multiple lines."
with patch("google.generativeai.configure"), patch(
"google.generativeai.GenerativeModel", _FakeModel
), patch.object(vs.config, "app", dict(vs.config.app, gemini_api_key="test-key")):
sub_maker = vs.gemini_tts(
text=text,
voice_name="Zephyr",
voice_rate=1.0,
voice_file=voice_file,
)
self.assertIsNotNone(sub_maker)
self.assertEqual(
getattr(sub_maker, "subs", []),
["Gemini subtitle generation should work now", "Testing multiple lines"],
)
self.assertEqual(len(getattr(sub_maker, "offset", [])), 2)
self.assertEqual(sub_maker.offset[0][0], 0)
self.assertLess(sub_maker.offset[0][1], sub_maker.offset[1][1])
vs.create_subtitle(sub_maker=sub_maker, text=text, subtitle_file=subtitle_file)
subtitle_content = Path(subtitle_file).read_text(encoding="utf-8")
self.assertIn("Gemini subtitle generation should work now", subtitle_content)
self.assertIn("Testing multiple lines", subtitle_content)
def test_mimo_tts_uses_openai_compatible_audio_response(self):
"""
验证 Xiaomi MiMo TTS 可以消费 OpenAI-compatible 的音频响应结构。
这里用 fake OpenAI client 和 fake AudioSegment 覆盖真实网络与 ffmpeg
确认运行时代码会把待合成文本放到 assistant message并把返回的
base64 WAV 音频导出到项目后续流程使用的音频文件。
"""
class _FakeAudio:
def __init__(self):
self.data = base64.b64encode(b"RIFF-fake-wav").decode("utf-8")
class _FakeMessage:
def __init__(self):
self.audio = _FakeAudio()
class _FakeChoice:
def __init__(self):
self.message = _FakeMessage()
class _FakeCompletion:
def __init__(self):
self.choices = [_FakeChoice()]
class _FakeCompletions:
def create(self, **kwargs):
self.kwargs = kwargs
return _FakeCompletion()
class _FakeAudioSegment:
def __len__(self):
return 1800
def export(self, output_file, format):
Path(output_file).write_bytes(b"fake-mp3")
fake_completions = _FakeCompletions()
fake_client = SimpleNamespace(
chat=SimpleNamespace(completions=fake_completions)
)
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
vs,
"OpenAI",
return_value=fake_client,
) as openai_client, patch(
"pydub.AudioSegment.from_file",
return_value=_FakeAudioSegment(),
), patch.object(
vs.config,
"app",
dict(
vs.config.app,
mimo_api_key="mimo-key",
mimo_base_url="https://api.xiaomimimo.com/v1",
mimo_tts_model_name="mimo-v2.5-tts",
mimo_tts_style_prompt="用清晰的中文旁白朗读。",
),
):
voice_file = str(Path(tmp_dir) / "mimo-tts.mp3")
sub_maker = vs.mimo_tts(
text="小米语音合成测试。第二句话。",
voice_name="冰糖",
voice_rate=1.0,
voice_file=voice_file,
voice_volume=1.0,
)
generated_audio = Path(voice_file).read_bytes()
openai_client.assert_called_once_with(
api_key="mimo-key",
base_url="https://api.xiaomimimo.com/v1",
)
self.assertEqual(fake_completions.kwargs["model"], "mimo-v2.5-tts")
self.assertEqual(
fake_completions.kwargs["messages"],
[
{"role": "user", "content": "用清晰的中文旁白朗读。"},
{"role": "assistant", "content": "小米语音合成测试。第二句话。"},
],
)
self.assertEqual(
fake_completions.kwargs["audio"],
{"format": "wav", "voice": "冰糖"},
)
self.assertEqual(generated_audio, b"fake-mp3")
self.assertIsNotNone(sub_maker)
self.assertEqual(getattr(sub_maker, "subs", []), ["小米语音合成测试", "第二句话"])
self.assertEqual(len(getattr(sub_maker, "offset", [])), 2)
def test_generate_subtitle_keeps_edge_provider_for_gemini_legacy_submaker(self):
"""
验证 Gemini TTS 返回的 legacy 字幕结构在 edge provider 下可以直接产出
SRT不会因为匹配失败而回退到 Whisper。
"""
script = "Gemini subtitle generation should work now. Testing multiple lines."
sub_maker = vs.populate_legacy_submaker_with_full_text(
vs.ensure_legacy_submaker_fields(vs.SubMaker()),
script,
2.4,
)
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
task_service.config,
"app",
dict(task_service.config.app, subtitle_provider="edge"),
), patch("app.services.subtitle.create") as whisper_create, patch(
"app.utils.utils.task_dir",
lambda tid="": str(Path(tmp_dir) / tid) if tid else str(Path(tmp_dir)),
):
task_id = "gemini-subtitle-edge-task"
Path(tmp_dir, task_id).mkdir(parents=True, exist_ok=True)
subtitle_path = task_service.generate_subtitle(
task_id=task_id,
params=type("Params", (), {"subtitle_enabled": True})(),
video_script=script,
sub_maker=sub_maker,
audio_file="",
)
self.assertTrue(subtitle_path.endswith("subtitle.srt"))
self.assertTrue(Path(subtitle_path).exists())
self.assertFalse(whisper_create.called)
subtitle_content = Path(subtitle_path).read_text(encoding="utf-8")
self.assertIn("Gemini subtitle generation should work now", subtitle_content)
self.assertIn("Testing multiple lines", subtitle_content)
def test_script_split_keeps_thousand_separator_comma(self):
"""
Edge TTS 会把 "1,000 years" 作为连续文本返回。脚本断句时不能把
数字中间的英文逗号当成句子边界,否则字幕聚合会出现 issue #894
里的 sub_items 数量少于 script_lines并错误回退 Whisper。
"""
text = (
"It takes about 1,000 years for a single drop of water to finish "
"the whole trip!"
)
self.assertEqual(
utils.split_string_by_punctuations(text),
[
(
"It takes about 1,000 years for a single drop of water to finish "
"the whole trip"
)
],
)
def test_edge_cue_aggregation_handles_thousand_separator_comma(self):
"""
复现 issue #894 的关键形态Edge cues 中最后一句作为连续文本返回,
包含 `1,000 years`。脚本断句必须与 cues 聚合结果一致,不能把它
拆成两条字幕。
"""
text = (
"The ocean isn't just sitting stil, it moves around the world like a massive "
"amusement park ride! Cold water at the North and South Poles sinks to the "
"bottom because it is heavy and salty. At the same time, warm water from the "
"sunny equator flows along the top to take its place. This creates a giant "
"underwater conveyor belt that travels all the way around the Earth. It takes "
"about 1,000 years for a single drop of water to finish the whole trip!"
)
script_lines = utils.split_string_by_punctuations(text)
cues = []
for index, line in enumerate(script_lines):
# Edge 的 cue content 经常没有脚本里的空格和标点布局,这里去掉空格
# 来模拟更严格的匹配场景。
cues.append(
SimpleNamespace(
content=line.replace(" ", ""),
start=timedelta(seconds=index),
end=timedelta(seconds=index + 0.8),
)
)
sub_maker = SimpleNamespace(cues=cues)
sub_items = vs._build_subtitle_items_from_edge_cues(sub_maker, script_lines)
self.assertEqual(len(sub_items), len(script_lines))
self.assertIn("1,000 years", sub_items[-1])
def test_convert_rate_to_percent_signs_zero_rate(self):
# Rates near but not exactly 1.0 round to 0 percent. edge-tts rejects
# an unsigned "0%" (ValueError: Invalid rate '0%'), so the helper must
# emit a sign-prefixed "+0%". Regression test for that crash.
self.assertEqual(vs.convert_rate_to_percent(1.0), "+0%")
self.assertEqual(vs.convert_rate_to_percent(1.004), "+0%")
self.assertEqual(vs.convert_rate_to_percent(0.997), "+0%")
self.assertEqual(vs.convert_rate_to_percent(1.5), "+50%")
self.assertEqual(vs.convert_rate_to_percent(0.8), "-20%")
if __name__ == "__main__":
# python -m unittest test.services.test_voice.TestVoiceService.test_azure_tts_v1
# python -m unittest test.services.test_voice.TestVoiceService.test_azure_tts_v2
unittest.main()