diff --git a/app/config/config.py b/app/config/config.py index b1f4ca9..641bac4 100644 --- a/app/config/config.py +++ b/app/config/config.py @@ -10,7 +10,7 @@ root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__fi config_file = f"{root_dir}/config.toml" version_file = f"{root_dir}/project_version" INDEXTTS_ENGINE = "indextts" -INDEXTTS_DISPLAY_NAME = "IndexTTS-1.5" +INDEXTTS_DISPLAY_NAME = "IndexTTS-1.5-windows" INDEXTTS2_ENGINE = "indextts2" INDEXTTS2_DISPLAY_NAME = "IndexTTS-2" OMNIVOICE_ENGINE = "omnivoice" @@ -18,6 +18,16 @@ OMNIVOICE_DISPLAY_NAME = "OmniVoice" INDEXTTS_VOICE_PREFIX = f"{INDEXTTS_ENGINE}:" INDEXTTS2_VOICE_PREFIX = f"{INDEXTTS2_ENGINE}:" OMNIVOICE_VOICE_PREFIX = f"{OMNIVOICE_ENGINE}:" +INDEXTTS2_EMOTION_VECTOR_FIELDS = ( + ("happy", "vec_happy"), + ("angry", "vec_angry"), + ("sad", "vec_sad"), + ("afraid", "vec_afraid"), + ("disgusted", "vec_disgusted"), + ("melancholic", "vec_melancholic"), + ("surprised", "vec_surprised"), + ("calm", "vec_calm"), +) def normalize_tts_engine_name(tts_engine: str) -> str: @@ -28,6 +38,32 @@ def normalize_indextts_voice_prefix(voice_name: str) -> str: return voice_name +def get_indextts2_pack_emotion(indextts2_config) -> str: + """Return the MLX Pack emotion string for current or legacy settings.""" + if not isinstance(indextts2_config, dict): + return "" + + configured_emotion = str(indextts2_config.get("emotion", "")).strip() + if configured_emotion: + return configured_emotion + + emotion_mode = indextts2_config.get("emotion_mode", "speaker") + if emotion_mode == "text": + return str(indextts2_config.get("emotion_text", "")).strip() + if emotion_mode != "vector": + return "" + + weights = [] + for emotion, field in INDEXTTS2_EMOTION_VECTOR_FIELDS: + try: + weight = float(indextts2_config.get(field, 0.0)) + except (TypeError, ValueError): + continue + if weight > 0: + weights.append(f"{emotion}:{weight:g}") + return ",".join(weights) + + def _is_legacy_indextts2_config(indextts2_config) -> bool: if not isinstance(indextts2_config, dict): return False diff --git a/app/services/test_indextts2_tts_unittest.py b/app/services/test_indextts2_tts_unittest.py new file mode 100644 index 0000000..d4cab90 --- /dev/null +++ b/app/services/test_indextts2_tts_unittest.py @@ -0,0 +1,211 @@ +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, content_type="application/json"): + self.status_code = status_code + self.content = content + self._payload = payload or {} + self.headers = {"content-type": content_type} + self.text = "OK" + + def json(self): + return self._payload + + +class IndexTTS2TtsTests(unittest.TestCase): + def setUp(self): + self.original_indextts2 = dict(voice.config.indextts2) + self.original_proxy = dict(voice.config.proxy) + + def tearDown(self): + voice.config.indextts2.clear() + voice.config.indextts2.update(self.original_indextts2) + voice.config.proxy.clear() + voice.config.proxy.update(self.original_proxy) + + def test_uploads_reference_audio_and_downloads_pack_output_url(self): + voice.config.indextts2.clear() + voice.config.indextts2.update( + { + "api_url": "http://127.0.0.1:7860", + "emotion": "happy:0.7,calm:0.3", + "emo_alpha": 0.6, + "speed": 1.15, + "seed": 20260713, + "max_mel_tokens": 1500, + "max_text_tokens_per_segment": 120, + "interval_silence": 200, + "temperature": 0.8, + "top_p": 0.8, + "top_k": 30, + "repetition_penalty": 10.0, + "diffusion_steps": 25, + "cfg_rate": 0.7, + "segment_overlap_ms": 50, + } + ) + voice.config.proxy.clear() + + generation_response = FakeResponse( + payload={"output": {"url": "/outputs/audio/speech.wav"}} + ) + download_response = FakeResponse(content=b"wav-bytes", content_type="audio/wav") + + with tempfile.TemporaryDirectory() as temp_dir: + reference_audio = Path(temp_dir) / "reference.wav" + output_file = Path(temp_dir) / "output.wav" + reference_audio.write_bytes(b"reference-wav") + + with ( + patch("app.services.voice.requests.post", return_value=generation_response) as post, + patch("app.services.voice.requests.get", return_value=download_response) as get, + patch("app.services.voice.get_audio_duration_from_file", return_value=1.25), + ): + result = voice.indextts2_tts( + text=" 新版接口测试。 ", + voice_name=f"indextts2:{reference_audio}", + voice_file=str(output_file), + ) + + output_bytes = output_file.read_bytes() if output_file.exists() else b"" + + self.assertIsNotNone(result) + self.assertEqual(output_bytes, b"wav-bytes") + self.assertEqual( + post.call_args.args[0], + "http://127.0.0.1:7860/v1/audio/speech/upload", + ) + self.assertEqual( + post.call_args.kwargs["data"], + { + "text": "新版接口测试。", + "emotion": "happy:0.7,calm:0.3", + "emo_alpha": 0.6, + "speed": 1.15, + "seed": 20260713, + "max_mel_tokens": 1500, + "max_text_tokens_per_segment": 120, + "interval_silence": 200, + "temperature": 0.8, + "top_p": 0.8, + "top_k": 30, + "repetition_penalty": 10.0, + "diffusion_steps": 25, + "cfg_rate": 0.7, + "segment_overlap_ms": 50, + }, + ) + self.assertIn("reference_audio", post.call_args.kwargs["files"]) + self.assertEqual( + get.call_args.args[0], + "http://127.0.0.1:7860/outputs/audio/speech.wav", + ) + + def test_maps_legacy_vector_settings_to_pack_emotion_and_endpoint(self): + voice.config.indextts2.clear() + voice.config.indextts2.update( + { + "api_url": "http://127.0.0.1:7860/tts", + "emotion_mode": "vector", + "emotion_alpha": 0.65, + "vec_happy": 0.3, + "vec_calm": 0.7, + } + ) + voice.config.proxy.clear() + + generation_response = FakeResponse(content=b"wav-bytes", content_type="audio/wav") + + with tempfile.TemporaryDirectory() as temp_dir: + reference_audio = Path(temp_dir) / "reference.wav" + output_file = Path(temp_dir) / "output.wav" + reference_audio.write_bytes(b"reference-wav") + + with ( + patch("app.services.voice.requests.post", return_value=generation_response) as post, + patch("app.services.voice.get_audio_duration_from_file", return_value=1.0), + ): + result = voice.indextts2_tts( + text="旧配置兼容测试。", + voice_name=f"indextts2:{reference_audio}", + voice_file=str(output_file), + ) + + self.assertIsNotNone(result) + self.assertEqual( + post.call_args.args[0], + "http://127.0.0.1:7860/v1/audio/speech/upload", + ) + self.assertEqual(post.call_args.kwargs["data"]["emotion"], "happy:0.3,calm:0.7") + self.assertEqual(post.call_args.kwargs["data"]["emo_alpha"], 0.65) + self.assertNotIn("emotion_mode", post.call_args.kwargs["data"]) + self.assertNotIn("vec_happy", post.call_args.kwargs["data"]) + + def test_normalizes_saved_values_to_pack_request_ranges(self): + voice.config.indextts2.clear() + voice.config.indextts2.update( + { + "api_url": "http://127.0.0.1:7860", + "emo_alpha": 2.0, + "speed": 0.1, + "max_mel_tokens": 50, + "max_text_tokens_per_segment": 1, + "interval_silence": -10, + "temperature": 0.0, + "top_p": 0.0, + "top_k": 0, + "repetition_penalty": 0.1, + "diffusion_steps": 0, + "cfg_rate": -1.0, + "segment_overlap_ms": -5, + "seed": "not-an-integer", + } + ) + voice.config.proxy.clear() + + generation_response = FakeResponse(content=b"wav-bytes", content_type="audio/wav") + + with tempfile.TemporaryDirectory() as temp_dir: + reference_audio = Path(temp_dir) / "reference.wav" + output_file = Path(temp_dir) / "output.wav" + reference_audio.write_bytes(b"reference-wav") + + with ( + patch("app.services.voice.requests.post", return_value=generation_response) as post, + patch("app.services.voice.get_audio_duration_from_file", return_value=1.0), + ): + result = voice.indextts2_tts( + text="参数范围测试。", + voice_name=f"indextts2:{reference_audio}", + voice_file=str(output_file), + ) + + self.assertIsNotNone(result) + self.assertEqual( + post.call_args.kwargs["data"], + { + "text": "参数范围测试。", + "emo_alpha": 1.0, + "speed": 0.5, + "max_mel_tokens": 64, + "max_text_tokens_per_segment": 20, + "interval_silence": 0, + "temperature": 0.05, + "top_p": 0.05, + "top_k": 1, + "repetition_penalty": 1.0, + "diffusion_steps": 1, + "cfg_rate": 0.0, + "segment_overlap_ms": 0, + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/services/voice.py b/app/services/voice.py index d003151..1f8e759 100644 --- a/app/services/voice.py +++ b/app/services/voice.py @@ -2395,10 +2395,23 @@ def indextts_tts(text: str, voice_name: str, voice_file: str, speed: float = 1.0 def _normalize_indextts2_api_url(api_url: str) -> str: - api_url = (api_url or "http://192.168.3.6:7863/tts").strip() - if api_url.endswith("/tts"): + """Return the IndexTTS-2 MLX Pack upload endpoint for a configured URL. + + The Pack accepts a server root, the JSON speech endpoint, or the multipart + upload endpoint. Treat an old ``/tts`` value as a server root so existing + saved settings move to the new Pack route instead of continuing to 404. + """ + api_url = (api_url or "http://127.0.0.1:7860").strip().rstrip("/") + upload_path = "/v1/audio/speech/upload" + speech_path = "/v1/audio/speech" + + if api_url.endswith(upload_path): return api_url - return f"{api_url.rstrip('/')}/tts" + if api_url.endswith(speech_path): + return f"{api_url}/upload" + if api_url.endswith("/tts"): + api_url = api_url[: -len("/tts")] + return f"{api_url}{upload_path}" def _get_configured_proxies() -> dict: @@ -2410,6 +2423,49 @@ def _get_configured_proxies() -> dict: } +def _get_indextts2_number( + key: str, + default: float | int, + minimum: float | int, + maximum: float | int, + *, + integer: bool = False, +) -> float | int: + """Read an IndexTTS-2 option and constrain it to the MLX Pack schema.""" + try: + raw_value = config.indextts2.get(key, default) + value = int(float(raw_value)) if integer else float(raw_value) + except (TypeError, ValueError): + value = default + return max(minimum, min(maximum, value)) + + +def _get_indextts2_seed() -> int | None: + """Return the optional Pack seed, omitting invalid legacy text values.""" + seed = config.indextts2.get("seed") + if seed in (None, ""): + return None + try: + return int(seed) + except (TypeError, ValueError): + logger.warning("IndexTTS-2 随机种子无效,将使用随机采样: {}", seed) + return None + + +def _get_indextts2_emotion() -> str: + """Map current and legacy IndexTTS-2 emotion settings to the MLX Pack API.""" + emotion = config.get_indextts2_pack_emotion(config.indextts2) + if emotion: + return emotion + + emotion_mode = config.indextts2.get("emotion_mode", "speaker") + if emotion_mode == "audio" and config.indextts2.get("emotion_audio"): + logger.warning( + "IndexTTS-2 MLX Pack 不支持单独的情感参考音频,将使用音色参考音频的情感。" + ) + return "" + + def _download_indextts2_audio(response: requests.Response, api_url: str, voice_file: str, proxies: dict) -> bool: content_type = response.headers.get("content-type", "").lower() if "application/json" not in content_type: @@ -2417,9 +2473,14 @@ def _download_indextts2_audio(response: requests.Response, api_url: str, voice_f f.write(response.content) return os.path.getsize(voice_file) > 0 - result = response.json() - downloads = result.get("downloads") if isinstance(result, dict) else {} - download_url = downloads.get("wav") if isinstance(downloads, dict) else "" + try: + result = response.json() + except ValueError: + logger.error("IndexTTS-2 API 返回了无效的 JSON 响应") + return False + + output = result.get("output") if isinstance(result, dict) else {} + download_url = output.get("url") if isinstance(output, dict) else "" if not download_url: logger.error(f"IndexTTS-2 API 响应中没有音频下载地址: {result}") return False @@ -2437,68 +2498,74 @@ def _download_indextts2_audio(response: requests.Response, api_url: str, voice_f def indextts2_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker, None]: """ - 使用 IndexTTS-2 API 进行零样本语音克隆。 - 接口兼容 IndexTTS2-Pack 的 POST /tts multipart form。 + 使用 IndexTTS-2 MLX Pack API 进行零样本语音克隆。 + + 参考音频通过 ``POST /v1/audio/speech/upload`` 上传,这样 Pack 即使 + 运行在另一台机器上,也不需要访问 NarratoAI 的本地文件路径。 """ - api_url = _normalize_indextts2_api_url(config.indextts2.get("api_url", "http://192.168.3.6:7863/tts")) + api_url = _normalize_indextts2_api_url(config.indextts2.get("api_url", "http://127.0.0.1:7860")) reference_audio_path = parse_indextts2_voice(voice_name) if not reference_audio_path or not os.path.exists(reference_audio_path): logger.error(f"IndexTTS-2 参考音频文件不存在: {reference_audio_path}") return None - emotion_mode = config.indextts2.get("emotion_mode", "speaker") - emotion_audio_path = config.indextts2.get("emotion_audio", "") data = { "text": text.strip(), - "emotion_mode": emotion_mode, - "emotion_alpha": config.indextts2.get("emotion_alpha", 0.65), - "emotion_text": config.indextts2.get("emotion_text", ""), - "use_random": str(bool(config.indextts2.get("use_random", False))).lower(), - "max_text_tokens_per_segment": config.indextts2.get("max_text_tokens_per_segment", 120), - "vec_happy": config.indextts2.get("vec_happy", 0.0), - "vec_angry": config.indextts2.get("vec_angry", 0.0), - "vec_sad": config.indextts2.get("vec_sad", 0.0), - "vec_afraid": config.indextts2.get("vec_afraid", 0.0), - "vec_disgusted": config.indextts2.get("vec_disgusted", 0.0), - "vec_melancholic": config.indextts2.get("vec_melancholic", 0.0), - "vec_surprised": config.indextts2.get("vec_surprised", 0.0), - "vec_calm": config.indextts2.get("vec_calm", 0.8), - "temperature": config.indextts2.get("temperature", 0.8), - "top_p": config.indextts2.get("top_p", 0.8), - "top_k": config.indextts2.get("top_k", 30), - "num_beams": config.indextts2.get("num_beams", 3), - "repetition_penalty": config.indextts2.get("repetition_penalty", 10.0), - "max_mel_tokens": config.indextts2.get("max_mel_tokens", 1500), + "emo_alpha": _get_indextts2_number( + "emo_alpha", config.indextts2.get("emotion_alpha", 0.6), 0.0, 1.0 + ), + "speed": _get_indextts2_number("speed", 1.0, 0.5, 2.0), + "max_mel_tokens": _get_indextts2_number( + "max_mel_tokens", 1500, 64, 1815, integer=True + ), + "max_text_tokens_per_segment": _get_indextts2_number( + "max_text_tokens_per_segment", 120, 20, 600, integer=True + ), + "interval_silence": _get_indextts2_number( + "interval_silence", 200, 0, 5000, integer=True + ), + "temperature": _get_indextts2_number("temperature", 0.8, 0.05, 2.0), + "top_p": _get_indextts2_number("top_p", 0.8, 0.05, 1.0), + "top_k": _get_indextts2_number("top_k", 30, 1, 200, integer=True), + "repetition_penalty": _get_indextts2_number( + "repetition_penalty", 10.0, 1.0, 30.0 + ), + "diffusion_steps": _get_indextts2_number( + "diffusion_steps", 25, 1, 100, integer=True + ), + "cfg_rate": _get_indextts2_number("cfg_rate", 0.7, 0.0, 2.0), + "segment_overlap_ms": _get_indextts2_number( + "segment_overlap_ms", 50, 0, 1000, integer=True + ), } + emotion = _get_indextts2_emotion() + if emotion: + data["emotion"] = emotion + seed = _get_indextts2_seed() + if seed is not None: + data["seed"] = seed proxies = _get_configured_proxies() for attempt in range(3): - files = {} try: - files["speaker_audio"] = open(reference_audio_path, "rb") - if emotion_mode == "audio": - if not emotion_audio_path or not os.path.exists(emotion_audio_path): - logger.error(f"IndexTTS-2 情感参考音频文件不存在: {emotion_audio_path}") - return None - files["emotion_audio"] = open(emotion_audio_path, "rb") + with open(reference_audio_path, "rb") as reference_audio: + logger.info(f"第 {attempt + 1} 次调用 IndexTTS-2 API: {api_url}") + response = requests.post( + api_url, + files={"reference_audio": reference_audio}, + data=data, + proxies=proxies, + timeout=180, + ) - logger.info(f"第 {attempt + 1} 次调用 IndexTTS-2 API: {api_url}") - response = requests.post( - api_url, - files=files, - data=data, - proxies=proxies, - timeout=180, - ) - - if response.status_code == 200 and _download_indextts2_audio(response, api_url, voice_file, proxies): - logger.info(f"IndexTTS-2 成功生成音频: {voice_file}, 大小: {os.path.getsize(voice_file)} 字节") - sub_maker = new_sub_maker() - duration = get_audio_duration_from_file(voice_file) - duration_ms = int(duration * 1000) if duration > 0 else max(1000, int(len(text) * 200)) - add_subtitle_event(sub_maker, 0, duration_ms * 10000, text) - return sub_maker + if response.status_code == 200 and _download_indextts2_audio(response, api_url, voice_file, proxies): + logger.info(f"IndexTTS-2 成功生成音频: {voice_file}, 大小: {os.path.getsize(voice_file)} 字节") + sub_maker = new_sub_maker() + duration = get_audio_duration_from_file(voice_file) + duration_ms = int(duration * 1000) if duration > 0 else max(1000, int(len(text) * 200)) + add_subtitle_event(sub_maker, 0, duration_ms * 10000, text) + return sub_maker logger.error(f"IndexTTS-2 API 调用失败: {response.status_code} - {response.text}") except requests.exceptions.Timeout: @@ -2507,12 +2574,6 @@ def indextts2_tts(text: str, voice_name: str, voice_file: str) -> Union[SubMaker logger.error(f"IndexTTS-2 API 网络错误: {str(e)} (尝试 {attempt + 1}/3)") except Exception as e: logger.error(f"IndexTTS-2 TTS 处理错误: {str(e)} (尝试 {attempt + 1}/3)") - finally: - for file_obj in files.values(): - try: - file_obj.close() - except Exception: - pass if attempt < 2: time.sleep(2) diff --git a/config.example.toml b/config.example.toml index d0911c1..f93b11f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -161,39 +161,32 @@ repetition_penalty = 10.0 [indextts2] - # IndexTTS-2 语音克隆配置 - # 支持 IndexTTS2-Pack FastAPI 接口:POST /tts - api_url = "http://192.168.3.6:7863/tts" + # IndexTTS-2 MLX Pack 语音克隆配置 + # 参考音频会通过 POST /v1/audio/speech/upload 上传;可填写服务根地址或完整接口地址 + api_url = "http://127.0.0.1:7860" # 默认参考音频(可选),音色列表复用 IndexTTS-1.5 的资源目录 reference_audio_source = "resource" # reference_audio = "/path/to/reference_audio.wav" - # 情感控制:speaker / audio / vector / text - emotion_mode = "speaker" - emotion_audio = "" - emotion_alpha = 0.65 - emotion_text = "" - use_random = false - max_text_tokens_per_segment = 120 - - # 8 维情感向量,顺序:happy, angry, sad, afraid, disgusted, melancholic, surprised, calm - vec_happy = 0.0 - vec_angry = 0.0 - vec_sad = 0.0 - vec_afraid = 0.0 - vec_disgusted = 0.0 - vec_melancholic = 0.0 - vec_surprised = 0.0 - vec_calm = 0.8 + # 留空时保留参考音频情绪;支持单个情绪或混合权重,例如 happy:0.7,calm:0.3 + emotion = "" + emo_alpha = 0.6 + speed = 1.0 + # 可选:设置固定随机种子以复现生成结果 + # seed = 20260713 # 高级生成参数 + max_text_tokens_per_segment = 120 + interval_silence = 200 temperature = 0.8 top_p = 0.8 top_k = 30 - num_beams = 3 repetition_penalty = 10.0 max_mel_tokens = 1500 + diffusion_steps = 25 + cfg_rate = 0.7 + segment_overlap_ms = 50 [omnivoice] # OmniVoice-Pack 语音合成配置 diff --git a/webui/components/audio_settings.py b/webui/components/audio_settings.py index f5e606c..6cb89fe 100644 --- a/webui/components/audio_settings.py +++ b/webui/components/audio_settings.py @@ -1123,10 +1123,18 @@ def render_indextts_tts_settings(tr): def render_indextts2_tts_settings(tr): - """渲染 IndexTTS-2 TTS 设置""" + """渲染 IndexTTS-2 MLX Pack TTS 设置""" + + def bounded_value(key, default, min_value, max_value): + try: + value = float(config.indextts2.get(key, default)) + except (TypeError, ValueError): + value = default + return max(min_value, min(max_value, value)) + api_url = st.text_input( tr("API URL"), - value=config.indextts2.get("api_url", "http://192.168.3.6:7863/tts"), + value=config.indextts2.get("api_url", "http://127.0.0.1:7860"), help=tr("IndexTTS2 API URL Help") ) @@ -1135,108 +1143,43 @@ def render_indextts2_tts_settings(tr): config.indextts2, "indextts2", ) - - emotion_mode_options = [ - ("speaker", tr("Emotion Mode Speaker")), - ("audio", tr("Emotion Mode Audio")), - ("vector", tr("Emotion Mode Vector")), - ("text", tr("Emotion Mode Text")), - ] - saved_emotion_mode = config.indextts2.get("emotion_mode", "speaker") - emotion_mode_values = [item[0] for item in emotion_mode_options] - if saved_emotion_mode not in emotion_mode_values: - saved_emotion_mode = "speaker" + initial_emotion = config.get_indextts2_pack_emotion(config.indextts2) + legacy_emotion_audio = ( + config.indextts2.get("emotion_mode") == "audio" + and bool(config.indextts2.get("emotion_audio")) + ) with st.expander(tr("IndexTTS2 Emotion Parameters"), expanded=False): - emotion_mode = emotion_mode_options[st.selectbox( - tr("Emotion Mode"), - options=range(len(emotion_mode_options)), - index=emotion_mode_values.index(saved_emotion_mode), - format_func=lambda x: emotion_mode_options[x][1], - help=tr("Emotion Mode Help"), - )][0] - - emotion_alpha = st.slider( + emotion = st.text_input( + tr("IndexTTS2 Emotion"), + value=initial_emotion, + help=tr("IndexTTS2 Emotion Help"), + placeholder=tr("IndexTTS2 Emotion Placeholder"), + ) + if legacy_emotion_audio and not emotion.strip(): + st.warning(tr("IndexTTS2 Emotion Audio Unsupported")) + emo_alpha = st.slider( tr("Emotion Alpha"), min_value=0.0, max_value=1.0, - value=float(config.indextts2.get("emotion_alpha", 0.65)), + value=bounded_value("emo_alpha", config.indextts2.get("emotion_alpha", 0.6), 0.0, 1.0), step=0.05, help=tr("Emotion Alpha Help"), ) - - emotion_audio = config.indextts2.get("emotion_audio", "") - emotion_text = config.indextts2.get("emotion_text", "") - if emotion_mode == "audio": - emotion_audio_col, emotion_preview_col = st.columns([5, 1]) - with emotion_audio_col: - emotion_audio = st.text_input( - tr("Emotion Reference Audio Path"), - value=emotion_audio, - help=tr("Emotion Reference Audio Path Help"), - ) - with emotion_preview_col: - render_reference_audio_preview_button( - emotion_audio, - "indextts2_emotion_audio_preview", - tr, - preview_state_key="indextts2_emotion_audio_preview_path", - ) - preview_audio_path = st.session_state.get("indextts2_emotion_audio_preview_path", "") - if preview_audio_path == emotion_audio and os.path.isfile(preview_audio_path): - with open(preview_audio_path, "rb") as audio_file: - st.audio(audio_file.read(), format=get_audio_mime_type(preview_audio_path)) - elif emotion_mode == "text": - emotion_text = st.text_input( - tr("Emotion Text"), - value=emotion_text, - help=tr("Emotion Text Help"), - placeholder=tr("Emotion Text Placeholder"), - ) - - use_random = st.checkbox( - tr("Use Random Emotion"), - value=bool(config.indextts2.get("use_random", False)), - help=tr("Use Random Emotion Help"), + speed = st.slider( + tr("IndexTTS2 Speed"), + min_value=0.5, + max_value=2.0, + value=bounded_value("speed", 1.0, 0.5, 2.0), + step=0.05, + help=tr("IndexTTS2 Speed Help"), + ) + seed = st.text_input( + tr("IndexTTS2 Seed"), + value=str(config.indextts2.get("seed", "") or ""), + help=tr("IndexTTS2 Seed Help"), + placeholder=tr("IndexTTS2 Seed Placeholder"), ) - - emotion_vector_defaults = { - "vec_happy": 0.0, - "vec_angry": 0.0, - "vec_sad": 0.0, - "vec_afraid": 0.0, - "vec_disgusted": 0.0, - "vec_melancholic": 0.0, - "vec_surprised": 0.0, - "vec_calm": 0.8, - } - emotion_vector_labels = { - "vec_happy": tr("Emotion Happy"), - "vec_angry": tr("Emotion Angry"), - "vec_sad": tr("Emotion Sad"), - "vec_afraid": tr("Emotion Afraid"), - "vec_disgusted": tr("Emotion Disgusted"), - "vec_melancholic": tr("Emotion Melancholic"), - "vec_surprised": tr("Emotion Surprised"), - "vec_calm": tr("Emotion Calm"), - } - emotion_vector_values = {} - if emotion_mode == "vector": - vec_cols = st.columns(2) - for index, (field, default_value) in enumerate(emotion_vector_defaults.items()): - with vec_cols[index % 2]: - emotion_vector_values[field] = st.slider( - emotion_vector_labels[field], - min_value=0.0, - max_value=1.0, - value=float(config.indextts2.get(field, default_value)), - step=0.05, - ) - else: - emotion_vector_values = { - field: float(config.indextts2.get(field, default_value)) - for field, default_value in emotion_vector_defaults.items() - } with st.expander(tr("Advanced Parameters"), expanded=False): col1, col2 = st.columns(2) @@ -1244,65 +1187,92 @@ def render_indextts2_tts_settings(tr): with col1: temperature = st.slider( tr("Sampling Temperature"), - min_value=0.1, + min_value=0.05, max_value=2.0, - value=float(config.indextts2.get("temperature", 0.8)), - step=0.1, + value=bounded_value("temperature", 0.8, 0.05, 2.0), + step=0.05, help=tr("Sampling Temperature Help") ) top_p = st.slider( "Top P", - min_value=0.0, + min_value=0.05, max_value=1.0, - value=float(config.indextts2.get("top_p", 0.8)), + value=bounded_value("top_p", 0.8, 0.05, 1.0), step=0.05, help=tr("Top P Help") ) top_k = st.slider( "Top K", - min_value=0, - max_value=100, - value=int(config.indextts2.get("top_k", 30)), - step=5, - help=tr("Top K Help") + min_value=1, + max_value=200, + value=int(bounded_value("top_k", 30, 1, 200)), + step=1, + help=tr("IndexTTS2 Top K Help") ) max_text_tokens_per_segment = st.slider( tr("Max Text Tokens Per Segment"), min_value=20, max_value=600, - value=int(config.indextts2.get("max_text_tokens_per_segment", 120)), + value=int(bounded_value("max_text_tokens_per_segment", 120, 20, 600)), step=10, help=tr("Max Text Tokens Per Segment Help") ) + interval_silence = st.slider( + tr("Interval Silence"), + min_value=0, + max_value=5000, + value=int(bounded_value("interval_silence", 200, 0, 5000)), + step=50, + help=tr("Interval Silence Help"), + ) + + segment_overlap_ms = st.slider( + tr("Segment Overlap"), + min_value=0, + max_value=1000, + value=int(bounded_value("segment_overlap_ms", 50, 0, 1000)), + step=10, + help=tr("Segment Overlap Help"), + ) + with col2: - num_beams = st.slider( - tr("Num Beams"), + diffusion_steps = st.slider( + tr("Diffusion Steps"), min_value=1, - max_value=10, - value=int(config.indextts2.get("num_beams", 3)), + max_value=100, + value=int(bounded_value("diffusion_steps", 25, 1, 100)), step=1, - help=tr("Num Beams Help") + help=tr("Diffusion Steps Help") + ) + + cfg_rate = st.slider( + tr("CFG Rate"), + min_value=0.0, + max_value=2.0, + value=bounded_value("cfg_rate", 0.7, 0.0, 2.0), + step=0.05, + help=tr("CFG Rate Help"), ) repetition_penalty = st.slider( tr("Repetition Penalty"), - min_value=0.1, - max_value=20.0, - value=float(config.indextts2.get("repetition_penalty", 10.0)), + min_value=1.0, + max_value=30.0, + value=bounded_value("repetition_penalty", 10.0, 1.0, 30.0), step=0.1, help=tr("Repetition Penalty Help") ) max_mel_tokens = st.slider( tr("Max Mel Tokens"), - min_value=50, + min_value=64, max_value=1815, - value=int(config.indextts2.get("max_mel_tokens", 1500)), - step=10, + value=int(bounded_value("max_mel_tokens", 1500, 64, 1815)), + step=1, help=tr("Max Mel Tokens Help") ) @@ -1312,20 +1282,32 @@ def render_indextts2_tts_settings(tr): config.indextts2["api_url"] = api_url config.indextts2["reference_audio_source"] = reference_audio_source config.indextts2["reference_audio"] = reference_audio - config.indextts2["emotion_mode"] = emotion_mode - config.indextts2["emotion_audio"] = emotion_audio - config.indextts2["emotion_alpha"] = emotion_alpha - config.indextts2["emotion_text"] = emotion_text - config.indextts2["use_random"] = use_random + config.indextts2["emotion"] = emotion + config.indextts2["emo_alpha"] = emo_alpha + config.indextts2["speed"] = speed + config.indextts2["seed"] = seed.strip() config.indextts2["max_text_tokens_per_segment"] = max_text_tokens_per_segment - for field, value in emotion_vector_values.items(): - config.indextts2[field] = value + config.indextts2["interval_silence"] = interval_silence + config.indextts2["segment_overlap_ms"] = segment_overlap_ms config.indextts2["temperature"] = temperature config.indextts2["top_p"] = top_p config.indextts2["top_k"] = top_k - config.indextts2["num_beams"] = num_beams config.indextts2["repetition_penalty"] = repetition_penalty config.indextts2["max_mel_tokens"] = max_mel_tokens + config.indextts2["diffusion_steps"] = diffusion_steps + config.indextts2["cfg_rate"] = cfg_rate + + legacy_fields = ( + "emotion_mode", "emotion_audio", "emotion_alpha", "emotion_text", "use_random", + "num_beams", "vec_happy", "vec_angry", "vec_sad", "vec_afraid", + "vec_disgusted", "vec_melancholic", "vec_surprised", "vec_calm", + ) + if legacy_emotion_audio and not emotion.strip(): + legacy_fields = tuple( + field for field in legacy_fields if field not in {"emotion_mode", "emotion_audio"} + ) + for field in legacy_fields: + config.indextts2.pop(field, None) if reference_audio: config.ui["voice_name"] = f"{config.INDEXTTS2_VOICE_PREFIX}{reference_audio}" diff --git a/webui/i18n/en.json b/webui/i18n/en.json index 306a738..5d9b8c9 100644 --- a/webui/i18n/en.json +++ b/webui/i18n/en.json @@ -376,8 +376,8 @@ "IndexTTS features": "A locally or privately deployed IndexTTS-1.5 voice-cloning engine. Choose a resource audio file or upload a reference audio file, then synthesize narration in that voice.", "IndexTTS use case": "Best for fixed narrator voices, character dubbing, or generating multiple videos with the same voice. Start the IndexTTS-1.5 API service before use. Deployment package: https://pan.quark.cn/s/0767c9bcefd5", "IndexTTS download link": "Download link: https://pan.quark.cn/s/0767c9bcefd5", - "IndexTTS2 features": "A locally or privately deployed IndexTTS-2 voice-cloning engine with emotion control and fuller generation parameters.", - "IndexTTS2 use case": "Best for fixed voices, emotional narration, and local speech synthesis workflows that need finer sampling controls. Start the IndexTTS-2 API service before use.", + "IndexTTS2 features": "A locally deployed IndexTTS-2 MLX Pack voice-cloning engine with emotion control and advanced generation parameters.", + "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.", "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.", "Doubao TTS features": "Volcengine Doubao speech synthesis with multiple voices and emotions, plus fast access in mainland China.", @@ -564,7 +564,7 @@ "Select Qwen3 TTS Voice": "Select a Qwen3 TTS voice", "API URL": "API URL", "IndexTTS API URL Help": "IndexTTS-1.5 API service URL", - "IndexTTS2 API URL Help": "IndexTTS-2 API service URL. You can enter the service root or the full /tts 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.", "OmniVoice Language Code": "Synthesis Language", "OmniVoice Language Code Help": "The language parameter sent to OmniVoice-Pack, such as zh or en.", @@ -619,6 +619,15 @@ "IndexTTS Usage Instructions Title": "IndexTTS-1.5 Usage Instructions", "IndexTTS Usage Instructions": "**Zero-shot voice cloning**\n\n1. **Prepare reference audio**: upload or specify a clear audio file (3-10 seconds recommended)\n2. **Set API URL**: make sure the IndexTTS-1.5 service is running\n3. **Start synthesis**: the system will use the reference voice to synthesize new speech\n\n**Notes**:\n- Reference audio quality directly affects synthesis quality\n- Use clean audio without background noise when possible\n- Keep text length within a reasonable range\n- The first synthesis may take longer", "IndexTTS2 Emotion Parameters": "Emotion Parameters", + "IndexTTS2 Emotion": "Emotion Override", + "IndexTTS2 Emotion Help": "Leave blank to preserve the reference audio emotion. Use a label such as happy or a weighted mix such as happy:0.7,calm:0.3.", + "IndexTTS2 Emotion Placeholder": "e.g. happy:0.7,calm:0.3", + "IndexTTS2 Emotion Audio Unsupported": "The IndexTTS-2 MLX Pack does not support a separate emotion reference audio. The speaker reference audio emotion will be used.", + "IndexTTS2 Speed": "Speech Speed", + "IndexTTS2 Speed Help": "Speech speed sent to the IndexTTS-2 MLX Pack (0.5x–2.0x).", + "IndexTTS2 Seed": "Seed", + "IndexTTS2 Seed Help": "Optional integer seed for reproducible generation. Leave blank for random sampling.", + "IndexTTS2 Seed Placeholder": "Random", "Emotion Mode": "Emotion Mode", "Emotion Mode Help": "Choose the emotion control source for IndexTTS-2.", "Emotion Mode Speaker": "Same as speaker reference", @@ -642,12 +651,21 @@ "Emotion Melancholic": "Melancholic", "Emotion Surprised": "Surprised", "Emotion Calm": "Calm", + "Diffusion Steps": "Diffusion Steps", + "Diffusion Steps Help": "Number of diffusion steps. Higher values can improve quality but take longer.", + "CFG Rate": "CFG Rate", + "CFG Rate Help": "Classifier-free guidance strength for the diffusion stage.", + "Interval Silence": "Inter-segment Silence (ms)", + "Interval Silence Help": "Silence inserted between text segments by the Pack.", + "Segment Overlap": "Segment Overlap (ms)", + "Segment Overlap Help": "Crossfade overlap between generated text segments.", + "IndexTTS2 Top K Help": "Top-k sampling value for IndexTTS-2 MLX Pack. It must be at least 1.", "Max Text Tokens Per Segment": "Max Text Tokens Per Segment", "Max Text Tokens Per Segment Help": "Maximum text tokens per segment for IndexTTS-2 inference.", "Max Mel Tokens": "Max Mel Tokens", "Max Mel Tokens Help": "Controls the maximum mel tokens generated in one request. Higher values can produce longer audio.", "IndexTTS2 Usage Instructions Title": "IndexTTS-2 Usage Instructions", - "IndexTTS2 Usage Instructions": "**IndexTTS-2 voice cloning**\n\n1. **Choose a voice**: reuse IndexTTS-1.5 resource audio or upload a reference audio file\n2. **Set API URL**: for example http://192.168.3.6:7863/tts, or enter the service root\n3. **Tune emotion**: speaker is the default; switch to audio, vector, or text when needed\n4. **Tune generation**: temperature, top_p, top_k, num_beams, repetition_penalty, and max_mel_tokens are sent directly to the IndexTTS-2 API\n\n**Notes**:\n- Reference audio quality directly affects cloning quality\n- The first request may load the model and take longer\n- CPU deployments are much slower than GPU deployments", + "IndexTTS2 Usage Instructions": "**IndexTTS-2 MLX Pack voice cloning**\n\n1. **Choose a voice**: reuse IndexTTS-1.5 resource audio or upload a reference audio file\n2. **Set API URL**: the default Pack service is http://127.0.0.1:7860; NarratoAI calls /v1/audio/speech/upload and uploads the reference audio\n3. **Tune emotion**: leave it blank to preserve the reference audio emotion, or enter a label/mix such as happy:0.7,calm:0.3\n4. **Tune generation**: speed, temperature, top_p, top_k, repetition_penalty, diffusion_steps, cfg_rate, and max_mel_tokens are sent to the Pack\n\n**Notes**:\n- Reference audio quality directly affects cloning quality\n- The first request may load the model and take longer\n- The Pack binds to localhost by default", "OmniVoice Usage Instructions Title": "OmniVoice Usage Instructions", "OmniVoice Usage Instructions": "**OmniVoice-Pack speech synthesis**\n\n1. **Automatic voice**: set the API URL and language, then synthesize directly.\n2. **Voice design**: fill instruct with the desired gender, pitch, accent, or style.\n3. **Reference-audio clone**: upload or choose reference audio and fill its matching transcript.\n\n**Notes**:\n- The default service URL is http://127.0.0.1:7866/tts\n- Reference-audio cloning requires reference text when the service has no ASR model loaded\n- OmniVoice returns WAV audio, and NarratoAI estimates subtitle segment timing from the audio duration", "Volcengine Access Key Help": "Volcengine Access Key", diff --git a/webui/i18n/zh.json b/webui/i18n/zh.json index fbfbd66..222d0a4 100644 --- a/webui/i18n/zh.json +++ b/webui/i18n/zh.json @@ -314,8 +314,8 @@ "IndexTTS features": "本地/私有部署的 IndexTTS-1.5 语音克隆引擎。选择资源目录音频或上传参考音频后,可按该音色合成旁白。", "IndexTTS use case": "适合需要固定旁白音色、角色配音或批量生成同一音色视频的场景。使用前请先启动 IndexTTS-1.5 API 服务;部署包下载:https://pan.quark.cn/s/0767c9bcefd5", "IndexTTS download link": "下载地址:https://pan.quark.cn/s/0767c9bcefd5", - "IndexTTS2 features": "本地/私有部署的 IndexTTS-2 语音克隆引擎,支持情感控制和更完整的生成参数。", - "IndexTTS2 use case": "适合需要固定音色、情绪化旁白或更细致采样控制的本地语音合成场景。使用前请先启动 IndexTTS-2 API 服务。", + "IndexTTS2 features": "本地部署的 IndexTTS-2 MLX Pack 语音克隆引擎,支持情感控制和更完整的生成参数。", + "IndexTTS2 use case": "适合需要固定音色、情绪化旁白或更细致采样控制的本地语音合成场景。使用前请先启动 IndexTTS-2 MLX Pack 服务。", "OmniVoice features": "本地/私有部署的 OmniVoice-Pack 多语种语音合成引擎,支持自动音色、指令音色和参考音频克隆。", "OmniVoice use case": "适合需要本地可控、多语言旁白、音色设计或参考音频克隆的场景。使用前请先启动 OmniVoice-Pack API 服务。", "Doubao TTS features": "火山引擎豆包语音合成,支持多种音色和情感,国内访问速度快", @@ -503,7 +503,7 @@ "Select Qwen3 TTS Voice": "选择 Qwen3 TTS 音色", "API URL": "API 地址", "IndexTTS API URL Help": "IndexTTS-1.5 API 服务地址", - "IndexTTS2 API URL Help": "IndexTTS-2 API 服务地址,可填写服务根地址或完整 /tts 地址", + "IndexTTS2 API URL Help": "IndexTTS-2 MLX Pack 服务地址,可填写服务根地址或完整 /v1/audio/speech/upload 地址", "OmniVoice API URL Help": "OmniVoice-Pack API 服务地址,可填写服务根地址或完整 /tts 地址", "OmniVoice Language Code": "合成语言", "OmniVoice Language Code Help": "传给 OmniVoice-Pack 的 language 参数,例如 zh、en。", @@ -558,6 +558,15 @@ "IndexTTS Usage Instructions Title": "IndexTTS-1.5 使用说明", "IndexTTS Usage Instructions": "**零样本语音克隆**\n\n1. **准备参考音频**:上传或指定一段清晰的音频文件(建议 3-10 秒)\n2. **设置 API 地址**:确保 IndexTTS-1.5 服务正常运行\n3. **开始合成**:系统会自动使用参考音频的音色合成新语音\n\n**注意事项**:\n- 参考音频质量直接影响合成效果\n- 建议使用无背景噪音的清晰音频\n- 文本长度建议控制在合理范围内\n- 首次合成可能需要较长时间", "IndexTTS2 Emotion Parameters": "情感参数", + "IndexTTS2 Emotion": "情感覆盖", + "IndexTTS2 Emotion Help": "留空时保留参考音频的情感;可填写单个情感,如 happy,或权重混合,如 happy:0.7,calm:0.3。", + "IndexTTS2 Emotion Placeholder": "例如:happy:0.7,calm:0.3", + "IndexTTS2 Emotion Audio Unsupported": "IndexTTS-2 MLX Pack 不支持单独的情感参考音频,将使用音色参考音频的情感。", + "IndexTTS2 Speed": "语速", + "IndexTTS2 Speed Help": "传给 IndexTTS-2 MLX Pack 的语速(0.5x–2.0x)。", + "IndexTTS2 Seed": "随机种子", + "IndexTTS2 Seed Help": "可选整数,用于复现生成结果;留空则随机采样。", + "IndexTTS2 Seed Placeholder": "随机", "Emotion Mode": "情感控制方式", "Emotion Mode Help": "选择 IndexTTS-2 的情感控制来源", "Emotion Mode Speaker": "与音色参考相同", @@ -581,12 +590,21 @@ "Emotion Melancholic": "忧郁", "Emotion Surprised": "惊讶", "Emotion Calm": "平静", + "Diffusion Steps": "扩散步数", + "Diffusion Steps Help": "扩散生成的步数;值越大可能提升质量,但生成更慢。", + "CFG Rate": "CFG 引导强度", + "CFG Rate Help": "扩散阶段的 classifier-free guidance 强度。", + "Interval Silence": "分段静音(毫秒)", + "Interval Silence Help": "Pack 在文本分段之间插入的静音时长。", + "Segment Overlap": "分段重叠(毫秒)", + "Segment Overlap Help": "生成的文本片段之间用于交叉淡化的重叠时长。", + "IndexTTS2 Top K Help": "IndexTTS-2 MLX Pack 的 top-k 采样值,必须不小于 1。", "Max Text Tokens Per Segment": "单段最大文本 Token", "Max Text Tokens Per Segment Help": "IndexTTS-2 分段推理的最大文本 token 数", "Max Mel Tokens": "最大 Mel Tokens", "Max Mel Tokens Help": "控制单次生成的最大 mel token 数,值越大可生成更长音频", "IndexTTS2 Usage Instructions Title": "IndexTTS-2 使用说明", - "IndexTTS2 Usage Instructions": "**IndexTTS-2 语音克隆**\n\n1. **选择音色**:复用 IndexTTS-1.5 的资源音频或上传参考音频\n2. **设置 API 地址**:例如 http://192.168.3.6:7863/tts,也可以填写服务根地址\n3. **调整情感参数**:默认使用 speaker,可按需切换到 audio、vector 或 text\n4. **调整生成参数**:temperature、top_p、top_k、num_beams、repetition_penalty 和 max_mel_tokens 会直接传给 IndexTTS-2 接口\n\n**注意事项**:\n- 参考音频质量会直接影响克隆效果\n- 首次请求可能需要加载模型,耗时更长\n- CPU 部署生成速度会明显慢于 GPU", + "IndexTTS2 Usage Instructions": "**IndexTTS-2 MLX Pack 语音克隆**\n\n1. **选择音色**:复用 IndexTTS-1.5 的资源音频或上传参考音频\n2. **设置 API 地址**:默认 Pack 服务为 http://127.0.0.1:7860;NarratoAI 会调用 /v1/audio/speech/upload 并上传参考音频\n3. **调整情感参数**:留空时保留参考音频情感;也可输入 happy:0.7,calm:0.3 这类情感或权重混合\n4. **调整生成参数**:speed、temperature、top_p、top_k、repetition_penalty、diffusion_steps、cfg_rate 和 max_mel_tokens 会传给 Pack\n\n**注意事项**:\n- 参考音频质量会直接影响克隆效果\n- 首次请求可能需要加载模型,耗时更长\n- Pack 默认仅监听本机地址", "OmniVoice Usage Instructions Title": "OmniVoice 使用说明", "OmniVoice Usage Instructions": "**OmniVoice-Pack 语音合成**\n\n1. **自动音色**:只需要设置 API 地址和语言,可直接合成。\n2. **指令音色**:填写 instruct 描述想要的性别、音高、口音或风格。\n3. **参考音频克隆**:上传或选择参考音频,并填写该音频对应文本。\n\n**注意事项**:\n- 当前默认服务地址为 http://127.0.0.1:7866/tts\n- 参考音频克隆在服务未加载 ASR 模型时必须填写参考文本\n- OmniVoice 返回 WAV 音频,系统会按音频时长估算字幕段落", "Volcengine Access Key Help": "火山引擎 Access Key",