1034 lines
42 KiB
Python
1034 lines
42 KiB
Python
import asyncio
|
||
import base64
|
||
import os
|
||
import unittest
|
||
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
|
||
RUN_INTEGRATION_TESTS = os.environ.get("MPT_RUN_INTEGRATION_TESTS", "").lower() in {
|
||
"1",
|
||
"true",
|
||
"yes",
|
||
}
|
||
|
||
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_get_all_azure_voices(self):
|
||
voices = vs.get_all_azure_voices()
|
||
# 数据已从内联字符串迁移到 azure_voices.json,确保仍能完整加载
|
||
self.assertEqual(len(voices), 331)
|
||
# 结果应为 "Name-Gender" 格式且已排序
|
||
self.assertEqual(voices, sorted(voices))
|
||
for v in voices:
|
||
self.assertTrue(v.endswith("-Male") or v.endswith("-Female"))
|
||
|
||
def test_get_all_azure_voices_filtered(self):
|
||
filtered = vs.get_all_azure_voices(filter_locals=["zh-CN", "en-US"])
|
||
self.assertTrue(len(filtered) > 0)
|
||
self.assertTrue(
|
||
all(v.startswith(("zh-CN", "en-US")) for v in filtered)
|
||
)
|
||
|
||
def test_no_voice_tts_generates_silent_audio_and_subtitle_timeline(self):
|
||
"""
|
||
无配音模式不调用任何外部 TTS provider,只生成静音音频作为时间轴占位。
|
||
这里 mock FFmpeg,验证请求参数、输出文件和 legacy 字幕结构都符合后续
|
||
视频合成链路的预期。
|
||
"""
|
||
|
||
def fake_run(command, capture_output, text, check):
|
||
self.assertEqual(command[0], "/tmp/fake-ffmpeg")
|
||
self.assertIn("anullsrc=r=44100:cl=mono", command)
|
||
Path(command[-1]).write_bytes(b"fake-silent-mp3")
|
||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||
|
||
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
|
||
vs.utils,
|
||
"get_ffmpeg_binary",
|
||
return_value="/tmp/fake-ffmpeg",
|
||
), patch.object(vs.subprocess, "run", side_effect=fake_run):
|
||
voice_file = str(Path(tmp_dir) / "silent.mp3")
|
||
sub_maker = vs.tts(
|
||
text="第一句话。Second sentence.",
|
||
voice_name=vs.NO_VOICE_NAME,
|
||
voice_rate=1.0,
|
||
voice_file=voice_file,
|
||
)
|
||
|
||
self.assertEqual(Path(voice_file).read_bytes(), b"fake-silent-mp3")
|
||
|
||
self.assertIsNotNone(sub_maker)
|
||
self.assertEqual(getattr(sub_maker, "subs", []), ["第一句话", "Second sentence"])
|
||
self.assertEqual(len(getattr(sub_maker, "offset", [])), 2)
|
||
self.assertGreater(vs.get_audio_duration(sub_maker), 0)
|
||
|
||
def test_get_audio_duration_accepts_non_mp3_files(self):
|
||
"""
|
||
自定义音频(custom_audio_file)常见为 m4a/wav/aac 等非 mp3 格式。
|
||
get_audio_duration 不应因扩展名不是 .mp3 就报 "Invalid target type" 并返回 0,
|
||
而应交给 moviepy(ffmpeg) 读取真实时长。
|
||
"""
|
||
for path in ("custom-audio.m4a", "voice.wav", "clip.aac"):
|
||
with patch.object(vs.os.path, "exists", return_value=True), \
|
||
patch.object(vs, "AudioFileClip") as mock_afc:
|
||
mock_afc.return_value.__enter__.return_value.duration = 28.89
|
||
self.assertEqual(vs.get_audio_duration(path), 28.89)
|
||
mock_afc.assert_called_once_with(path)
|
||
|
||
def test_get_audio_duration_missing_file_returns_zero(self):
|
||
"""音频文件不存在时安全返回 0,而不是抛异常或读取失败。"""
|
||
with patch.object(vs.os.path, "exists", return_value=False):
|
||
self.assertEqual(vs.get_audio_duration("does-not-exist.m4a"), 0.0)
|
||
|
||
def test_no_voice_alias_none_is_supported_temporarily(self):
|
||
"""
|
||
兼容 PR #981 曾使用过的 none sentinel,避免少量直接调用 API 的用户
|
||
升级后立即失效。新 UI 和新代码仍统一使用 no-voice。
|
||
"""
|
||
self.assertTrue(vs.is_no_voice("none"))
|
||
self.assertTrue(vs.is_no_voice(vs.NO_VOICE_NAME))
|
||
self.assertFalse(vs.is_no_voice(""))
|
||
|
||
def test_no_voice_duration_estimates_non_ascii_languages(self):
|
||
"""
|
||
无配音没有真实 TTS 音频,只能根据脚本文字估算阅读时间。俄语、阿拉伯语、
|
||
日文假名、韩文等非 ASCII 文本也必须参与估算,不能都落到最短 3 秒。
|
||
"""
|
||
russian_text = (
|
||
"Это длинный тестовый сценарий без озвучки. "
|
||
"Он должен получить достаточно времени для чтения субтитров."
|
||
)
|
||
arabic_text = "هذا اختبار طويل بدون تعليق صوتي، ويجب أن يحصل على وقت كاف لقراءة الترجمة."
|
||
|
||
self.assertGreater(vs.estimate_no_voice_duration(russian_text), 8.0)
|
||
self.assertGreater(vs.estimate_no_voice_duration(arabic_text), 8.0)
|
||
|
||
def test_generate_silent_audio_rejects_missing_output_file(self):
|
||
"""
|
||
即使 FFmpeg 进程返回成功,也要确认输出文件真实存在且非空。这样可以把
|
||
异常收敛在 TTS 阶段,而不是拖到后续视频合成阶段才暴露。
|
||
"""
|
||
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
|
||
vs.utils,
|
||
"get_ffmpeg_binary",
|
||
return_value="/tmp/fake-ffmpeg",
|
||
), patch.object(
|
||
vs.subprocess,
|
||
"run",
|
||
return_value=SimpleNamespace(returncode=0, stdout="", stderr=""),
|
||
):
|
||
voice_file = str(Path(tmp_dir) / "missing-silent.mp3")
|
||
|
||
self.assertFalse(vs.generate_silent_audio(3.0, voice_file))
|
||
|
||
def test_empty_voice_name_does_not_enable_no_voice_mode(self):
|
||
"""
|
||
空 voice 通常意味着配置缺失或接口参数错误,不能自动切到无配音模式。
|
||
否则用户填错 TTS 配置时也会得到一个“成功”的静音视频,定位成本更高。
|
||
"""
|
||
sentinel = object()
|
||
|
||
with patch.object(vs, "azure_tts_v1", return_value=sentinel) as azure_tts_v1:
|
||
result = vs.tts(
|
||
text="empty voice should still use the default TTS path",
|
||
voice_name="",
|
||
voice_rate=1.0,
|
||
voice_file="/tmp/empty-voice.mp3",
|
||
)
|
||
|
||
self.assertIs(result, sentinel)
|
||
azure_tts_v1.assert_called_once()
|
||
|
||
@unittest.skipUnless(
|
||
RUN_INTEGRATION_TESTS,
|
||
"MPT_RUN_INTEGRATION_TESTS not set",
|
||
)
|
||
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())
|
||
|
||
@unittest.skipUnless(
|
||
RUN_INTEGRATION_TESTS,
|
||
"MPT_RUN_INTEGRATION_TESTS not set",
|
||
)
|
||
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)
|
||
|
||
@unittest.skipUnless(
|
||
RUN_INTEGRATION_TESTS,
|
||
"MPT_RUN_INTEGRATION_TESTS not set",
|
||
)
|
||
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,
|
||
voice_rate=1.0,
|
||
)
|
||
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_azure_tts_v2_ssml_applies_rate_and_escapes_text(self):
|
||
"""Azure V2 必须通过 SSML 应用语速,并避免用户文案破坏 XML。"""
|
||
ssml = vs._build_azure_v2_ssml(
|
||
text='A < B & "quoted"',
|
||
voice_name="zh-CN-XiaoxiaoMultilingualNeural",
|
||
voice_rate=1.8,
|
||
)
|
||
|
||
self.assertIn('xml:lang="zh-CN"', ssml)
|
||
self.assertIn('rate="1.8"', ssml)
|
||
self.assertIn("A < B & \"quoted\"", ssml)
|
||
|
||
def test_tts_forwards_rate_to_azure_v2(self):
|
||
"""统一 TTS 入口不能在分发 Azure V2 时丢失 voice_rate。"""
|
||
voice_name = "zh-CN-XiaoxiaoMultilingualNeural-V2-Female"
|
||
with patch.object(vs, "azure_tts_v2", return_value=object()) as mock_tts:
|
||
result = vs.tts(
|
||
text="语速测试",
|
||
voice_name=voice_name,
|
||
voice_rate=1.8,
|
||
voice_file="/tmp/azure-v2-rate.mp3",
|
||
)
|
||
|
||
self.assertIsNotNone(result)
|
||
mock_tts.assert_called_once_with(
|
||
"语速测试",
|
||
voice_name,
|
||
"/tmp/azure-v2-rate.mp3",
|
||
voice_rate=1.8,
|
||
)
|
||
|
||
def test_gemini_tts_uses_google_genai_and_compatible_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)]
|
||
|
||
captured = {}
|
||
|
||
class _FakeModels:
|
||
def generate_content(self, **kwargs):
|
||
captured.update(kwargs)
|
||
tone = (
|
||
AudioSegment.silent(duration=1800)
|
||
.set_frame_rate(24000)
|
||
.set_channels(1)
|
||
.set_sample_width(2)
|
||
)
|
||
return _Response(tone.raw_data)
|
||
|
||
class _FakeClient:
|
||
def __init__(self, **kwargs):
|
||
captured["client_kwargs"] = kwargs
|
||
self.models = _FakeModels()
|
||
|
||
def __enter__(self):
|
||
return self
|
||
|
||
def __exit__(self, exc_type, exc_value, traceback):
|
||
captured["closed"] = True
|
||
|
||
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.genai.Client", _FakeClient), 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])
|
||
self.assertEqual(captured["client_kwargs"], {"api_key": "test-key"})
|
||
self.assertEqual(captured["model"], "gemini-2.5-flash-preview-tts")
|
||
self.assertEqual(captured["contents"], text)
|
||
self.assertEqual(captured["config"].response_modalities, ["AUDIO"])
|
||
voice_config = captured["config"].speech_config.voice_config
|
||
self.assertEqual(
|
||
voice_config.prebuilt_voice_config.voice_name,
|
||
"Zephyr",
|
||
)
|
||
self.assertTrue(captured["closed"])
|
||
|
||
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_chatterbox_voice_helpers(self):
|
||
"""is_chatterbox_voice / get_chatterbox_voices basics and normalisation."""
|
||
self.assertTrue(vs.is_chatterbox_voice("chatterbox:default-Female"))
|
||
self.assertFalse(vs.is_chatterbox_voice("elevenlabs:abc:Rachel"))
|
||
self.assertFalse(vs.is_chatterbox_voice(""))
|
||
self.assertFalse(vs.is_chatterbox_voice(None))
|
||
|
||
# list entries are normalised to the chatterbox:<name> dispatcher format,
|
||
# and entries that are already prefixed are left untouched
|
||
with patch.object(
|
||
vs.config,
|
||
"chatterbox",
|
||
{"voices": ["narrator-Male", "chatterbox:host"]},
|
||
):
|
||
self.assertEqual(
|
||
vs.get_chatterbox_voices(),
|
||
["chatterbox:narrator-Male", "chatterbox:host"],
|
||
)
|
||
|
||
# a comma-separated string is also accepted (TOML-friendly)
|
||
with patch.object(vs.config, "chatterbox", {"voices": "alpha, beta ,"}):
|
||
self.assertEqual(
|
||
vs.get_chatterbox_voices(),
|
||
["chatterbox:alpha", "chatterbox:beta"],
|
||
)
|
||
|
||
# with nothing configured the dropdown still gets a usable default
|
||
with patch.object(vs.config, "chatterbox", {}):
|
||
self.assertEqual(vs.get_chatterbox_voices(), ["chatterbox:default-Female"])
|
||
|
||
def test_chatterbox_tts_posts_to_openai_compatible_endpoint(self):
|
||
"""Success path: POST /audio/speech, write audio, return legacy SubMaker."""
|
||
|
||
class _FakeResponse:
|
||
status_code = 200
|
||
content = b"RIFF-fake-wav"
|
||
text = ""
|
||
|
||
class _FakeClip:
|
||
duration = 3.5
|
||
|
||
def close(self):
|
||
pass
|
||
|
||
captured = {}
|
||
|
||
def _fake_post(url, json=None, headers=None, timeout=None):
|
||
captured["url"] = url
|
||
captured["json"] = json
|
||
captured["headers"] = headers
|
||
return _FakeResponse()
|
||
|
||
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
|
||
vs.config,
|
||
"chatterbox",
|
||
{
|
||
"base_url": "http://localhost:4123/v1/",
|
||
"api_key": "secret",
|
||
"model_id": "chatterbox",
|
||
},
|
||
), patch.object(
|
||
vs.requests, "post", side_effect=_fake_post
|
||
) as post, patch.object(
|
||
vs, "AudioFileClip", return_value=_FakeClip()
|
||
):
|
||
voice_file = str(Path(tmp_dir) / "chatterbox.mp3")
|
||
sub_maker = vs.chatterbox_tts(
|
||
text="Hello world. Second sentence.",
|
||
voice="default",
|
||
voice_file=voice_file,
|
||
voice_rate=1.2,
|
||
voice_volume=1.0,
|
||
)
|
||
generated_audio = Path(voice_file).read_bytes()
|
||
|
||
post.assert_called_once()
|
||
# trailing slash on base_url is stripped before appending /audio/speech
|
||
self.assertEqual(captured["url"], "http://localhost:4123/v1/audio/speech")
|
||
self.assertEqual(captured["json"]["model"], "chatterbox")
|
||
self.assertEqual(captured["json"]["voice"], "default")
|
||
self.assertEqual(captured["json"]["input"], "Hello world. Second sentence.")
|
||
self.assertAlmostEqual(captured["json"]["speed"], 1.2)
|
||
# api_key is forwarded as a bearer token
|
||
self.assertEqual(captured["headers"].get("Authorization"), "Bearer secret")
|
||
# volume is intentionally not part of the OpenAI speech payload
|
||
self.assertNotIn("volume", captured["json"])
|
||
self.assertEqual(generated_audio, b"RIFF-fake-wav")
|
||
self.assertIsNotNone(sub_maker)
|
||
self.assertTrue(getattr(sub_maker, "subs", []))
|
||
|
||
def test_chatterbox_tts_requires_base_url(self):
|
||
"""Missing base_url short-circuits without any network call."""
|
||
with patch.object(
|
||
vs.config, "chatterbox", {"base_url": ""}
|
||
), patch.object(vs.requests, "post") as post:
|
||
result = vs.chatterbox_tts(
|
||
text="hi", voice="default", voice_file="unused.mp3"
|
||
)
|
||
self.assertIsNone(result)
|
||
post.assert_not_called()
|
||
|
||
def test_chatterbox_tts_returns_none_on_http_error(self):
|
||
"""A non-200 response is retried up to 3 times, then fails to None."""
|
||
|
||
class _FakeResponse:
|
||
status_code = 500
|
||
content = b""
|
||
text = "boom"
|
||
|
||
with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
|
||
vs.config, "chatterbox", {"base_url": "http://localhost:4123/v1"}
|
||
), patch.object(
|
||
vs.requests, "post", return_value=_FakeResponse()
|
||
) as post:
|
||
voice_file = str(Path(tmp_dir) / "chatterbox.mp3")
|
||
result = vs.chatterbox_tts(
|
||
text="hi", voice="default", voice_file=voice_file
|
||
)
|
||
self.assertIsNone(result)
|
||
self.assertEqual(post.call_count, 3)
|
||
|
||
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_script_split_supports_arabic_punctuation(self):
|
||
"""
|
||
阿拉伯语脚本常用 ، ؛ ؟ 作为自然断句标点。断句阶段必须识别这些
|
||
标点,否则 edge-tts cue 的停顿边界和脚本行边界会错位。
|
||
"""
|
||
text = "مرحبا بالعالم، كيف حالك؟ هذا اختبار؛ يعمل بشكل جيد."
|
||
|
||
self.assertEqual(
|
||
utils.split_string_by_punctuations(text),
|
||
[
|
||
"مرحبا بالعالم",
|
||
"كيف حالك",
|
||
"هذا اختبار",
|
||
"يعمل بشكل جيد",
|
||
],
|
||
)
|
||
|
||
def test_match_script_line_normalizes_arabic_letter_forms(self):
|
||
"""
|
||
edge-tts 可能把阿拉伯语中的不同字母形态归一化,或返回带变音符号、
|
||
Tatweel 的 cue 文本。匹配时应容错,但最终字幕仍保留原始脚本文案。
|
||
"""
|
||
script_lines = ["أهلاً وسهلاً بك في المدرسة"]
|
||
|
||
matched = vs._match_script_line(
|
||
script_lines,
|
||
"اهلا وسهلا بك في المدرسه",
|
||
0,
|
||
)
|
||
|
||
self.assertEqual(matched, script_lines[0])
|
||
|
||
def test_edge_cue_aggregation_handles_arabic_variant_forms(self):
|
||
"""
|
||
复现阿拉伯语字幕失败的核心路径:脚本包含 أ/ة 等字母形态,edge cue
|
||
返回 ا/ه 等归一化形态时,聚合仍应生成完整字幕,避免回退 Whisper。
|
||
"""
|
||
text = "أهلاً وسهلاً بك في المدرسة؟ هذا اختبار رائع، شكراً لك."
|
||
script_lines = utils.split_string_by_punctuations(text)
|
||
cue_texts = [
|
||
"اهلا وسهلا بك في المدرسه",
|
||
"هذا اختبار رائع",
|
||
"شكرا لك",
|
||
]
|
||
sub_maker = SimpleNamespace(
|
||
cues=[
|
||
SimpleNamespace(
|
||
content=cue_text,
|
||
start=timedelta(seconds=index),
|
||
end=timedelta(seconds=index + 0.8),
|
||
)
|
||
for index, cue_text in enumerate(cue_texts)
|
||
]
|
||
)
|
||
|
||
sub_items = vs._build_subtitle_items_from_edge_cues(sub_maker, script_lines)
|
||
|
||
self.assertEqual(len(sub_items), len(script_lines))
|
||
self.assertIn("أهلاً وسهلاً بك في المدرسة", sub_items[0])
|
||
self.assertIn("شكراً لك", sub_items[-1])
|
||
|
||
def test_create_subtitle_ignores_markdown_separator_lines(self):
|
||
"""
|
||
用户手动脚本可能包含 `---` 这类 Markdown 分隔符。TTS 不会朗读
|
||
这些符号行,字幕聚合也不应把它们当成目标字幕行,否则后续真实
|
||
字幕会卡住并回退到 Whisper。
|
||
"""
|
||
text = "第一段\n---\n第二段"
|
||
sub_maker = SimpleNamespace(
|
||
cues=[
|
||
SimpleNamespace(
|
||
content="第一段",
|
||
start=timedelta(seconds=0),
|
||
end=timedelta(seconds=0.8),
|
||
),
|
||
SimpleNamespace(
|
||
content="第二段",
|
||
start=timedelta(seconds=1),
|
||
end=timedelta(seconds=1.8),
|
||
),
|
||
]
|
||
)
|
||
|
||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||
subtitle_file = Path(tmp_dir) / "subtitle.srt"
|
||
vs.create_subtitle(
|
||
sub_maker=sub_maker,
|
||
text=text,
|
||
subtitle_file=str(subtitle_file),
|
||
)
|
||
|
||
subtitle_content = subtitle_file.read_text(encoding="utf-8")
|
||
|
||
self.assertIn("第一段", subtitle_content)
|
||
self.assertIn("第二段", subtitle_content)
|
||
self.assertNotIn("---", subtitle_content)
|
||
self.assertNotIn("00:00:00,000 --> 00:00:00,000", subtitle_content)
|
||
|
||
def test_create_subtitle_ignores_markdown_underscore_marks(self):
|
||
"""
|
||
`_` 常被用户用作 Markdown 强调标记,但 TTS 返回的 cue 通常不包含
|
||
这些格式符。匹配时应忽略 `_`,避免生成空字幕或回退到 Whisper。
|
||
"""
|
||
text = "这是_a_测试。"
|
||
sub_maker = SimpleNamespace(
|
||
cues=[
|
||
SimpleNamespace(
|
||
content="这是a测试",
|
||
start=timedelta(seconds=0),
|
||
end=timedelta(seconds=0.8),
|
||
),
|
||
]
|
||
)
|
||
|
||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||
subtitle_file = Path(tmp_dir) / "subtitle.srt"
|
||
vs.create_subtitle(
|
||
sub_maker=sub_maker,
|
||
text=text,
|
||
subtitle_file=str(subtitle_file),
|
||
)
|
||
|
||
subtitle_content = subtitle_file.read_text(encoding="utf-8")
|
||
|
||
self.assertIn("这是a测试", subtitle_content)
|
||
self.assertNotIn("这是_a_测试", subtitle_content)
|
||
self.assertNotIn("00:00:00,000 --> 00:00:00,000", subtitle_content)
|
||
|
||
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%")
|
||
|
||
def test_convert_rate_to_percent_invalid_values_default_to_normal(self):
|
||
# API 和批处理脚本可能把空语速传成 0、None 或空字符串;这些都不应让
|
||
# edge-tts 收到 -100% 或触发异常,而是按正常语速处理。
|
||
self.assertEqual(vs.convert_rate_to_percent(0), "+0%")
|
||
self.assertEqual(vs.convert_rate_to_percent(0.0), "+0%")
|
||
self.assertEqual(vs.convert_rate_to_percent(None), "+0%")
|
||
self.assertEqual(vs.convert_rate_to_percent(""), "+0%")
|
||
|
||
|
||
class TestElevenLabsVoice(unittest.TestCase):
|
||
|
||
def test_is_elevenlabs_voice_true(self):
|
||
self.assertTrue(vs.is_elevenlabs_voice("elevenlabs:pNInz6obpgDQGcFmaJgB:Adam"))
|
||
|
||
def test_is_elevenlabs_voice_false_azure(self):
|
||
self.assertFalse(vs.is_elevenlabs_voice("zh-CN-XiaoxiaoNeural-Female"))
|
||
|
||
def test_is_elevenlabs_voice_false_siliconflow(self):
|
||
self.assertFalse(vs.is_elevenlabs_voice("siliconflow:model:voice-Male"))
|
||
|
||
def test_is_elevenlabs_voice_empty(self):
|
||
self.assertFalse(vs.is_elevenlabs_voice(""))
|
||
|
||
def test_is_elevenlabs_voice_none(self):
|
||
self.assertFalse(vs.is_elevenlabs_voice(None))
|
||
|
||
def test_get_elevenlabs_voices_empty_api_key(self):
|
||
result = vs.get_elevenlabs_voices("")
|
||
self.assertEqual(result, [])
|
||
|
||
@patch("app.services.voice.requests.get")
|
||
def test_get_elevenlabs_voices_success(self, mock_get):
|
||
mock_get.return_value.status_code = 200
|
||
mock_get.return_value.json.return_value = {
|
||
"voices": [
|
||
{"voice_id": "abc123", "name": "Adam"},
|
||
{"voice_id": "def456", "name": "Rachel"},
|
||
]
|
||
}
|
||
result = vs.get_elevenlabs_voices("fake-api-key")
|
||
self.assertEqual(result, [
|
||
"elevenlabs:abc123:Adam",
|
||
"elevenlabs:def456:Rachel",
|
||
])
|
||
mock_get.assert_called_once()
|
||
call_kwargs = mock_get.call_args
|
||
self.assertIn("xi-api-key", call_kwargs.kwargs.get("headers", {}))
|
||
|
||
@patch("app.services.voice.requests.get")
|
||
def test_get_elevenlabs_voices_http_error(self, mock_get):
|
||
mock_get.return_value.status_code = 401
|
||
mock_get.return_value.text = "Unauthorized"
|
||
result = vs.get_elevenlabs_voices("bad-key")
|
||
self.assertEqual(result, [])
|
||
|
||
@patch("app.services.voice.requests.get")
|
||
def test_get_elevenlabs_voices_network_error(self, mock_get):
|
||
import requests as req_lib
|
||
mock_get.side_effect = req_lib.exceptions.ConnectionError("timeout")
|
||
result = vs.get_elevenlabs_voices("fake-key")
|
||
self.assertEqual(result, [])
|
||
|
||
@patch("app.services.voice.requests.post")
|
||
@patch("app.services.voice.AudioFileClip")
|
||
@patch("app.services.voice.config")
|
||
def test_elevenlabs_tts_success(self, mock_config, mock_clip_cls, mock_post):
|
||
mock_config.elevenlabs.get.return_value = "fake-api-key"
|
||
mock_post.return_value.status_code = 200
|
||
mock_post.return_value.content = b"fake-mp3-bytes"
|
||
mock_clip = mock_clip_cls.return_value.__enter__.return_value
|
||
mock_clip_cls.return_value.duration = 3.0
|
||
mock_clip_cls.return_value.close = lambda: None
|
||
|
||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||
out_path = f.name
|
||
|
||
try:
|
||
result = vs.elevenlabs_tts("Hello world", "abc123", out_path)
|
||
self.assertIsNotNone(result)
|
||
self.assertTrue(hasattr(result, "subs"))
|
||
self.assertTrue(hasattr(result, "offset"))
|
||
finally:
|
||
if os.path.exists(out_path):
|
||
os.remove(out_path)
|
||
|
||
@patch("app.services.voice.config")
|
||
def test_elevenlabs_tts_no_api_key(self, mock_config):
|
||
mock_config.elevenlabs.get.return_value = ""
|
||
result = vs.elevenlabs_tts("Hello", "abc123", "/tmp/test.mp3")
|
||
self.assertIsNone(result)
|
||
|
||
@patch("app.services.voice.config")
|
||
def test_elevenlabs_tts_empty_text(self, mock_config):
|
||
mock_config.elevenlabs.get.return_value = "fake-key"
|
||
result = vs.elevenlabs_tts(" ", "abc123", "/tmp/test.mp3")
|
||
self.assertIsNone(result)
|
||
|
||
|
||
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()
|