Files
wehub-resource-sync 9a6da7d40d
Publish Docker image / Build and publish (push) Failing after 0s
CI / Python tests (push) Failing after 4s
chore: import upstream snapshot with attribution
2026-07-13 12:03:55 +08:00

1034 lines
42 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 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 &lt; B &amp; \"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()