返回 MoneyPrinterTurbo
test_voice.py
根目录 / test / services / test_voice.py
1 import asyncio
2 import base64
3 import os
4 import unittest
5 import sys
6 import tempfile
7 import time
8 from datetime import timedelta
9 from pathlib import Path
10 from types import SimpleNamespace
11 from unittest.mock import patch
12
13 # add project root to python path
14 sys.path.insert(0, str(Path(__file__).parent.parent.parent))
15
16 from app.utils import utils
17 from app.services import voice as vs
18 from app.services import task as task_service
19 from pydub import AudioSegment
20
21 temp_dir = utils.storage_dir("temp")
22
23 text_en = """
24 What is the meaning of life?
25 This question has puzzled philosophers, scientists, and thinkers of all kinds for centuries.
26 Throughout history, various cultures and individuals have come up with their interpretations and beliefs around the purpose of life.
27 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.
28 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.
29 It's an existential inquiry that encourages us to reflect on our values, desires, and the essence of our existence.
30 """
31
32 text_zh = """
33 预计未来3天深圳冷空气活动频繁,未来两天持续阴天有小雨,出门带好雨具;
34 10-11日持续阴天有小雨,日温差小,气温在13-17℃之间,体感阴凉;
35 12日天气短暂好转,早晚清凉;
36 """
37
38 voice_rate=1.0
39 voice_volume=1.0
40 RUN_INTEGRATION_TESTS = os.environ.get("MPT_RUN_INTEGRATION_TESTS", "").lower() in {
41 "1",
42 "true",
43 "yes",
44 }
45
46 class TestVoiceService(unittest.TestCase):
47 def setUp(self):
48 self.loop = asyncio.new_event_loop()
49 asyncio.set_event_loop(self.loop)
50
51 def tearDown(self):
52 self.loop.close()
53
54 def test_get_all_azure_voices(self):
55 voices = vs.get_all_azure_voices()
56 # 数据已从内联字符串迁移到 azure_voices.json,确保仍能完整加载
57 self.assertEqual(len(voices), 331)
58 # 结果应为 "Name-Gender" 格式且已排序
59 self.assertEqual(voices, sorted(voices))
60 for v in voices:
61 self.assertTrue(v.endswith("-Male") or v.endswith("-Female"))
62
63 def test_get_all_azure_voices_filtered(self):
64 filtered = vs.get_all_azure_voices(filter_locals=["zh-CN", "en-US"])
65 self.assertTrue(len(filtered) > 0)
66 self.assertTrue(
67 all(v.startswith(("zh-CN", "en-US")) for v in filtered)
68 )
69
70 def test_no_voice_tts_generates_silent_audio_and_subtitle_timeline(self):
71 """
72 无配音模式不调用任何外部 TTS provider,只生成静音音频作为时间轴占位。
73 这里 mock FFmpeg,验证请求参数、输出文件和 legacy 字幕结构都符合后续
74 视频合成链路的预期。
75 """
76
77 def fake_run(command, capture_output, text, check):
78 self.assertEqual(command[0], "/tmp/fake-ffmpeg")
79 self.assertIn("anullsrc=r=44100:cl=mono", command)
80 Path(command[-1]).write_bytes(b"fake-silent-mp3")
81 return SimpleNamespace(returncode=0, stdout="", stderr="")
82
83 with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
84 vs.utils,
85 "get_ffmpeg_binary",
86 return_value="/tmp/fake-ffmpeg",
87 ), patch.object(vs.subprocess, "run", side_effect=fake_run):
88 voice_file = str(Path(tmp_dir) / "silent.mp3")
89 sub_maker = vs.tts(
90 text="第一句话。Second sentence.",
91 voice_name=vs.NO_VOICE_NAME,
92 voice_rate=1.0,
93 voice_file=voice_file,
94 )
95
96 self.assertEqual(Path(voice_file).read_bytes(), b"fake-silent-mp3")
97
98 self.assertIsNotNone(sub_maker)
99 self.assertEqual(getattr(sub_maker, "subs", []), ["第一句话", "Second sentence"])
100 self.assertEqual(len(getattr(sub_maker, "offset", [])), 2)
101 self.assertGreater(vs.get_audio_duration(sub_maker), 0)
102
103 def test_no_voice_alias_none_is_supported_temporarily(self):
104 """
105 兼容 PR #981 曾使用过的 none sentinel,避免少量直接调用 API 的用户
106 升级后立即失效。新 UI 和新代码仍统一使用 no-voice。
107 """
108 self.assertTrue(vs.is_no_voice("none"))
109 self.assertTrue(vs.is_no_voice(vs.NO_VOICE_NAME))
110 self.assertFalse(vs.is_no_voice(""))
111
112 def test_no_voice_duration_estimates_non_ascii_languages(self):
113 """
114 无配音没有真实 TTS 音频,只能根据脚本文字估算阅读时间。俄语、阿拉伯语、
115 日文假名、韩文等非 ASCII 文本也必须参与估算,不能都落到最短 3 秒。
116 """
117 russian_text = (
118 "Это длинный тестовый сценарий без озвучки. "
119 "Он должен получить достаточно времени для чтения субтитров."
120 )
121 arabic_text = "هذا اختبار طويل بدون تعليق صوتي، ويجب أن يحصل على وقت كاف لقراءة الترجمة."
122
123 self.assertGreater(vs.estimate_no_voice_duration(russian_text), 8.0)
124 self.assertGreater(vs.estimate_no_voice_duration(arabic_text), 8.0)
125
126 def test_generate_silent_audio_rejects_missing_output_file(self):
127 """
128 即使 FFmpeg 进程返回成功,也要确认输出文件真实存在且非空。这样可以把
129 异常收敛在 TTS 阶段,而不是拖到后续视频合成阶段才暴露。
130 """
131 with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
132 vs.utils,
133 "get_ffmpeg_binary",
134 return_value="/tmp/fake-ffmpeg",
135 ), patch.object(
136 vs.subprocess,
137 "run",
138 return_value=SimpleNamespace(returncode=0, stdout="", stderr=""),
139 ):
140 voice_file = str(Path(tmp_dir) / "missing-silent.mp3")
141
142 self.assertFalse(vs.generate_silent_audio(3.0, voice_file))
143
144 def test_empty_voice_name_does_not_enable_no_voice_mode(self):
145 """
146 空 voice 通常意味着配置缺失或接口参数错误,不能自动切到无配音模式。
147 否则用户填错 TTS 配置时也会得到一个“成功”的静音视频,定位成本更高。
148 """
149 sentinel = object()
150
151 with patch.object(vs, "azure_tts_v1", return_value=sentinel) as azure_tts_v1:
152 result = vs.tts(
153 text="empty voice should still use the default TTS path",
154 voice_name="",
155 voice_rate=1.0,
156 voice_file="/tmp/empty-voice.mp3",
157 )
158
159 self.assertIs(result, sentinel)
160 azure_tts_v1.assert_called_once()
161
162 @unittest.skipUnless(
163 RUN_INTEGRATION_TESTS,
164 "MPT_RUN_INTEGRATION_TESTS not set",
165 )
166 def test_siliconflow(self):
167 # SiliconFlow 的 API Key 存在 [siliconflow].api_key 中,运行时代码也是从
168 # config.siliconflow 读取;这里必须使用同一配置源,避免正确配置凭据时
169 # 测试仍然被误跳过。
170 if not vs.config.siliconflow.get("api_key"):
171 self.skipTest("siliconflow_api_key is not configured")
172
173 voice_name = "siliconflow:FunAudioLLM/CosyVoice2-0.5B:alex-Male"
174 voice_name = vs.parse_voice_name(voice_name)
175
176 async def _do():
177 parts = voice_name.split(":")
178 if len(parts) >= 3:
179 model = parts[1]
180 # 移除性别后缀,例如 "alex-Male" -> "alex"
181 voice_with_gender = parts[2]
182 voice = voice_with_gender.split("-")[0]
183 # 构建完整的voice参数,格式为 "model:voice"
184 full_voice = f"{model}:{voice}"
185 voice_file = f"{temp_dir}/tts-siliconflow-{voice}.mp3"
186 subtitle_file = f"{temp_dir}/tts-siliconflow-{voice}.srt"
187 sub_maker = vs.siliconflow_tts(
188 text=text_zh, model=model, voice=full_voice, voice_file=voice_file, voice_rate=voice_rate, voice_volume=voice_volume
189 )
190 if not sub_maker:
191 self.fail("siliconflow tts failed")
192 vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file)
193 audio_duration = vs.get_audio_duration(sub_maker)
194 print(f"voice: {voice_name}, audio duration: {audio_duration}s")
195 else:
196 self.fail("siliconflow invalid voice name")
197
198 self.loop.run_until_complete(_do())
199
200 @unittest.skipUnless(
201 RUN_INTEGRATION_TESTS,
202 "MPT_RUN_INTEGRATION_TESTS not set",
203 )
204 def test_azure_tts_v1(self):
205 voice_name = "zh-CN-XiaoyiNeural-Female"
206 voice_name = vs.parse_voice_name(voice_name)
207 print(voice_name)
208
209 voice_file = f"{temp_dir}/tts-azure-v1-{voice_name}.mp3"
210 subtitle_file = f"{temp_dir}/tts-azure-v1-{voice_name}.srt"
211 sub_maker = vs.azure_tts_v1(
212 text=text_zh, voice_name=voice_name, voice_file=voice_file, voice_rate=voice_rate
213 )
214 if not sub_maker:
215 self.fail("azure tts v1 failed")
216 vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file)
217 audio_duration = vs.get_audio_duration(sub_maker)
218 print(f"voice: {voice_name}, audio duration: {audio_duration}s")
219
220 def test_azure_tts_v1_supports_legacy_edge_tts_without_boundary(self):
221 """
222 验证 Azure TTS V1 在旧版 edge_tts 依赖残留时仍可继续工作。
223
224 这个回归场景对应 Windows 便携包更新失败后,现场环境还停留在旧版
225 edge_tts 的情况:
226 1. `Communicate.__init__()` 不接受 `boundary`
227 2. 只有异步 `stream()`,没有 `stream_sync()`
228 """
229
230 class _LegacyCommunicate:
231 def __init__(self, text, voice, rate="+0%"):
232 self.text = text
233 self.voice = voice
234 self.rate = rate
235
236 async def stream(self):
237 yield {"type": "audio", "data": b"legacy-audio"}
238 yield {
239 "type": "WordBoundary",
240 "offset": 0,
241 "duration": 10000000,
242 "text": "legacy",
243 }
244
245 class _FakeSubMaker:
246 def __init__(self):
247 self.events = []
248
249 def feed(self, chunk):
250 self.events.append(chunk)
251
252 def get_srt(self):
253 if not self.events:
254 return ""
255 return "1\n00:00:00,000 --> 00:00:01,000\nlegacy\n"
256
257 with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
258 vs.edge_tts, "Communicate", _LegacyCommunicate
259 ), patch.object(vs.edge_tts, "SubMaker", _FakeSubMaker):
260 voice_file = str(Path(tmp_dir) / "legacy-edge-tts.mp3")
261 sub_maker = vs.azure_tts_v1(
262 text="legacy edge tts compatibility",
263 voice_name="zh-CN-XiaoyiNeural-Female",
264 voice_file=voice_file,
265 voice_rate=1.0,
266 )
267
268 self.assertIsNotNone(sub_maker)
269 self.assertEqual(Path(voice_file).read_bytes(), b"legacy-audio")
270 self.assertEqual(len(sub_maker.events), 1)
271 self.assertEqual(sub_maker.events[0]["type"], "WordBoundary")
272
273 def test_azure_tts_v1_times_out_hanging_stream_sync(self):
274 """
275 验证 Azure TTS V1 在 edge_tts 同步流卡住时能够快速失败。
276
277 真实现场里,网络异常、服务端限流、voice 语言与文本不匹配时,
278 `stream_sync()` 可能长时间不返回,导致 WebUI 任务只停在
279 `start, voice name...`。这里用阻塞的 fake stream 复现该场景,
280 确认超时保护会让函数结束并返回 None。
281 """
282
283 class _HangingCommunicate:
284 def __init__(self, text, voice, rate="+0%", boundary=None):
285 self.text = text
286 self.voice = voice
287 self.rate = rate
288 self.boundary = boundary
289
290 def stream_sync(self):
291 time.sleep(10)
292 yield {"type": "audio", "data": b"unreachable"}
293
294 class _FakeSubMaker:
295 def feed(self, chunk):
296 return None
297
298 def get_srt(self):
299 return ""
300
301 with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
302 vs.edge_tts, "Communicate", _HangingCommunicate
303 ), patch.object(vs.edge_tts, "SubMaker", _FakeSubMaker), patch.object(
304 vs.config,
305 "app",
306 dict(vs.config.app, edge_tts_timeout=0.05),
307 ):
308 voice_file = Path(tmp_dir) / "hanging-edge-tts.mp3"
309 started_at = time.monotonic()
310 sub_maker = vs.azure_tts_v1(
311 text="帮我生成一个花开花落的视频",
312 voice_name="en-AU-NatashaNeural-Female",
313 voice_file=str(voice_file),
314 voice_rate=1.0,
315 )
316 elapsed = time.monotonic() - started_at
317 self.assertFalse(voice_file.exists())
318
319 self.assertIsNone(sub_maker)
320 self.assertLess(elapsed, 2)
321
322 @unittest.skipUnless(
323 RUN_INTEGRATION_TESTS,
324 "MPT_RUN_INTEGRATION_TESTS not set",
325 )
326 def test_azure_tts_v2(self):
327 if not vs.config.azure.get("speech_key") or not vs.config.azure.get("speech_region"):
328 self.skipTest("Azure speech key or region is not configured")
329
330 voice_name = "zh-CN-XiaoxiaoMultilingualNeural-V2-Female"
331 voice_name = vs.parse_voice_name(voice_name)
332 print(voice_name)
333
334 async def _do():
335 voice_file = f"{temp_dir}/tts-azure-v2-{voice_name}.mp3"
336 subtitle_file = f"{temp_dir}/tts-azure-v2-{voice_name}.srt"
337 sub_maker = vs.azure_tts_v2(
338 text=text_zh, voice_name=voice_name, voice_file=voice_file
339 )
340 if not sub_maker:
341 self.fail("azure tts v2 failed")
342 vs.create_subtitle(sub_maker=sub_maker, text=text_zh, subtitle_file=subtitle_file)
343 audio_duration = vs.get_audio_duration(sub_maker)
344 print(f"voice: {voice_name}, audio duration: {audio_duration}s")
345
346 self.loop.run_until_complete(_do())
347
348 def test_gemini_tts_uses_legacy_submaker_fields(self):
349 """
350 验证 Gemini TTS 在 edge_tts 7.x 环境下仍会返回项目兼容的字幕结构,
351 并且可以被 `subtitle_provider=edge` 的字幕生成链路直接消费,
352 避免再次回退 Whisper。
353 """
354
355 class _InlineData:
356 def __init__(self, data):
357 self.data = data
358
359 class _Part:
360 def __init__(self, data):
361 self.inline_data = _InlineData(data)
362
363 class _Content:
364 def __init__(self, data):
365 self.parts = [_Part(data)]
366
367 class _Candidate:
368 def __init__(self, data):
369 self.content = _Content(data)
370
371 class _Response:
372 def __init__(self, data):
373 self.candidates = [_Candidate(data)]
374
375 class _FakeModel:
376 def __init__(self, name):
377 self.name = name
378
379 def generate_content(self, contents, generation_config):
380 tone = (
381 AudioSegment.silent(duration=1800)
382 .set_frame_rate(24000)
383 .set_channels(1)
384 .set_sample_width(2)
385 )
386 return _Response(tone.raw_data)
387
388 voice_file = f"{temp_dir}/tts-gemini-Zephyr.mp3"
389 subtitle_file = f"{temp_dir}/tts-gemini-Zephyr.srt"
390 text = "Gemini subtitle generation should work now. Testing multiple lines."
391
392 with patch("google.generativeai.configure"), patch(
393 "google.generativeai.GenerativeModel", _FakeModel
394 ), patch.object(vs.config, "app", dict(vs.config.app, gemini_api_key="test-key")):
395 sub_maker = vs.gemini_tts(
396 text=text,
397 voice_name="Zephyr",
398 voice_rate=1.0,
399 voice_file=voice_file,
400 )
401
402 self.assertIsNotNone(sub_maker)
403 self.assertEqual(
404 getattr(sub_maker, "subs", []),
405 ["Gemini subtitle generation should work now", "Testing multiple lines"],
406 )
407 self.assertEqual(len(getattr(sub_maker, "offset", [])), 2)
408 self.assertEqual(sub_maker.offset[0][0], 0)
409 self.assertLess(sub_maker.offset[0][1], sub_maker.offset[1][1])
410
411 vs.create_subtitle(sub_maker=sub_maker, text=text, subtitle_file=subtitle_file)
412 subtitle_content = Path(subtitle_file).read_text(encoding="utf-8")
413 self.assertIn("Gemini subtitle generation should work now", subtitle_content)
414 self.assertIn("Testing multiple lines", subtitle_content)
415
416 def test_mimo_tts_uses_openai_compatible_audio_response(self):
417 """
418 验证 Xiaomi MiMo TTS 可以消费 OpenAI-compatible 的音频响应结构。
419
420 这里用 fake OpenAI client 和 fake AudioSegment 覆盖真实网络与 ffmpeg,
421 确认运行时代码会把待合成文本放到 assistant message,并把返回的
422 base64 WAV 音频导出到项目后续流程使用的音频文件。
423 """
424
425 class _FakeAudio:
426 def __init__(self):
427 self.data = base64.b64encode(b"RIFF-fake-wav").decode("utf-8")
428
429 class _FakeMessage:
430 def __init__(self):
431 self.audio = _FakeAudio()
432
433 class _FakeChoice:
434 def __init__(self):
435 self.message = _FakeMessage()
436
437 class _FakeCompletion:
438 def __init__(self):
439 self.choices = [_FakeChoice()]
440
441 class _FakeCompletions:
442 def create(self, **kwargs):
443 self.kwargs = kwargs
444 return _FakeCompletion()
445
446 class _FakeAudioSegment:
447 def __len__(self):
448 return 1800
449
450 def export(self, output_file, format):
451 Path(output_file).write_bytes(b"fake-mp3")
452
453 fake_completions = _FakeCompletions()
454 fake_client = SimpleNamespace(
455 chat=SimpleNamespace(completions=fake_completions)
456 )
457
458 with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
459 vs,
460 "OpenAI",
461 return_value=fake_client,
462 ) as openai_client, patch(
463 "pydub.AudioSegment.from_file",
464 return_value=_FakeAudioSegment(),
465 ), patch.object(
466 vs.config,
467 "app",
468 dict(
469 vs.config.app,
470 mimo_api_key="mimo-key",
471 mimo_base_url="https://api.xiaomimimo.com/v1",
472 mimo_tts_model_name="mimo-v2.5-tts",
473 mimo_tts_style_prompt="用清晰的中文旁白朗读。",
474 ),
475 ):
476 voice_file = str(Path(tmp_dir) / "mimo-tts.mp3")
477 sub_maker = vs.mimo_tts(
478 text="小米语音合成测试。第二句话。",
479 voice_name="冰糖",
480 voice_rate=1.0,
481 voice_file=voice_file,
482 voice_volume=1.0,
483 )
484 generated_audio = Path(voice_file).read_bytes()
485
486 openai_client.assert_called_once_with(
487 api_key="mimo-key",
488 base_url="https://api.xiaomimimo.com/v1",
489 )
490 self.assertEqual(fake_completions.kwargs["model"], "mimo-v2.5-tts")
491 self.assertEqual(
492 fake_completions.kwargs["messages"],
493 [
494 {"role": "user", "content": "用清晰的中文旁白朗读。"},
495 {"role": "assistant", "content": "小米语音合成测试。第二句话。"},
496 ],
497 )
498 self.assertEqual(
499 fake_completions.kwargs["audio"],
500 {"format": "wav", "voice": "冰糖"},
501 )
502 self.assertEqual(generated_audio, b"fake-mp3")
503 self.assertIsNotNone(sub_maker)
504 self.assertEqual(getattr(sub_maker, "subs", []), ["小米语音合成测试", "第二句话"])
505 self.assertEqual(len(getattr(sub_maker, "offset", [])), 2)
506
507 def test_chatterbox_voice_helpers(self):
508 """is_chatterbox_voice / get_chatterbox_voices basics and normalisation."""
509 self.assertTrue(vs.is_chatterbox_voice("chatterbox:default-Female"))
510 self.assertFalse(vs.is_chatterbox_voice("elevenlabs:abc:Rachel"))
511 self.assertFalse(vs.is_chatterbox_voice(""))
512 self.assertFalse(vs.is_chatterbox_voice(None))
513
514 # list entries are normalised to the chatterbox:<name> dispatcher format,
515 # and entries that are already prefixed are left untouched
516 with patch.object(
517 vs.config,
518 "chatterbox",
519 {"voices": ["narrator-Male", "chatterbox:host"]},
520 ):
521 self.assertEqual(
522 vs.get_chatterbox_voices(),
523 ["chatterbox:narrator-Male", "chatterbox:host"],
524 )
525
526 # a comma-separated string is also accepted (TOML-friendly)
527 with patch.object(vs.config, "chatterbox", {"voices": "alpha, beta ,"}):
528 self.assertEqual(
529 vs.get_chatterbox_voices(),
530 ["chatterbox:alpha", "chatterbox:beta"],
531 )
532
533 # with nothing configured the dropdown still gets a usable default
534 with patch.object(vs.config, "chatterbox", {}):
535 self.assertEqual(vs.get_chatterbox_voices(), ["chatterbox:default-Female"])
536
537 def test_chatterbox_tts_posts_to_openai_compatible_endpoint(self):
538 """Success path: POST /audio/speech, write audio, return legacy SubMaker."""
539
540 class _FakeResponse:
541 status_code = 200
542 content = b"RIFF-fake-wav"
543 text = ""
544
545 class _FakeClip:
546 duration = 3.5
547
548 def close(self):
549 pass
550
551 captured = {}
552
553 def _fake_post(url, json=None, headers=None, timeout=None):
554 captured["url"] = url
555 captured["json"] = json
556 captured["headers"] = headers
557 return _FakeResponse()
558
559 with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
560 vs.config,
561 "chatterbox",
562 {
563 "base_url": "http://localhost:4123/v1/",
564 "api_key": "secret",
565 "model_id": "chatterbox",
566 },
567 ), patch.object(
568 vs.requests, "post", side_effect=_fake_post
569 ) as post, patch.object(
570 vs, "AudioFileClip", return_value=_FakeClip()
571 ):
572 voice_file = str(Path(tmp_dir) / "chatterbox.mp3")
573 sub_maker = vs.chatterbox_tts(
574 text="Hello world. Second sentence.",
575 voice="default",
576 voice_file=voice_file,
577 voice_rate=1.2,
578 voice_volume=1.0,
579 )
580 generated_audio = Path(voice_file).read_bytes()
581
582 post.assert_called_once()
583 # trailing slash on base_url is stripped before appending /audio/speech
584 self.assertEqual(captured["url"], "http://localhost:4123/v1/audio/speech")
585 self.assertEqual(captured["json"]["model"], "chatterbox")
586 self.assertEqual(captured["json"]["voice"], "default")
587 self.assertEqual(captured["json"]["input"], "Hello world. Second sentence.")
588 self.assertAlmostEqual(captured["json"]["speed"], 1.2)
589 # api_key is forwarded as a bearer token
590 self.assertEqual(captured["headers"].get("Authorization"), "Bearer secret")
591 # volume is intentionally not part of the OpenAI speech payload
592 self.assertNotIn("volume", captured["json"])
593 self.assertEqual(generated_audio, b"RIFF-fake-wav")
594 self.assertIsNotNone(sub_maker)
595 self.assertTrue(getattr(sub_maker, "subs", []))
596
597 def test_chatterbox_tts_requires_base_url(self):
598 """Missing base_url short-circuits without any network call."""
599 with patch.object(
600 vs.config, "chatterbox", {"base_url": ""}
601 ), patch.object(vs.requests, "post") as post:
602 result = vs.chatterbox_tts(
603 text="hi", voice="default", voice_file="unused.mp3"
604 )
605 self.assertIsNone(result)
606 post.assert_not_called()
607
608 def test_chatterbox_tts_returns_none_on_http_error(self):
609 """A non-200 response is retried up to 3 times, then fails to None."""
610
611 class _FakeResponse:
612 status_code = 500
613 content = b""
614 text = "boom"
615
616 with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
617 vs.config, "chatterbox", {"base_url": "http://localhost:4123/v1"}
618 ), patch.object(
619 vs.requests, "post", return_value=_FakeResponse()
620 ) as post:
621 voice_file = str(Path(tmp_dir) / "chatterbox.mp3")
622 result = vs.chatterbox_tts(
623 text="hi", voice="default", voice_file=voice_file
624 )
625 self.assertIsNone(result)
626 self.assertEqual(post.call_count, 3)
627
628 def test_generate_subtitle_keeps_edge_provider_for_gemini_legacy_submaker(self):
629 """
630 验证 Gemini TTS 返回的 legacy 字幕结构在 edge provider 下可以直接产出
631 SRT,不会因为匹配失败而回退到 Whisper。
632 """
633 script = "Gemini subtitle generation should work now. Testing multiple lines."
634 sub_maker = vs.populate_legacy_submaker_with_full_text(
635 vs.ensure_legacy_submaker_fields(vs.SubMaker()),
636 script,
637 2.4,
638 )
639
640 with tempfile.TemporaryDirectory() as tmp_dir, patch.object(
641 task_service.config,
642 "app",
643 dict(task_service.config.app, subtitle_provider="edge"),
644 ), patch("app.services.subtitle.create") as whisper_create, patch(
645 "app.utils.utils.task_dir",
646 lambda tid="": str(Path(tmp_dir) / tid) if tid else str(Path(tmp_dir)),
647 ):
648 task_id = "gemini-subtitle-edge-task"
649 Path(tmp_dir, task_id).mkdir(parents=True, exist_ok=True)
650 subtitle_path = task_service.generate_subtitle(
651 task_id=task_id,
652 params=type("Params", (), {"subtitle_enabled": True})(),
653 video_script=script,
654 sub_maker=sub_maker,
655 audio_file="",
656 )
657
658 self.assertTrue(subtitle_path.endswith("subtitle.srt"))
659 self.assertTrue(Path(subtitle_path).exists())
660 self.assertFalse(whisper_create.called)
661 subtitle_content = Path(subtitle_path).read_text(encoding="utf-8")
662 self.assertIn("Gemini subtitle generation should work now", subtitle_content)
663 self.assertIn("Testing multiple lines", subtitle_content)
664
665 def test_script_split_keeps_thousand_separator_comma(self):
666 """
667 Edge TTS 会把 "1,000 years" 作为连续文本返回。脚本断句时不能把
668 数字中间的英文逗号当成句子边界,否则字幕聚合会出现 issue #894
669 里的 sub_items 数量少于 script_lines,并错误回退 Whisper。
670 """
671 text = (
672 "It takes about 1,000 years for a single drop of water to finish "
673 "the whole trip!"
674 )
675
676 self.assertEqual(
677 utils.split_string_by_punctuations(text),
678 [
679 (
680 "It takes about 1,000 years for a single drop of water to finish "
681 "the whole trip"
682 )
683 ],
684 )
685
686 def test_edge_cue_aggregation_handles_thousand_separator_comma(self):
687 """
688 复现 issue #894 的关键形态:Edge cues 中最后一句作为连续文本返回,
689 包含 `1,000 years`。脚本断句必须与 cues 聚合结果一致,不能把它
690 拆成两条字幕。
691 """
692 text = (
693 "The ocean isn't just sitting stil, it moves around the world like a massive "
694 "amusement park ride! Cold water at the North and South Poles sinks to the "
695 "bottom because it is heavy and salty. At the same time, warm water from the "
696 "sunny equator flows along the top to take its place. This creates a giant "
697 "underwater conveyor belt that travels all the way around the Earth. It takes "
698 "about 1,000 years for a single drop of water to finish the whole trip!"
699 )
700 script_lines = utils.split_string_by_punctuations(text)
701 cues = []
702 for index, line in enumerate(script_lines):
703 # Edge 的 cue content 经常没有脚本里的空格和标点布局,这里去掉空格
704 # 来模拟更严格的匹配场景。
705 cues.append(
706 SimpleNamespace(
707 content=line.replace(" ", ""),
708 start=timedelta(seconds=index),
709 end=timedelta(seconds=index + 0.8),
710 )
711 )
712 sub_maker = SimpleNamespace(cues=cues)
713
714 sub_items = vs._build_subtitle_items_from_edge_cues(sub_maker, script_lines)
715
716 self.assertEqual(len(sub_items), len(script_lines))
717 self.assertIn("1,000 years", sub_items[-1])
718
719 def test_script_split_supports_arabic_punctuation(self):
720 """
721 阿拉伯语脚本常用 ، ؛ ؟ 作为自然断句标点。断句阶段必须识别这些
722 标点,否则 edge-tts cue 的停顿边界和脚本行边界会错位。
723 """
724 text = "مرحبا بالعالم، كيف حالك؟ هذا اختبار؛ يعمل بشكل جيد."
725
726 self.assertEqual(
727 utils.split_string_by_punctuations(text),
728 [
729 "مرحبا بالعالم",
730 "كيف حالك",
731 "هذا اختبار",
732 "يعمل بشكل جيد",
733 ],
734 )
735
736 def test_match_script_line_normalizes_arabic_letter_forms(self):
737 """
738 edge-tts 可能把阿拉伯语中的不同字母形态归一化,或返回带变音符号、
739 Tatweel 的 cue 文本。匹配时应容错,但最终字幕仍保留原始脚本文案。
740 """
741 script_lines = ["أهلاً وسهلاً بك في المدرسة"]
742
743 matched = vs._match_script_line(
744 script_lines,
745 "اهلا وسهلا بك في المدرسه",
746 0,
747 )
748
749 self.assertEqual(matched, script_lines[0])
750
751 def test_edge_cue_aggregation_handles_arabic_variant_forms(self):
752 """
753 复现阿拉伯语字幕失败的核心路径:脚本包含 أ/ة 等字母形态,edge cue
754 返回 ا/ه 等归一化形态时,聚合仍应生成完整字幕,避免回退 Whisper。
755 """
756 text = "أهلاً وسهلاً بك في المدرسة؟ هذا اختبار رائع، شكراً لك."
757 script_lines = utils.split_string_by_punctuations(text)
758 cue_texts = [
759 "اهلا وسهلا بك في المدرسه",
760 "هذا اختبار رائع",
761 "شكرا لك",
762 ]
763 sub_maker = SimpleNamespace(
764 cues=[
765 SimpleNamespace(
766 content=cue_text,
767 start=timedelta(seconds=index),
768 end=timedelta(seconds=index + 0.8),
769 )
770 for index, cue_text in enumerate(cue_texts)
771 ]
772 )
773
774 sub_items = vs._build_subtitle_items_from_edge_cues(sub_maker, script_lines)
775
776 self.assertEqual(len(sub_items), len(script_lines))
777 self.assertIn("أهلاً وسهلاً بك في المدرسة", sub_items[0])
778 self.assertIn("شكراً لك", sub_items[-1])
779
780 def test_create_subtitle_ignores_markdown_separator_lines(self):
781 """
782 用户手动脚本可能包含 `---` 这类 Markdown 分隔符。TTS 不会朗读
783 这些符号行,字幕聚合也不应把它们当成目标字幕行,否则后续真实
784 字幕会卡住并回退到 Whisper。
785 """
786 text = "第一段\n---\n第二段"
787 sub_maker = SimpleNamespace(
788 cues=[
789 SimpleNamespace(
790 content="第一段",
791 start=timedelta(seconds=0),
792 end=timedelta(seconds=0.8),
793 ),
794 SimpleNamespace(
795 content="第二段",
796 start=timedelta(seconds=1),
797 end=timedelta(seconds=1.8),
798 ),
799 ]
800 )
801
802 with tempfile.TemporaryDirectory() as tmp_dir:
803 subtitle_file = Path(tmp_dir) / "subtitle.srt"
804 vs.create_subtitle(
805 sub_maker=sub_maker,
806 text=text,
807 subtitle_file=str(subtitle_file),
808 )
809
810 subtitle_content = subtitle_file.read_text(encoding="utf-8")
811
812 self.assertIn("第一段", subtitle_content)
813 self.assertIn("第二段", subtitle_content)
814 self.assertNotIn("---", subtitle_content)
815 self.assertNotIn("00:00:00,000 --> 00:00:00,000", subtitle_content)
816
817 def test_create_subtitle_ignores_markdown_underscore_marks(self):
818 """
819 `_` 常被用户用作 Markdown 强调标记,但 TTS 返回的 cue 通常不包含
820 这些格式符。匹配时应忽略 `_`,避免生成空字幕或回退到 Whisper。
821 """
822 text = "这是_a_测试。"
823 sub_maker = SimpleNamespace(
824 cues=[
825 SimpleNamespace(
826 content="这是a测试",
827 start=timedelta(seconds=0),
828 end=timedelta(seconds=0.8),
829 ),
830 ]
831 )
832
833 with tempfile.TemporaryDirectory() as tmp_dir:
834 subtitle_file = Path(tmp_dir) / "subtitle.srt"
835 vs.create_subtitle(
836 sub_maker=sub_maker,
837 text=text,
838 subtitle_file=str(subtitle_file),
839 )
840
841 subtitle_content = subtitle_file.read_text(encoding="utf-8")
842
843 self.assertIn("这是a测试", subtitle_content)
844 self.assertNotIn("这是_a_测试", subtitle_content)
845 self.assertNotIn("00:00:00,000 --> 00:00:00,000", subtitle_content)
846
847 def test_convert_rate_to_percent_signs_zero_rate(self):
848 # Rates near but not exactly 1.0 round to 0 percent. edge-tts rejects
849 # an unsigned "0%" (ValueError: Invalid rate '0%'), so the helper must
850 # emit a sign-prefixed "+0%". Regression test for that crash.
851 self.assertEqual(vs.convert_rate_to_percent(1.0), "+0%")
852 self.assertEqual(vs.convert_rate_to_percent(1.004), "+0%")
853 self.assertEqual(vs.convert_rate_to_percent(0.997), "+0%")
854 self.assertEqual(vs.convert_rate_to_percent(1.5), "+50%")
855 self.assertEqual(vs.convert_rate_to_percent(0.8), "-20%")
856
857 def test_convert_rate_to_percent_invalid_values_default_to_normal(self):
858 # API 和批处理脚本可能把空语速传成 0、None 或空字符串;这些都不应让
859 # edge-tts 收到 -100% 或触发异常,而是按正常语速处理。
860 self.assertEqual(vs.convert_rate_to_percent(0), "+0%")
861 self.assertEqual(vs.convert_rate_to_percent(0.0), "+0%")
862 self.assertEqual(vs.convert_rate_to_percent(None), "+0%")
863 self.assertEqual(vs.convert_rate_to_percent(""), "+0%")
864
865
866 class TestElevenLabsVoice(unittest.TestCase):
867
868 def test_is_elevenlabs_voice_true(self):
869 self.assertTrue(vs.is_elevenlabs_voice("elevenlabs:pNInz6obpgDQGcFmaJgB:Adam"))
870
871 def test_is_elevenlabs_voice_false_azure(self):
872 self.assertFalse(vs.is_elevenlabs_voice("zh-CN-XiaoxiaoNeural-Female"))
873
874 def test_is_elevenlabs_voice_false_siliconflow(self):
875 self.assertFalse(vs.is_elevenlabs_voice("siliconflow:model:voice-Male"))
876
877 def test_is_elevenlabs_voice_empty(self):
878 self.assertFalse(vs.is_elevenlabs_voice(""))
879
880 def test_is_elevenlabs_voice_none(self):
881 self.assertFalse(vs.is_elevenlabs_voice(None))
882
883 def test_get_elevenlabs_voices_empty_api_key(self):
884 result = vs.get_elevenlabs_voices("")
885 self.assertEqual(result, [])
886
887 @patch("app.services.voice.requests.get")
888 def test_get_elevenlabs_voices_success(self, mock_get):
889 mock_get.return_value.status_code = 200
890 mock_get.return_value.json.return_value = {
891 "voices": [
892 {"voice_id": "abc123", "name": "Adam"},
893 {"voice_id": "def456", "name": "Rachel"},
894 ]
895 }
896 result = vs.get_elevenlabs_voices("fake-api-key")
897 self.assertEqual(result, [
898 "elevenlabs:abc123:Adam",
899 "elevenlabs:def456:Rachel",
900 ])
901 mock_get.assert_called_once()
902 call_kwargs = mock_get.call_args
903 self.assertIn("xi-api-key", call_kwargs.kwargs.get("headers", {}))
904
905 @patch("app.services.voice.requests.get")
906 def test_get_elevenlabs_voices_http_error(self, mock_get):
907 mock_get.return_value.status_code = 401
908 mock_get.return_value.text = "Unauthorized"
909 result = vs.get_elevenlabs_voices("bad-key")
910 self.assertEqual(result, [])
911
912 @patch("app.services.voice.requests.get")
913 def test_get_elevenlabs_voices_network_error(self, mock_get):
914 import requests as req_lib
915 mock_get.side_effect = req_lib.exceptions.ConnectionError("timeout")
916 result = vs.get_elevenlabs_voices("fake-key")
917 self.assertEqual(result, [])
918
919 @patch("app.services.voice.requests.post")
920 @patch("app.services.voice.AudioFileClip")
921 @patch("app.services.voice.config")
922 def test_elevenlabs_tts_success(self, mock_config, mock_clip_cls, mock_post):
923 mock_config.elevenlabs.get.return_value = "fake-api-key"
924 mock_post.return_value.status_code = 200
925 mock_post.return_value.content = b"fake-mp3-bytes"
926 mock_clip = mock_clip_cls.return_value.__enter__.return_value
927 mock_clip_cls.return_value.duration = 3.0
928 mock_clip_cls.return_value.close = lambda: None
929
930 with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
931 out_path = f.name
932
933 try:
934 result = vs.elevenlabs_tts("Hello world", "abc123", out_path)
935 self.assertIsNotNone(result)
936 self.assertTrue(hasattr(result, "subs"))
937 self.assertTrue(hasattr(result, "offset"))
938 finally:
939 if os.path.exists(out_path):
940 os.remove(out_path)
941
942 @patch("app.services.voice.config")
943 def test_elevenlabs_tts_no_api_key(self, mock_config):
944 mock_config.elevenlabs.get.return_value = ""
945 result = vs.elevenlabs_tts("Hello", "abc123", "/tmp/test.mp3")
946 self.assertIsNone(result)
947
948 @patch("app.services.voice.config")
949 def test_elevenlabs_tts_empty_text(self, mock_config):
950 mock_config.elevenlabs.get.return_value = "fake-key"
951 result = vs.elevenlabs_tts(" ", "abc123", "/tmp/test.mp3")
952 self.assertIsNone(result)
953
954
955 if __name__ == "__main__":
956 # python -m unittest test.services.test_voice.TestVoiceService.test_azure_tts_v1
957 # python -m unittest test.services.test_voice.TestVoiceService.test_azure_tts_v2
958 unittest.main()
959
959 lines PYTHON