From 7639c9d2899a3ff45469979629915029645d44de Mon Sep 17 00:00:00 2001 From: linyq-laien Date: Thu, 16 Jul 2026 19:11:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(audio):=20=E6=96=B0=E5=A2=9Evoxcpm-0.5b?= =?UTF-8?q?=E5=92=8Cvoxcpm-2b=E6=9C=AC=E5=9C=B0=E8=AF=AD=E9=9F=B3=E5=90=88?= =?UTF-8?q?=E6=88=90=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增完整的voxcpm系列本地语音合成功能,包括: - 新增配置常量、加载逻辑与示例配置项 - 集成webui界面:引擎选型、参数配置与语音预览 - 实现核心调用逻辑,包含API地址标准化、重试机制与文件处理 - 新增两套单元测试覆盖核心流程 - 在字幕转录设置页面补充转录工具包的下载提示文本 --- app/config/config.py | 10 ++ app/services/test_voxcpm2_tts_unittest.py | 81 +++++++++++ app/services/test_voxcpm_tts_unittest.py | 83 +++++++++++ app/services/voice.py | 164 ++++++++++++++++++++++ config.example.toml | 29 ++++ webui/components/audio_settings.py | 136 ++++++++++++++++++ webui/components/script_settings.py | 2 + webui/i18n/en.json | 26 ++++ webui/i18n/zh.json | 26 ++++ 9 files changed, 557 insertions(+) create mode 100644 app/services/test_voxcpm2_tts_unittest.py create mode 100644 app/services/test_voxcpm_tts_unittest.py diff --git a/app/config/config.py b/app/config/config.py index 908afea..0316f53 100644 --- a/app/config/config.py +++ b/app/config/config.py @@ -17,10 +17,16 @@ INDEXTTS2_ENGINE = "indextts2" INDEXTTS2_DISPLAY_NAME = "IndexTTS-2" OMNIVOICE_ENGINE = "omnivoice" OMNIVOICE_DISPLAY_NAME = "OmniVoice" +VOXCPM_ENGINE = "voxcpm_05b" +VOXCPM_DISPLAY_NAME = "VoxCPM-0.5B" +VOXCPM2_ENGINE = "voxcpm_2b" +VOXCPM2_DISPLAY_NAME = "VoxCPM-2B" INDEXTTS_VOICE_PREFIX = f"{INDEXTTS_ENGINE}:" INDEXTTS_MACOS_VOICE_PREFIX = f"{INDEXTTS_MACOS_ENGINE}:" INDEXTTS2_VOICE_PREFIX = f"{INDEXTTS2_ENGINE}:" OMNIVOICE_VOICE_PREFIX = f"{OMNIVOICE_ENGINE}:" +VOXCPM_VOICE_PREFIX = f"{VOXCPM_ENGINE}:" +VOXCPM2_VOICE_PREFIX = f"{VOXCPM2_ENGINE}:" INDEXTTS2_EMOTION_VECTOR_FIELDS = ( ("happy", "vec_happy"), ("angry", "vec_angry"), @@ -175,6 +181,8 @@ def save_config(): _cfg["indextts_macos"] = indextts_macos _cfg["indextts2"] = indextts2 _cfg["omnivoice"] = omnivoice + _cfg["voxcpm_05b"] = voxcpm_05b + _cfg["voxcpm_2b"] = voxcpm_2b _cfg["doubaotts"] = doubaotts f.write(toml.dumps(_cfg)) @@ -194,6 +202,8 @@ indextts = _cfg.get("indextts", {}) indextts_macos = _cfg.get("indextts_macos", {}) indextts2 = _cfg.get("indextts2", {}) omnivoice = _cfg.get("omnivoice", {}) +voxcpm_05b = _cfg.get("voxcpm_05b", {}) +voxcpm_2b = _cfg.get("voxcpm_2b", {}) doubaotts = _cfg.get("doubaotts", {}) hostname = socket.gethostname() diff --git a/app/services/test_voxcpm2_tts_unittest.py b/app/services/test_voxcpm2_tts_unittest.py new file mode 100644 index 0000000..38fea46 --- /dev/null +++ b/app/services/test_voxcpm2_tts_unittest.py @@ -0,0 +1,81 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from app.services import voice + + +class FakeResponse: + def __init__(self, *, status_code=200, content=b"", payload=None): + self.status_code = status_code + self.content = content + self._payload = payload or {} + self.text = "OK" + + def json(self): + return self._payload + + +class VoxCPM2TtsTests(unittest.TestCase): + def setUp(self): + self.original_config = dict(voice.config.voxcpm_2b) + self.original_proxy = dict(voice.config.proxy) + + def tearDown(self): + voice.config.voxcpm_2b.clear() + voice.config.voxcpm_2b.update(self.original_config) + voice.config.proxy.clear() + voice.config.proxy.update(self.original_proxy) + + def test_voice_design_sends_control_and_downloads_wav(self): + voice.config.voxcpm_2b.clear() + voice.config.voxcpm_2b.update({ + "api_url": "http://127.0.0.1:7863/v1/audio/speech", + "mode": "design", "control": "温暖自然的年轻女声", + "cfg_value": 2.0, "inference_timesteps": 10, + "normalize": True, "denoise": False, "output_48k": True, + "context_aware": True, "streaming": False, + }) + voice.config.proxy.clear() + with tempfile.TemporaryDirectory() as temp_dir: + output = Path(temp_dir) / "output.wav" + with ( + patch("app.services.voice.requests.post", return_value=FakeResponse(payload={"downloads": {"wav": "/download/result.wav"}})) as post, + patch("app.services.voice.requests.get", return_value=FakeResponse(content=b"wav-2b")) as get, + patch("app.services.voice.get_audio_duration_from_file", return_value=2.0), + ): + result = voice.voxcpm2_tts(" 高质量旁白。 ", "voxcpm_2b:design", str(output)) + self.assertIsNotNone(result) + self.assertEqual(output.read_bytes(), b"wav-2b") + self.assertEqual(post.call_args.args[0], "http://127.0.0.1:7863/tts") + self.assertIsNone(post.call_args.kwargs["files"]) + self.assertEqual(post.call_args.kwargs["data"]["control"], "温暖自然的年轻女声") + self.assertEqual(post.call_args.kwargs["data"]["output_48k"], "true") + self.assertEqual(get.call_args.args[0], "http://127.0.0.1:7863/download/result.wav") + + def test_clone_uploads_reference_audio(self): + voice.config.voxcpm_2b.clear() + voice.config.voxcpm_2b.update({ + "api_url": "http://127.0.0.1:7863/tts/batch", + "mode": "clone", "prompt_text": "参考音频文本", + }) + voice.config.proxy.clear() + with tempfile.TemporaryDirectory() as temp_dir: + reference = Path(temp_dir) / "reference.wav" + output = Path(temp_dir) / "output.wav" + reference.write_bytes(b"reference") + with ( + patch("app.services.voice.requests.post", return_value=FakeResponse(payload={"downloads": {"wav": "/download/result.wav"}})) as post, + patch("app.services.voice.requests.get", return_value=FakeResponse(content=b"wav")), + patch("app.services.voice.get_audio_duration_from_file", return_value=1.0), + ): + result = voice.voxcpm2_tts("克隆测试", f"voxcpm_2b:{reference}", str(output)) + self.assertIsNotNone(result) + self.assertEqual(post.call_args.args[0], "http://127.0.0.1:7863/tts") + self.assertIn("reference_audio", post.call_args.kwargs["files"]) + self.assertEqual(post.call_args.kwargs["data"]["prompt_text"], "参考音频文本") + + +if __name__ == "__main__": + unittest.main() diff --git a/app/services/test_voxcpm_tts_unittest.py b/app/services/test_voxcpm_tts_unittest.py new file mode 100644 index 0000000..4892eb2 --- /dev/null +++ b/app/services/test_voxcpm_tts_unittest.py @@ -0,0 +1,83 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from app.services import voice + + +class FakeResponse: + def __init__(self, *, status_code=200, content=b"", payload=None): + self.status_code = status_code + self.content = content + self._payload = payload or {} + self.text = "OK" + + def json(self): + return self._payload + + +class VoxCPMTtsTests(unittest.TestCase): + def setUp(self): + self.original_config = dict(voice.config.voxcpm_05b) + self.original_proxy = dict(voice.config.proxy) + + def tearDown(self): + voice.config.voxcpm_05b.clear() + voice.config.voxcpm_05b.update(self.original_config) + voice.config.proxy.clear() + voice.config.proxy.update(self.original_proxy) + + def test_uploads_prompt_audio_and_downloads_generated_wav(self): + voice.config.voxcpm_05b.clear() + voice.config.voxcpm_05b.update({ + "api_url": "http://127.0.0.1:7864/v1/audio/speech", + "prompt_text": "参考文本", + "cfg_value": 2.1, + "inference_timesteps": 12, + "max_length": 2048, + "normalize": True, + "denoise": False, + }) + voice.config.proxy.clear() + + with tempfile.TemporaryDirectory() as temp_dir: + reference = Path(temp_dir) / "reference.wav" + output = Path(temp_dir) / "output.wav" + reference.write_bytes(b"reference") + with ( + patch("app.services.voice.requests.post", return_value=FakeResponse(payload={"audio_url": "/outputs/result.wav"})) as post, + patch("app.services.voice.requests.get", return_value=FakeResponse(content=b"wav-bytes")) as get, + patch("app.services.voice.get_audio_duration_from_file", return_value=1.5), + ): + result = voice.voxcpm_tts(" 测试文本。 ", f"voxcpm_05b:{reference}", str(output)) + + self.assertIsNotNone(result) + self.assertEqual(output.read_bytes(), b"wav-bytes") + self.assertEqual(post.call_args.args[0], "http://127.0.0.1:7864/tts") + self.assertEqual(post.call_args.kwargs["data"], { + "text": "测试文本。", "prompt_text": "参考文本", "cfg_value": 2.1, + "inference_timesteps": 12, "max_length": 2048, + "normalize": "true", "denoise": "false", + }) + self.assertIn("prompt_audio", post.call_args.kwargs["files"]) + self.assertEqual(get.call_args.args[0], "http://127.0.0.1:7864/outputs/result.wav") + + def test_default_voice_does_not_upload_audio(self): + voice.config.voxcpm_05b.clear() + voice.config.voxcpm_05b.update({"api_url": "http://127.0.0.1:7864"}) + voice.config.proxy.clear() + with tempfile.TemporaryDirectory() as temp_dir: + output = Path(temp_dir) / "output.wav" + with ( + patch("app.services.voice.requests.post", return_value=FakeResponse(payload={"audio_url": "/outputs/result.wav"})) as post, + patch("app.services.voice.requests.get", return_value=FakeResponse(content=b"wav")), + patch("app.services.voice.get_audio_duration_from_file", return_value=1.0), + ): + result = voice.voxcpm_tts("默认音色", "voxcpm_05b:default", str(output)) + self.assertIsNotNone(result) + self.assertIsNone(post.call_args.kwargs["files"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/services/voice.py b/app/services/voice.py index 20a6223..fb2f333 100644 --- a/app/services/voice.py +++ b/app/services/voice.py @@ -1313,6 +1313,14 @@ def tts( if tts_engine == config.OMNIVOICE_ENGINE: logger.info("分发到 OmniVoice") return omnivoice_tts(text, voice_name, voice_file, speed=voice_rate) + + if tts_engine == config.VOXCPM_ENGINE: + logger.info("分发到 VoxCPM-0.5B") + return voxcpm_tts(text, voice_name, voice_file) + + if tts_engine == config.VOXCPM2_ENGINE: + logger.info("分发到 VoxCPM-2B") + return voxcpm2_tts(text, voice_name, voice_file) if tts_engine == "doubaotts": logger.info("分发到豆包语音 TTS") @@ -1803,6 +1811,8 @@ def tts_multiple(task_id: str, list_script: list, voice_name: str, voice_rate: f config.INDEXTTS_MACOS_ENGINE, config.INDEXTTS2_ENGINE, config.OMNIVOICE_ENGINE, + config.VOXCPM_ENGINE, + config.VOXCPM2_ENGINE, ) else ".mp3" for item in list_script: @@ -1838,6 +1848,8 @@ def tts_multiple(task_id: str, list_script: list, voice_name: str, voice_rate: f config.INDEXTTS_MACOS_ENGINE, config.INDEXTTS2_ENGINE, config.OMNIVOICE_ENGINE, + config.VOXCPM_ENGINE, + config.VOXCPM2_ENGINE, ) or tts_engine == "doubaotts" ): @@ -2299,6 +2311,20 @@ def parse_omnivoice_voice(voice_name: str) -> str: return voice_name +def parse_voxcpm_voice(voice_name: str) -> str: + """解析 VoxCPM-0.5B 的可选参考音频路径。""" + if isinstance(voice_name, str) and voice_name.startswith(config.VOXCPM_VOICE_PREFIX): + return voice_name[len(config.VOXCPM_VOICE_PREFIX):] + return voice_name + + +def parse_voxcpm2_voice(voice_name: str) -> str: + """解析 VoxCPM-2B 的模式或参考音频路径。""" + if isinstance(voice_name, str) and voice_name.startswith(config.VOXCPM2_VOICE_PREFIX): + return voice_name[len(config.VOXCPM2_VOICE_PREFIX):] + return voice_name + + def indextts_tts(text: str, voice_name: str, voice_file: str, speed: float = 1.0) -> Union[SubMaker, None]: """ 使用 IndexTTS-1.5 API 进行零样本语音克隆 @@ -2742,6 +2768,144 @@ def indextts2_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker return None +def _normalize_voxcpm_api_url(api_url: str) -> str: + api_url = (api_url or "http://127.0.0.1:7864").strip().rstrip("/") + if api_url.endswith("/v1/audio/speech"): + api_url = api_url[:-len("/v1/audio/speech")] + if api_url.endswith("/tts"): + return api_url + return f"{api_url}/tts" + + +def voxcpm_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]: + """使用 VoxCPM-0.5B-Pack 的 multipart /tts 接口生成 WAV。""" + voxcpm_config = getattr(config, "voxcpm_05b", {}) or {} + api_url = _normalize_voxcpm_api_url(voxcpm_config.get("api_url", "http://127.0.0.1:7864")) + reference_audio = parse_voxcpm_voice(voice_name) + if reference_audio in ("", "default") or not os.path.isfile(reference_audio): + reference_audio = voxcpm_config.get("reference_audio", "") or "" + if reference_audio and not os.path.isfile(reference_audio): + logger.error(f"VoxCPM-0.5B 参考音频文件不存在: {reference_audio}") + return None + + data = {"text": text.strip()} + optional_fields = { + "prompt_text": voxcpm_config.get("prompt_text"), + "cfg_value": voxcpm_config.get("cfg_value"), + "inference_timesteps": voxcpm_config.get("inference_timesteps"), + "max_length": voxcpm_config.get("max_length"), + } + for key, value in optional_fields.items(): + if value not in (None, ""): + data[key] = value + for key in ("normalize", "denoise"): + if key in voxcpm_config: + data[key] = str(bool(voxcpm_config[key])).lower() + + proxies = _get_configured_proxies() + for attempt in range(3): + files = {} + try: + if reference_audio: + files["prompt_audio"] = open(reference_audio, "rb") + response = requests.post(api_url, data=data, files=files or None, proxies=proxies, timeout=300) + if response.status_code == 200: + result = response.json() + audio_url = result.get("audio_url", "") if isinstance(result, dict) else "" + if audio_url: + audio_response = requests.get(urljoin(api_url, audio_url), proxies=proxies, timeout=180) + if audio_response.status_code == 200: + with open(voice_file, "wb") as output: + output.write(audio_response.content) + if os.path.getsize(voice_file) > 0: + sub_maker = new_sub_maker() + duration = get_audio_duration_from_file(voice_file) + duration_ms = int(duration * 1000) if duration > 0 else max(1000, len(text) * 200) + add_subtitle_event(sub_maker, 0, duration_ms * 10000, text) + return sub_maker + logger.error(f"VoxCPM-0.5B API 响应中没有有效音频地址: {result}") + else: + logger.error(f"VoxCPM-0.5B API 调用失败: {response.status_code} - {response.text}") + except (ValueError, requests.exceptions.RequestException) as exc: + logger.error(f"VoxCPM-0.5B TTS 请求失败 (尝试 {attempt + 1}/3): {exc}") + finally: + for file_obj in files.values(): + file_obj.close() + if attempt < 2: + time.sleep(2) + return None + + +def _normalize_voxcpm2_api_url(api_url: str) -> str: + api_url = (api_url or "http://127.0.0.1:7863").strip().rstrip("/") + for suffix in ("/v1/audio/speech", "/tts/batch"): + if api_url.endswith(suffix): + api_url = api_url[:-len(suffix)] + return api_url if api_url.endswith("/tts") else f"{api_url}/tts" + + +def voxcpm2_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]: + """使用 VoxCPM-2B-Pack 的 /tts 接口进行音色设计或参考音频克隆。""" + pack_config = getattr(config, "voxcpm_2b", {}) or {} + api_url = _normalize_voxcpm2_api_url(pack_config.get("api_url", "http://127.0.0.1:7863")) + mode = str(pack_config.get("mode", "design")) + parsed_voice = parse_voxcpm2_voice(voice_name) + reference_audio = "" + if mode == "clone": + reference_audio = parsed_voice if parsed_voice and os.path.isfile(parsed_voice) else pack_config.get("reference_audio", "") + if not reference_audio or not os.path.isfile(reference_audio): + logger.error(f"VoxCPM-2B 参考音频文件不存在: {reference_audio}") + return None + + data = {"text": text.strip()} + optional_fields = { + "control": pack_config.get("control"), + "prompt_text": pack_config.get("prompt_text") if mode == "clone" else None, + "cfg_value": pack_config.get("cfg_value"), + "inference_timesteps": pack_config.get("inference_timesteps"), + } + for key, value in optional_fields.items(): + if value not in (None, ""): + data[key] = value + for key in ("normalize", "denoise", "output_48k", "context_aware", "streaming"): + if key in pack_config: + data[key] = str(bool(pack_config[key])).lower() + + proxies = _get_configured_proxies() + for attempt in range(3): + files = {} + try: + if reference_audio: + files["reference_audio"] = open(reference_audio, "rb") + response = requests.post(api_url, data=data, files=files or None, proxies=proxies, timeout=600) + if response.status_code == 200: + result = response.json() + downloads = result.get("downloads", {}) if isinstance(result, dict) else {} + audio_url = downloads.get("wav", "") if isinstance(downloads, dict) else "" + if audio_url: + audio_response = requests.get(urljoin(api_url, audio_url), proxies=proxies, timeout=180) + if audio_response.status_code == 200: + with open(voice_file, "wb") as output: + output.write(audio_response.content) + if os.path.getsize(voice_file) > 0: + sub_maker = new_sub_maker() + duration = get_audio_duration_from_file(voice_file) + duration_ms = int(duration * 1000) if duration > 0 else max(1000, len(text) * 200) + add_subtitle_event(sub_maker, 0, duration_ms * 10000, text) + return sub_maker + logger.error(f"VoxCPM-2B API 响应中没有有效 WAV 下载地址: {result}") + else: + logger.error(f"VoxCPM-2B API 调用失败: {response.status_code} - {response.text}") + except (ValueError, requests.exceptions.RequestException) as exc: + logger.error(f"VoxCPM-2B TTS 请求失败 (尝试 {attempt + 1}/3): {exc}") + finally: + for file_obj in files.values(): + file_obj.close() + if attempt < 2: + time.sleep(2) + return None + + def _normalize_omnivoice_api_url(api_url: str) -> str: api_url = (api_url or "http://127.0.0.1:7866/tts").strip() if api_url.endswith("/tts"): diff --git a/config.example.toml b/config.example.toml index d0c5aa0..9e3814a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -239,6 +239,35 @@ postprocess_output = true preprocess_prompt = true +[voxcpm_05b] + # VoxCPM-0.5B-Pack 本地语音合成/参考音频克隆配置 + # 启动整合包后,可填写服务根地址或完整 /tts 地址 + api_url = "http://127.0.0.1:7864" + reference_audio_source = "resource" + reference_audio = "" + prompt_text = "" + cfg_value = 2.0 + inference_timesteps = 10 + max_length = 4096 + normalize = true + denoise = false + +[voxcpm_2b] + # VoxCPM-2B-Pack 本地语音合成、音色设计与参考音频克隆 + api_url = "http://127.0.0.1:7863" + mode = "design" + control = "" + reference_audio_source = "resource" + reference_audio = "" + prompt_text = "" + cfg_value = 2.0 + inference_timesteps = 10 + normalize = true + denoise = false + output_48k = true + context_aware = true + streaming = false + [doubaotts] # 豆包语音 TTS 配置 # 新版配置优先填写 API Key;旧版 appid/token 配置仍兼容 diff --git a/webui/components/audio_settings.py b/webui/components/audio_settings.py index 320b09c..d5e31a2 100644 --- a/webui/components/audio_settings.py +++ b/webui/components/audio_settings.py @@ -46,6 +46,8 @@ LOCAL_TTS_ENGINES = { config.INDEXTTS_MACOS_ENGINE, config.INDEXTTS2_ENGINE, config.OMNIVOICE_ENGINE, + config.VOXCPM_ENGINE, + config.VOXCPM2_ENGINE, } @@ -79,6 +81,8 @@ def get_tts_engine_options(tr=lambda key: key): config.INDEXTTS_MACOS_ENGINE: config.INDEXTTS_MACOS_DISPLAY_NAME, config.INDEXTTS2_ENGINE: config.INDEXTTS2_DISPLAY_NAME, config.OMNIVOICE_ENGINE: config.OMNIVOICE_DISPLAY_NAME, + config.VOXCPM_ENGINE: config.VOXCPM_DISPLAY_NAME, + config.VOXCPM2_ENGINE: config.VOXCPM2_DISPLAY_NAME, "edge_tts": "Edge TTS", "qwen3_tts": tr("Tongyi Qwen3 TTS"), "tencent_tts": tr("Tencent Cloud TTS"), @@ -157,6 +161,18 @@ def get_tts_engine_descriptions(tr=lambda key: key): "use_case": tr("OmniVoice use case"), "registration": None }, + config.VOXCPM_ENGINE: { + "title": config.VOXCPM_DISPLAY_NAME, + "features": tr("VoxCPM features"), + "use_case": tr("VoxCPM use case"), + "registration": None, + }, + config.VOXCPM2_ENGINE: { + "title": config.VOXCPM2_DISPLAY_NAME, + "features": tr("VoxCPM2 features"), + "use_case": tr("VoxCPM2 use case"), + "registration": None, + }, "doubaotts": { "title": tr("Doubao TTS"), "features": tr("Doubao TTS features"), @@ -603,6 +619,10 @@ def render_tts_settings(tr): render_indextts2_tts_settings(tr) elif selected_engine == config.OMNIVOICE_ENGINE: render_omnivoice_tts_settings(tr) + elif selected_engine == config.VOXCPM_ENGINE: + render_voxcpm_tts_settings(tr) + elif selected_engine == config.VOXCPM2_ENGINE: + render_voxcpm2_tts_settings(tr) elif selected_engine == "doubaotts": render_doubaotts_settings(tr) @@ -1564,6 +1584,109 @@ def render_omnivoice_tts_settings(tr): st.session_state["voice_pitch"] = 1.0 +def render_voxcpm_tts_settings(tr): + """渲染 VoxCPM-0.5B-Pack 设置。""" + pack_config = config.voxcpm_05b + api_url = st.text_input( + tr("API URL"), + value=pack_config.get("api_url", "http://127.0.0.1:7864"), + help=tr("VoxCPM API URL Help"), + ) + use_reference = st.checkbox( + tr("VoxCPM Use Reference Audio"), + value=bool(pack_config.get("reference_audio", "")), + help=tr("VoxCPM Use Reference Audio Help"), + ) + source = pack_config.get("reference_audio_source", "resource") + reference_audio = pack_config.get("reference_audio", "") + prompt_text = pack_config.get("prompt_text", "") + if use_reference: + source, reference_audio = render_indextts_reference_audio_selector(tr, pack_config, "voxcpm_05b") + prompt_text = st.text_area( + tr("VoxCPM Prompt Text"), value=prompt_text, + help=tr("VoxCPM Prompt Text Help"), height=90, + ) + else: + reference_audio = "" + + with st.expander(tr("Advanced Parameters"), expanded=False): + cfg_value = st.slider("CFG Value", 1.0, 3.0, float(pack_config.get("cfg_value", 2.0)), 0.1) + inference_timesteps = st.slider("Inference Timesteps", 1, 50, int(pack_config.get("inference_timesteps", 10)), 1) + max_length = st.number_input("Max Length", 128, 8192, int(pack_config.get("max_length", 4096)), 128) + normalize = st.checkbox(tr("VoxCPM Normalize"), value=bool(pack_config.get("normalize", True))) + denoise = st.checkbox(tr("VoxCPM Denoise"), value=bool(pack_config.get("denoise", False))) + + with st.expander(tr("VoxCPM Usage Instructions Title"), expanded=False): + st.markdown(tr("VoxCPM Usage Instructions")) + + pack_config.update({ + "api_url": api_url, "reference_audio_source": source, + "reference_audio": reference_audio, "prompt_text": prompt_text, + "cfg_value": cfg_value, "inference_timesteps": inference_timesteps, + "max_length": max_length, "normalize": normalize, "denoise": denoise, + }) + config.ui["voice_name"] = f"{config.VOXCPM_VOICE_PREFIX}{reference_audio or 'default'}" + st.session_state["voice_rate"] = 1.0 + st.session_state["voice_pitch"] = 1.0 + + +def render_voxcpm2_tts_settings(tr): + """渲染 VoxCPM-2B-Pack 设置。""" + pack_config = config.voxcpm_2b + api_url = st.text_input( + tr("API URL"), value=pack_config.get("api_url", "http://127.0.0.1:7863"), + help=tr("VoxCPM2 API URL Help"), + ) + mode_options = [("design", tr("VoxCPM2 Mode Design")), ("clone", tr("VoxCPM2 Mode Clone"))] + mode_values = [item[0] for item in mode_options] + saved_mode = pack_config.get("mode", "design") + if saved_mode not in mode_values: + saved_mode = "design" + mode = mode_options[st.selectbox( + tr("VoxCPM2 Generation Mode"), options=range(len(mode_options)), + index=mode_values.index(saved_mode), format_func=lambda index: mode_options[index][1], + help=tr("VoxCPM2 Generation Mode Help"), + )][0] + control = st.text_area( + tr("VoxCPM2 Voice Control"), value=pack_config.get("control", ""), + help=tr("VoxCPM2 Voice Control Help"), height=80, + ) + source = pack_config.get("reference_audio_source", "resource") + reference_audio = pack_config.get("reference_audio", "") + prompt_text = pack_config.get("prompt_text", "") + if mode == "clone": + source, reference_audio = render_indextts_reference_audio_selector(tr, pack_config, "voxcpm_2b") + prompt_text = st.text_area( + tr("VoxCPM Prompt Text"), value=prompt_text, + help=tr("VoxCPM Prompt Text Help"), height=90, + ) + else: + reference_audio = "" + + with st.expander(tr("Advanced Parameters"), expanded=False): + cfg_value = st.slider("CFG Value", 1.0, 3.0, float(pack_config.get("cfg_value", 2.0)), 0.1, key="voxcpm2_cfg") + inference_timesteps = st.slider("Inference Timesteps", 1, 50, int(pack_config.get("inference_timesteps", 10)), 1, key="voxcpm2_steps") + normalize = st.checkbox(tr("VoxCPM Normalize"), value=bool(pack_config.get("normalize", True)), key="voxcpm2_normalize") + denoise = st.checkbox(tr("VoxCPM Denoise"), value=bool(pack_config.get("denoise", False)), key="voxcpm2_denoise") + output_48k = st.checkbox(tr("VoxCPM2 Output 48k"), value=bool(pack_config.get("output_48k", True))) + context_aware = st.checkbox(tr("VoxCPM2 Context Aware"), value=bool(pack_config.get("context_aware", True))) + streaming = st.checkbox(tr("VoxCPM2 Streaming"), value=bool(pack_config.get("streaming", False))) + + with st.expander(tr("VoxCPM2 Usage Instructions Title"), expanded=False): + st.markdown(tr("VoxCPM2 Usage Instructions")) + pack_config.update({ + "api_url": api_url, "mode": mode, "control": control, + "reference_audio_source": source, "reference_audio": reference_audio, + "prompt_text": prompt_text, "cfg_value": cfg_value, + "inference_timesteps": inference_timesteps, "normalize": normalize, + "denoise": denoise, "output_48k": output_48k, + "context_aware": context_aware, "streaming": streaming, + }) + config.ui["voice_name"] = f"{config.VOXCPM2_VOICE_PREFIX}{reference_audio if mode == 'clone' else mode}" + st.session_state["voice_rate"] = 1.0 + st.session_state["voice_pitch"] = 1.0 + + def render_doubaotts_settings(tr): """渲染豆包语音 TTS 设置""" api_key = st.text_input( @@ -1876,6 +1999,17 @@ def render_voice_preview_new(tr, selected_engine): voice_name = f"{config.OMNIVOICE_VOICE_PREFIX}{mode}" voice_rate = config.omnivoice.get("speed", 1.0) voice_pitch = 1.0 + elif selected_engine == config.VOXCPM_ENGINE: + reference_audio = config.voxcpm_05b.get("reference_audio", "") + voice_name = f"{config.VOXCPM_VOICE_PREFIX}{reference_audio or 'default'}" + voice_rate = 1.0 + voice_pitch = 1.0 + elif selected_engine == config.VOXCPM2_ENGINE: + mode = config.voxcpm_2b.get("mode", "design") + reference_audio = config.voxcpm_2b.get("reference_audio", "") + voice_name = f"{config.VOXCPM2_VOICE_PREFIX}{reference_audio if mode == 'clone' else mode}" + voice_rate = 1.0 + voice_pitch = 1.0 elif selected_engine == "doubaotts": voice_type = config.ui.get("doubaotts_voice_type", "BV700_streaming") voice_name = voice_type @@ -1893,6 +2027,8 @@ def render_voice_preview_new(tr, selected_engine): config.INDEXTTS_MACOS_ENGINE, config.INDEXTTS2_ENGINE, config.OMNIVOICE_ENGINE, + config.VOXCPM_ENGINE, + config.VOXCPM2_ENGINE, ) else "audio/mp3" audio_extension = ".wav" if audio_format == "audio/wav" else ".mp3" audio_file = os.path.join(temp_dir, f"tmp-voice-{str(uuid4())}{audio_extension}") diff --git a/webui/components/script_settings.py b/webui/components/script_settings.py index 6eb6193..3f1b666 100644 --- a/webui/components/script_settings.py +++ b/webui/components/script_settings.py @@ -1196,6 +1196,8 @@ def render_fun_asr_transcription(tr): ) backend = backend_options[backend_label] + st.markdown(tr("Subtitle transcription package downloads")) + if backend == "upload": render_subtitle_upload(tr) elif backend == "local": diff --git a/webui/i18n/en.json b/webui/i18n/en.json index 6d3072b..b3b1a27 100644 --- a/webui/i18n/en.json +++ b/webui/i18n/en.json @@ -390,6 +390,10 @@ "IndexTTS2 use case": "Best for fixed voices, emotional narration, and local speech synthesis workflows that need finer sampling controls. Start the IndexTTS-2 MLX Pack service before use. Deployment packages:\n\n- **macOS**: [Download](https://cutagent.online/resources/indextts2-full-macos)\n- **Windows**: Coming soon", "OmniVoice features": "A locally or privately deployed OmniVoice-Pack multilingual TTS engine with automatic voice generation, voice design, and reference-audio cloning.", "OmniVoice use case": "Best for local controllable multilingual narration, voice design, or reference-audio cloning. Start the OmniVoice-Pack API service before use. Deployment packages:\n\n- **macOS**: [Download](https://cutagent.online/resources/omnivoice-macos)\n- **Windows**: [Download](https://cutagent.online/resources/omnivoice-windows)", + "VoxCPM features": "A locally deployed VoxCPM-0.5B speech engine supporting its default voice and zero-shot cloning from reference audio.", + "VoxCPM use case": "Best for private local Chinese narration and reusable voices on Apple Silicon Macs. Start VoxCPM-0.5B-Pack before use; [download the deployment package](https://cutagent.online/resources/voxcpm-05b-macos).", + "VoxCPM2 features": "A locally deployed high-quality VoxCPM-2B engine with voice-description design, reference-audio cloning, 48k output, and context awareness.", + "VoxCPM2 use case": "Best for higher-quality local narration, describable voices, style control, and cloned voices on Apple Silicon Macs. Start VoxCPM-2B-Pack before use; [download the deployment package](https://cutagent.online/resources/voxcpm-2b-macos).", "Doubao TTS features": "Volcengine Doubao speech synthesis with multiple voices and emotions, plus fast access in mainland China.", "Local Deployment": "Local Deployment", "Cloud Service": "Cloud Service", @@ -482,6 +486,7 @@ "Characters": "characters", "Ali Bailian Fun-ASR Subtitle Transcription": "Subtitle Processing", "Subtitle Processing Method": "Subtitle Processing Method", + "Subtitle transcription package downloads": "**Subtitle transcription package downloads**\n\n- **Fun-ASR-Nano**: [macOS](https://cutagent.online/resources/fun-asr-nano-macos) · [Windows](https://cutagent.online/resources/fun-asr-nano-windows)\n- **FunASR v1.1.0**: [macOS](https://cutagent.online/resources/funasr-v110-macos) · [Windows](https://cutagent.online/resources/funasr-v110-windows)\n- **FireRedASR2-AED**: [macOS](https://cutagent.online/resources/fireredasr2-aed-macos) · [Windows](https://cutagent.online/resources/fireredasr2-aed-windows)", "Fun-ASR Backend": "Fun-ASR Backend", "Local FunASR-Pack API": "FunASR (Local)", "Local FireRedASR API": "FireRedASR2 (Local)", @@ -577,6 +582,27 @@ "IndexTTS macOS API URL Help": "IndexTTS-MLX-1.5-Pack service URL. Enter the service root or the full /v1/audio/speech/upload endpoint.", "IndexTTS2 API URL Help": "IndexTTS-2 MLX Pack service URL. Enter the service root or the full /v1/audio/speech/upload endpoint.", "OmniVoice API URL Help": "OmniVoice-Pack API service URL. You can enter the service root or the full /tts endpoint.", + "VoxCPM API URL Help": "VoxCPM-0.5B-Pack service URL. Enter the service root, /tts, or /v1/audio/speech endpoint.", + "VoxCPM Use Reference Audio": "Clone from Reference Audio", + "VoxCPM Use Reference Audio Help": "Uploads prompt_audio through /tts when enabled; otherwise uses the model's default voice.", + "VoxCPM Prompt Text": "Reference Audio Text", + "VoxCPM Prompt Text Help": "Optional transcript of the speech in the reference audio.", + "VoxCPM Normalize": "Normalize Text", + "VoxCPM Denoise": "Denoise Reference Audio", + "VoxCPM Usage Instructions Title": "VoxCPM-0.5B Usage Instructions", + "VoxCPM Usage Instructions": "1. Start VoxCPM-0.5B-Pack (double-click start.command on macOS).\n2. The default service URL is http://127.0.0.1:7864.\n3. Use the default voice without reference audio, or enable reference audio to clone a voice.\n4. The first request may take longer while the model loads.", + "VoxCPM2 API URL Help": "VoxCPM-2B-Pack service URL. Enter the service root, /tts, /tts/batch, or /v1/audio/speech endpoint.", + "VoxCPM2 Generation Mode": "Generation Mode", + "VoxCPM2 Generation Mode Help": "Voice design generates a voice from a text description; cloning uploads reference_audio.", + "VoxCPM2 Mode Design": "Voice Design", + "VoxCPM2 Mode Clone": "Reference Audio Clone", + "VoxCPM2 Voice Control": "Voice Description / Style Control", + "VoxCPM2 Voice Control Help": "Describe voice, emotion, pace, and expression, for example: warm, natural, slightly fast young female voice.", + "VoxCPM2 Output 48k": "Output 48kHz Audio", + "VoxCPM2 Context Aware": "Enable Context Awareness", + "VoxCPM2 Streaming": "Enable Streaming Inference", + "VoxCPM2 Usage Instructions Title": "VoxCPM-2B Usage Instructions", + "VoxCPM2 Usage Instructions": "1. Start VoxCPM-2B-Pack (double-click start.command on macOS).\n2. The default service URL is http://127.0.0.1:7863.\n3. Describe the desired voice in design mode, or choose reference audio in clone mode.\n4. Initial model loading and 2B generation may take longer.", "OmniVoice Language Code": "Synthesis Language", "OmniVoice Language Code Help": "The language parameter sent to OmniVoice-Pack, such as zh or en.", "OmniVoice Generation Mode": "Generation Mode", diff --git a/webui/i18n/zh.json b/webui/i18n/zh.json index feee817..bdf3fba 100644 --- a/webui/i18n/zh.json +++ b/webui/i18n/zh.json @@ -328,6 +328,10 @@ "IndexTTS2 use case": "适合需要固定音色、情绪化旁白或更细致采样控制的本地语音合成场景。使用前请先启动 IndexTTS-2 MLX Pack 服务;部署包下载:\n\n- **macOS**:[下载地址](https://cutagent.online/resources/indextts2-full-macos)\n- **Windows**:待更新", "OmniVoice features": "本地/私有部署的 OmniVoice-Pack 多语种语音合成引擎,支持自动音色、指令音色和参考音频克隆。", "OmniVoice use case": "适合需要本地可控、多语言旁白、音色设计或参考音频克隆的场景。使用前请先启动 OmniVoice-Pack API 服务;部署包下载:\n\n- **macOS**:[下载地址](https://cutagent.online/resources/omnivoice-macos)\n- **Windows**:[下载地址](https://cutagent.online/resources/omnivoice-windows)", + "VoxCPM features": "本地部署的 VoxCPM-0.5B 语音合成引擎,支持默认音色和参考音频零样本音色克隆。", + "VoxCPM use case": "适合 Apple Silicon Mac 上的本地中文旁白、固定音色与隐私敏感型语音合成。使用前请启动 VoxCPM-0.5B-Pack;[下载部署包](https://cutagent.online/resources/voxcpm-05b-macos)。", + "VoxCPM2 features": "本地部署的 VoxCPM-2B 高质量语音引擎,支持音色描述设计、参考音频克隆、48k 输出和上下文感知。", + "VoxCPM2 use case": "适合 Apple Silicon Mac 上需要更高质量、可描述音色、情绪风格控制或固定克隆音色的本地旁白。使用前请启动 VoxCPM-2B-Pack;[下载部署包](https://cutagent.online/resources/voxcpm-2b-macos)。", "Doubao TTS features": "火山引擎豆包语音合成,支持多种音色和情感,国内访问速度快", "Local Deployment": "本地部署", "Cloud Service": "云端服务", @@ -421,6 +425,7 @@ "Characters": "字符", "Ali Bailian Fun-ASR Subtitle Transcription": "字幕处理", "Subtitle Processing Method": "字幕处理方式", + "Subtitle transcription package downloads": "**字幕转录整合包下载**\n\n- **Fun-ASR-Nano**:[macOS](https://cutagent.online/resources/fun-asr-nano-macos) · [Windows](https://cutagent.online/resources/fun-asr-nano-windows)\n- **FunASR v1.1.0**:[macOS](https://cutagent.online/resources/funasr-v110-macos) · [Windows](https://cutagent.online/resources/funasr-v110-windows)\n- **FireRedASR2-AED**:[macOS](https://cutagent.online/resources/fireredasr2-aed-macos) · [Windows](https://cutagent.online/resources/fireredasr2-aed-windows)", "Fun-ASR Backend": "Fun-ASR 后端", "Local FunASR-Pack API": "FunASR(本地部署)", "Local FireRedASR API": "FireRedASR2(本地部署)", @@ -516,6 +521,27 @@ "IndexTTS macOS API URL Help": "IndexTTS-MLX-1.5-Pack 服务地址,可填写服务根地址或完整 /v1/audio/speech/upload 地址", "IndexTTS2 API URL Help": "IndexTTS-2 MLX Pack 服务地址,可填写服务根地址或完整 /v1/audio/speech/upload 地址", "OmniVoice API URL Help": "OmniVoice-Pack API 服务地址,可填写服务根地址或完整 /tts 地址", + "VoxCPM API URL Help": "VoxCPM-0.5B-Pack 服务地址,可填写服务根地址、/tts 或 /v1/audio/speech 地址。", + "VoxCPM Use Reference Audio": "使用参考音频克隆音色", + "VoxCPM Use Reference Audio Help": "启用后通过 /tts 上传 prompt_audio;关闭时使用模型默认音色。", + "VoxCPM Prompt Text": "参考音频文本", + "VoxCPM Prompt Text Help": "可选,填写参考音频中实际朗读的文字。", + "VoxCPM Normalize": "文本规范化", + "VoxCPM Denoise": "参考音频降噪", + "VoxCPM Usage Instructions Title": "VoxCPM-0.5B 使用说明", + "VoxCPM Usage Instructions": "1. 启动 VoxCPM-0.5B-Pack(macOS 双击 start.command)。\n2. 默认服务地址为 http://127.0.0.1:7864。\n3. 不选择参考音频时使用默认音色;启用参考音频后可克隆音色。\n4. 首次合成需要加载模型,可能耗时较长。", + "VoxCPM2 API URL Help": "VoxCPM-2B-Pack 服务地址,可填写服务根地址、/tts、/tts/batch 或 /v1/audio/speech 地址。", + "VoxCPM2 Generation Mode": "生成模式", + "VoxCPM2 Generation Mode Help": "音色设计使用文字描述生成声音;参考音频克隆会上传 reference_audio。", + "VoxCPM2 Mode Design": "音色设计", + "VoxCPM2 Mode Clone": "参考音频克隆", + "VoxCPM2 Voice Control": "音色描述 / 风格控制", + "VoxCPM2 Voice Control Help": "描述音色、情绪、语速和表达,例如:温暖、自然、稍快的年轻女声。", + "VoxCPM2 Output 48k": "输出 48kHz 音频", + "VoxCPM2 Context Aware": "启用上下文感知", + "VoxCPM2 Streaming": "启用流式推理", + "VoxCPM2 Usage Instructions Title": "VoxCPM-2B 使用说明", + "VoxCPM2 Usage Instructions": "1. 启动 VoxCPM-2B-Pack(macOS 双击 start.command)。\n2. 默认服务地址为 http://127.0.0.1:7863。\n3. 音色设计模式填写声音与风格描述;克隆模式选择参考音频,可补充其文本。\n4. 2B 模型首次加载和生成可能耗时较长。", "OmniVoice Language Code": "合成语言", "OmniVoice Language Code Help": "传给 OmniVoice-Pack 的 language 参数,例如 zh、en。", "OmniVoice Generation Mode": "生成模式",