Files
index-tts/cli_tests/test_cli_v2_check.py
T
nanaoto bfaf561d7b ci: run the lightweight checks on Windows too (#766)
* test: resolve temp paths in the CLI tests so they pass off Linux

These assertions compare a path the CLI printed against one built from
`tempfile.TemporaryDirectory()`. On Linux those agree; on macOS they do not,
because `/var` is a symlink to `/private/var` and the CLI resolves it:

    AssertionError: 'model_dir = "/var/folders/.../models"'
      not found in 'model_dir = "/private/var/folders/.../models"'

18 of 153 non-GPU tests failed that way. Windows has its own version of the same
problem — `RUNNER~1` 8.3 short names and drive-letter case — so this blocks CI on
any non-Linux runner.

Resolve at the point the temp directory is wrapped, which is what
`test_cli_v2_download.py` already does at its seven sites and why that file was
the only one passing. 129 call sites across the other four files; no behaviour
changes, the comparison just uses the same canonical form on both sides.

153 passed, 0 failed afterwards.

* ci: run the lightweight checks on Windows too

Everything platform-specific in this repo was unverified. `pyproject.toml`
carries three `sys_platform` markers — triton-windows gated to win32, and the
wetext / WeTextProcessing split on linux — and `cli_tests` branches three ways on
`sys.platform` for config and model-cache locations. CI only ever exercised the
Linux side of any of it. The triton-windows marker bug (#742, #749) was exactly
this shape: a Windows-only resolution failure that no amount of green Linux CI
could catch.

No GPU is needed. `-m "not gpu"` deselects the 22 GPU tests, and the one
remaining case that passes `--device cuda` runs against a hand-built
`SimpleNamespace` whose `is_available` is `lambda: True`
(`cli_tests/test_cli_v2_check.py:93-98`), so nothing looks for a real device.

`fail-fast: false` so a Windows failure still lets the Linux result through, and
the uv cache is enabled — the Windows job resolves a cu128 torch and there is no
reason to refetch it every run.

Blocking rather than advisory, because the previous commit fixed the path
assertions that would otherwise have failed there: 153 passed, 0 failed.

---------

Co-authored-by: nanaoto <10inspiral@gmail.com>
2026-08-13 16:33:16 +08:00

1449 lines
54 KiB
Python

import contextlib
import importlib
import io
import os
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
def _mock_optional_dependency_imports():
"""Make flash_attn/triton appear installed so acceleration CLI tests can run."""
original = importlib.import_module
def _fake_import(name, package=None):
if name in ("flash_attn", "triton"):
return mock.MagicMock()
return original(name, package)
return mock.patch("indextts.cli_v2.importlib.import_module", side_effect=_fake_import)
REQUIRED_MODEL_FILES = [
"config.yaml",
"bpe.model",
"gpt.pth",
"s2mel.pth",
"wav2vec2bert_stats.pt",
"feat1.pt",
"feat2.pt",
]
REQUIRED_MODEL_DIRS = [
"qwen0.6bemo4-merge",
]
AUX_MODEL_FILES = [
"hf_cache/semantic_codec_model.safetensors",
"hf_cache/campplus_cn_common.bin",
"hf_cache/bigvgan/config.json",
"hf_cache/bigvgan/bigvgan_generator.pt",
]
AUX_MODEL_DIRS = [
"hf_cache/w2v-bert-2.0",
]
def make_model_dir(base_dir, include_aux=True):
model_dir = base_dir / "checkpoints"
model_dir.mkdir()
for filename in REQUIRED_MODEL_FILES:
(model_dir / filename).write_text("placeholder", encoding="utf-8")
for dirname in REQUIRED_MODEL_DIRS:
(model_dir / dirname).mkdir()
if include_aux:
make_aux_model_cache(model_dir)
return model_dir
def make_aux_model_cache(model_dir):
for filename in AUX_MODEL_FILES:
target = model_dir / filename
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("placeholder", encoding="utf-8")
for dirname in AUX_MODEL_DIRS:
target = model_dir / dirname
target.mkdir(parents=True, exist_ok=True)
(target / "config.json").write_text("placeholder", encoding="utf-8")
def assert_model_resource_help(test_case, stderr, model_dir):
test_case.assertIn(f"Model directory: {model_dir}", stderr)
test_case.assertIn("Missing resources:", stderr)
test_case.assertIn("huggingface-cli download IndexTeam/IndexTTS-2", stderr)
test_case.assertIn("modelscope download --model IndexTeam/IndexTTS-2", stderr)
test_case.assertIn(f"indextts2 config set model_dir {model_dir}", stderr)
def user_state_env(temp_path):
if sys.platform == "win32":
return {
"APPDATA": str(temp_path / "roaming"),
"LOCALAPPDATA": str(temp_path / "local"),
}
if sys.platform == "darwin":
return {"HOME": str(temp_path)}
return {
"XDG_CONFIG_HOME": str(temp_path / "config"),
"XDG_DATA_HOME": str(temp_path / "data"),
}
def fake_torch(cuda=False, xpu=False, mps=False, cuda_device_count=0, xpu_device_count=0):
return SimpleNamespace(
cuda=SimpleNamespace(is_available=lambda: cuda, device_count=lambda: cuda_device_count),
xpu=SimpleNamespace(is_available=lambda: xpu, device_count=lambda: xpu_device_count),
backends=SimpleNamespace(
mps=SimpleNamespace(is_available=lambda: mps),
),
)
def patched_imports(torch_module):
real_import_module = importlib.import_module
def import_module(name, package=None):
if name == "torch":
return torch_module
if name in {"torchaudio", "indextts"}:
return SimpleNamespace(__name__=name)
return real_import_module(name, package)
return mock.patch("importlib.import_module", side_effect=import_module)
def patched_missing_import(missing_package, torch_module):
real_import_module = importlib.import_module
def import_module(name, package=None):
if name == missing_package:
raise ImportError(name)
if name == "torch":
return torch_module
if name in {"torchaudio", "indextts"}:
return SimpleNamespace(__name__=name)
return real_import_module(name, package)
return mock.patch("importlib.import_module", side_effect=import_module)
class CheckCommandTests(unittest.TestCase):
def setUp(self):
self.user_state = tempfile.TemporaryDirectory()
self.env_patch = mock.patch.dict(os.environ, user_state_env(Path(self.user_state.name)), clear=False)
self.env_patch.start()
def tearDown(self):
self.env_patch.stop()
self.user_state.cleanup()
def test_pyproject_registers_indextts2_without_replacing_existing_indextts_command(self):
pyproject = Path("pyproject.toml").read_text(encoding="utf-8")
self.assertIn('indextts = "indextts.cli:main"', pyproject)
self.assertIn('indextts2 = "indextts.cli_v2:main"', pyproject)
def test_check_returns_success_when_resources_packages_and_requested_device_are_available(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_dir = make_model_dir(Path(temp_dir).resolve())
with patched_imports(fake_torch(cuda=True)):
from indextts.cli_v2 import main
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
exit_code = main(["check", "--model-dir", str(model_dir), "--device", "cuda"])
self.assertEqual(exit_code, 0)
self.assertIn(f"Checking model directory: {model_dir}", stdout.getvalue())
self.assertIn("OK: model directory", stdout.getvalue())
self.assertIn("OK: required model files", stdout.getvalue())
self.assertIn("OK: python packages", stdout.getvalue())
self.assertIn("cuda: available", stdout.getvalue())
self.assertEqual(stderr.getvalue(), "")
def test_check_returns_resource_error_when_model_directory_is_missing(self):
with tempfile.TemporaryDirectory() as temp_dir:
missing_model_dir = Path(temp_dir).resolve() / "missing"
from indextts.cli_v2 import main
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
exit_code = main(["check", "--model-dir", str(missing_model_dir)])
self.assertEqual(exit_code, 2)
self.assertEqual(stdout.getvalue(), "")
self.assertIn("ERROR: model directory does not exist", stderr.getvalue())
self.assertIn(str(missing_model_dir), stderr.getvalue())
def test_check_returns_resource_error_when_required_model_files_are_missing(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_dir = Path(temp_dir).resolve() / "checkpoints"
model_dir.mkdir()
(model_dir / "config.yaml").write_text("placeholder", encoding="utf-8")
from indextts.cli_v2 import main
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
exit_code = main(["check", "--model-dir", str(model_dir)])
self.assertEqual(exit_code, 2)
self.assertEqual(stdout.getvalue(), "")
self.assertIn("ERROR: missing required model files", stderr.getvalue())
self.assertIn("bpe.model", stderr.getvalue())
self.assertIn("gpt.pth", stderr.getvalue())
assert_model_resource_help(self, stderr.getvalue(), model_dir)
def test_check_requires_the_full_key_model_resource_set(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_dir = Path(temp_dir).resolve() / "checkpoints"
model_dir.mkdir()
for filename in [
"config.yaml",
"bpe.model",
"gpt.pth",
"s2mel.pth",
"wav2vec2bert_stats.pt",
]:
(model_dir / filename).write_text("placeholder", encoding="utf-8")
from indextts.cli_v2 import main
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
exit_code = main(["check", "--model-dir", str(model_dir)])
self.assertEqual(exit_code, 2)
self.assertEqual(stdout.getvalue(), "")
self.assertIn("feat1.pt", stderr.getvalue())
self.assertIn("feat2.pt", stderr.getvalue())
self.assertIn("qwen0.6bemo4-merge", stderr.getvalue())
def test_check_requires_the_auxiliary_model_cache_resources(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_dir = make_model_dir(Path(temp_dir).resolve(), include_aux=False)
from indextts.cli_v2 import main
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
exit_code = main(["check", "--model-dir", str(model_dir)])
self.assertEqual(exit_code, 2)
self.assertEqual(stdout.getvalue(), "")
self.assertIn("ERROR: missing required model files", stderr.getvalue())
self.assertIn("hf_cache/w2v-bert-2.0", stderr.getvalue())
self.assertIn("hf_cache/semantic_codec_model.safetensors", stderr.getvalue())
self.assertIn("hf_cache/campplus_cn_common.bin", stderr.getvalue())
self.assertIn("hf_cache/bigvgan/config.json", stderr.getvalue())
self.assertIn("hf_cache/bigvgan/bigvgan_generator.pt", stderr.getvalue())
def test_check_requires_file_resources_and_directory_resources(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_dir = Path(temp_dir).resolve() / "checkpoints"
model_dir.mkdir()
for filename in REQUIRED_MODEL_FILES:
if filename == "gpt.pth":
(model_dir / filename).mkdir()
else:
(model_dir / filename).write_text("placeholder", encoding="utf-8")
(model_dir / "qwen0.6bemo4-merge").write_text("placeholder", encoding="utf-8")
from indextts.cli_v2 import main
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
exit_code = main(["check", "--model-dir", str(model_dir)])
self.assertEqual(exit_code, 2)
self.assertEqual(stdout.getvalue(), "")
self.assertIn("gpt.pth", stderr.getvalue())
self.assertIn("qwen0.6bemo4-merge", stderr.getvalue())
def test_check_returns_runtime_error_when_required_python_package_is_missing(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_dir = make_model_dir(Path(temp_dir).resolve())
with patched_missing_import("torchaudio", fake_torch(cuda=True)):
from indextts.cli_v2 import main
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
exit_code = main(["check", "--model-dir", str(model_dir)])
self.assertEqual(exit_code, 3)
self.assertEqual(stdout.getvalue(), "")
self.assertIn("ERROR: missing required Python packages", stderr.getvalue())
self.assertIn("torchaudio", stderr.getvalue())
def test_check_returns_runtime_error_when_requested_device_is_unavailable(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_dir = make_model_dir(Path(temp_dir).resolve())
with patched_imports(fake_torch(cuda=False)):
from indextts.cli_v2 import main
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
exit_code = main(["check", "--model-dir", str(model_dir), "--device", "cuda:0"])
self.assertEqual(exit_code, 3)
self.assertEqual(stdout.getvalue(), "")
self.assertIn("ERROR: requested device is not available: cuda:0", stderr.getvalue())
def test_check_returns_runtime_error_when_requested_cuda_index_does_not_exist(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_dir = make_model_dir(Path(temp_dir).resolve())
with patched_imports(fake_torch(cuda=True, cuda_device_count=1)):
from indextts.cli_v2 import main
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
exit_code = main(["check", "--model-dir", str(model_dir), "--device", "cuda:1"])
self.assertEqual(exit_code, 3)
self.assertEqual(stdout.getvalue(), "")
self.assertIn("ERROR: requested device is not available: cuda:1", stderr.getvalue())
def test_check_returns_runtime_error_when_requested_xpu_index_does_not_exist(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_dir = make_model_dir(Path(temp_dir).resolve())
with patched_imports(fake_torch(xpu=True, xpu_device_count=1)):
from indextts.cli_v2 import main
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
exit_code = main(["check", "--model-dir", str(model_dir), "--device", "xpu:1"])
self.assertEqual(exit_code, 3)
self.assertEqual(stdout.getvalue(), "")
self.assertIn("ERROR: requested device is not available: xpu:1", stderr.getvalue())
class SynthCommandTests(unittest.TestCase):
def setUp(self):
self.user_state = tempfile.TemporaryDirectory()
self.env_patch = mock.patch.dict(os.environ, user_state_env(Path(self.user_state.name)), clear=False)
self.env_patch.start()
self._import_patch = _mock_optional_dependency_imports()
self._import_patch.start()
def tearDown(self):
self._import_patch.stop()
self.env_patch.stop()
self.user_state.cleanup()
def run_synth(
self,
temp_path,
args,
stdin=None,
fail_init=False,
fail_infer=False,
noisy=False,
add_model_dir=True,
):
calls = []
if add_model_dir and "--model-dir" not in args:
model_dir = make_model_dir(temp_path)
args = [*args, "--model-dir", str(model_dir)]
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
if noisy:
print("model init noise")
if fail_init:
raise RuntimeError("load boom")
def infer(self, **kwargs):
calls.append(("infer", kwargs))
if noisy:
print("model infer noise")
if fail_infer:
raise RuntimeError("boom")
from indextts.cli_v2 import main
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
exit_code = main(args, tts_factory=FakeIndexTTS2, stdin=stdin)
return exit_code, stdout.getvalue(), stderr.getvalue(), calls
def test_synth_generates_audio_from_inline_text(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
" hello ",
"--voice",
str(voice_path),
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertEqual(stderr, "")
self.assertEqual(
calls,
[
(
"init",
{
"cfg_path": str(temp_path / "checkpoints" / "config.yaml"),
"model_dir": str(temp_path / "checkpoints"),
"use_fp16": False,
"device": None,
"use_cuda_kernel": False,
"use_deepspeed": False,
"use_accel": False,
"use_torch_compile": False,
},
),
(
"infer",
{
"spk_audio_prompt": str(voice_path),
"text": "hello",
"output_path": str(output_path),
"verbose": False,
},
),
],
)
def test_synth_generates_audio_from_utf8_text_file(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
text_path = temp_path / "input.txt"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
text_path.write_text(" 你好, IndexTTS2 ", encoding="utf-8")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text-file",
str(text_path),
"--voice",
str(voice_path),
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertEqual(stderr, "")
self.assertEqual(calls[1][1]["text"], "你好, IndexTTS2")
def test_synth_generates_audio_from_stdin(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--stdin",
"--voice",
str(voice_path),
"--output",
str(output_path),
],
stdin=io.StringIO(" stdin text "),
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertEqual(stderr, "")
self.assertEqual(calls[1][1]["text"], "stdin text")
def test_synth_uses_emotion_audio_and_weight(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
emotion_path = temp_path / "emotion.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
emotion_path.write_bytes(b"emotion")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--emotion-audio",
str(emotion_path),
"--emotion-weight",
"0.75",
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertEqual(stderr, "")
self.assertEqual(calls[1][1]["emo_audio_prompt"], str(emotion_path))
self.assertEqual(calls[1][1]["emo_alpha"], 0.75)
self.assertNotIn("use_emo_text", calls[1][1])
self.assertNotIn("emo_text", calls[1][1])
def test_synth_uses_emotion_text_and_weight(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--emotion-text",
"warm and calm",
"--emotion-weight",
"0.6",
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertEqual(stderr, "")
self.assertNotIn("emo_audio_prompt", calls[1][1])
self.assertEqual(calls[1][1]["use_emo_text"], True)
self.assertEqual(calls[1][1]["emo_text"], "warm and calm")
self.assertEqual(calls[1][1]["emo_alpha"], 0.6)
def test_synth_uses_emotion_vector_and_weight(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--emotion-vector",
"0,0,0.8,0,0,0,0,0",
"--emotion-weight",
"0.7",
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertEqual(stderr, "")
self.assertEqual(calls[1][1]["emo_vector"], [0.0, 0.0, 0.8, 0.0, 0.0, 0.0, 0.0, 0.0])
self.assertEqual(calls[1][1]["emo_alpha"], 0.7)
self.assertNotIn("emo_audio_prompt", calls[1][1])
self.assertNotIn("use_emo_text", calls[1][1])
self.assertNotIn("emo_text", calls[1][1])
def test_synth_accepts_python_list_style_emotion_vector(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--emotion-vector",
"[0, 0, 0.8, 0, 0, 0, 0, 0]",
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertEqual(stderr, "")
self.assertEqual(calls[1][1]["emo_vector"], [0.0, 0.0, 0.8, 0.0, 0.0, 0.0, 0.0, 0.0])
self.assertEqual(calls[1][1]["emo_alpha"], 1.0)
def test_synth_does_not_rewrite_valid_emotion_vector(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--emotion-vector",
"0.12,0.03,0.25,0.04,0,0.11,0.07,0.02",
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertEqual(stderr, "")
self.assertEqual(calls[1][1]["emo_vector"], [0.12, 0.03, 0.25, 0.04, 0.0, 0.11, 0.07, 0.02])
def test_synth_returns_input_error_when_emotion_vector_is_empty(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--emotion-vector",
"",
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("ERROR: --emotion-vector must not be empty", stderr)
self.assertEqual(calls, [])
def test_synth_returns_input_error_when_emotion_vector_is_invalid(self):
cases = [
("0,0,nope,0,0,0,0,0", "entries must be numeric"),
("0,0,0.8,0,0,0,0", "exactly 8 values"),
("0,0,0.8,0,0,0,0,0,0", "exactly 8 values"),
("0,0,-0.1,0,0,0,0,0", "between 0.0 and 1.0"),
("0,0,1.1,0,0,0,0,0", "between 0.0 and 1.0"),
("0.2,0.2,0.2,0.2,0.1,0,0,0", "sum must be <= 0.8"),
]
for vector, expected_error in cases:
with self.subTest(vector=vector):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--emotion-vector",
vector,
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("ERROR: --emotion-vector", stderr)
self.assertIn(expected_error, stderr)
self.assertEqual(calls, [])
def test_synth_returns_input_error_when_emotion_vector_conflicts_with_other_emotion_sources(self):
for other_emotion_args in (["--emotion-audio"], ["--emotion-text", "warm and calm"]):
with self.subTest(other_emotion_args=other_emotion_args):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
emotion_path = temp_path / "emotion.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
emotion_path.write_bytes(b"emotion")
args = [
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--emotion-vector",
"0,0,0.8,0,0,0,0,0",
"--output",
str(output_path),
]
if other_emotion_args == ["--emotion-audio"]:
args.extend(["--emotion-audio", str(emotion_path)])
else:
args.extend(other_emotion_args)
exit_code, stdout, stderr, calls = self.run_synth(temp_path, args)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("--emotion-vector, --emotion-audio and --emotion-text are mutually exclusive", stderr)
self.assertEqual(calls, [])
def test_synth_returns_input_error_when_emotion_text_is_empty(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--emotion-text",
"",
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("ERROR: --emotion-text must not be empty", stderr)
self.assertEqual(calls, [])
def test_synth_returns_input_error_when_emotion_sources_conflict(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
emotion_path = temp_path / "emotion.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
emotion_path.write_bytes(b"emotion")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--emotion-audio",
str(emotion_path),
"--emotion-text",
"warm and calm",
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("ERROR: --emotion-audio and --emotion-text are mutually exclusive", stderr)
self.assertEqual(calls, [])
def test_synth_returns_input_error_when_empty_emotion_audio_conflicts_with_emotion_text(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--emotion-audio",
"",
"--emotion-text",
"warm and calm",
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("ERROR: --emotion-audio and --emotion-text are mutually exclusive", stderr)
self.assertEqual(calls, [])
def test_synth_returns_resource_error_when_emotion_audio_is_missing(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
emotion_path = temp_path / "missing-emotion.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--emotion-audio",
str(emotion_path),
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 2)
self.assertEqual(stdout, "")
self.assertIn("ERROR: emotion reference audio does not exist", stderr)
self.assertIn(str(emotion_path), stderr)
self.assertEqual(calls, [])
def test_synth_returns_input_error_when_emotion_weight_is_not_a_float(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
emotion_path = temp_path / "emotion.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
emotion_path.write_bytes(b"emotion")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--emotion-audio",
str(emotion_path),
"--emotion-weight",
"strong",
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("ERROR: --emotion-weight must be a float", stderr)
self.assertEqual(calls, [])
def test_synth_returns_input_error_when_text_source_is_missing(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--voice",
str(voice_path),
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("ERROR: provide exactly one text source", stderr)
self.assertEqual(calls, [])
def test_synth_returns_input_error_when_text_sources_conflict(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
text_path = temp_path / "input.txt"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
text_path.write_text("file text", encoding="utf-8")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"inline",
"--text-file",
str(text_path),
"--voice",
str(voice_path),
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("ERROR: provide exactly one text source", stderr)
self.assertEqual(calls, [])
def test_synth_returns_input_error_when_empty_text_source_conflicts_with_stdin(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"",
"--stdin",
"--voice",
str(voice_path),
"--output",
str(output_path),
],
stdin=io.StringIO("stdin text"),
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("ERROR: provide exactly one text source", stderr)
self.assertEqual(calls, [])
def test_synth_returns_input_error_when_text_is_empty_after_trimming(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
" \t\r\n ",
"--voice",
str(voice_path),
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("ERROR: text is empty", stderr)
self.assertEqual(calls, [])
def test_synth_returns_resource_error_when_text_file_is_missing(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
text_path = temp_path / "missing.txt"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text-file",
str(text_path),
"--voice",
str(voice_path),
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 2)
self.assertEqual(stdout, "")
self.assertIn("ERROR: text file does not exist", stderr)
self.assertIn(str(text_path), stderr)
self.assertEqual(calls, [])
def test_synth_returns_resource_error_when_voice_file_is_missing(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "missing.wav"
output_path = temp_path / "out.wav"
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 2)
self.assertEqual(stdout, "")
self.assertIn("ERROR: voice reference audio does not exist", stderr)
self.assertIn(str(voice_path), stderr)
self.assertEqual(calls, [])
def test_synth_returns_input_error_when_voice_argument_is_missing(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
output_path = temp_path / "out.wav"
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 2)
self.assertEqual(stdout, "")
self.assertIn("ERROR: --voice is required", stderr)
self.assertEqual(calls, [])
def test_synth_returns_input_error_when_output_exists_without_force(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
output_path.write_bytes(b"existing")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--output",
str(output_path),
],
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("ERROR: output file already exists", stderr)
self.assertIn(str(output_path), stderr)
self.assertEqual(calls, [])
def test_synth_returns_input_error_when_output_argument_is_missing(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
],
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("ERROR: --output is required", stderr)
self.assertEqual(calls, [])
def test_synth_allows_existing_output_when_force_is_set(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
output_path.write_bytes(b"existing")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--output",
str(output_path),
"--force",
],
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertEqual(stderr, "")
self.assertEqual(calls[1][1]["output_path"], str(output_path))
def test_synth_creates_output_parent_directory(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "nested" / "audio" / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--output",
str(output_path),
],
)
output_parent_exists = output_path.parent.is_dir()
self.assertEqual(exit_code, 0)
self.assertTrue(output_parent_exists)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertEqual(stderr, "")
self.assertEqual(calls[1][1]["output_path"], str(output_path))
def test_synth_maps_runtime_options_to_indextts2(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--output",
str(output_path),
"--model-dir",
str(model_dir),
"--device",
"cuda:0",
"--fp16",
"--deepspeed",
"--cuda-kernel",
"--accel",
"--torch-compile",
"--verbose",
],
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertEqual(stderr, "")
self.assertEqual(
calls[0][1],
{
"cfg_path": str(model_dir / "config.yaml"),
"model_dir": str(model_dir),
"use_fp16": True,
"device": "cuda:0",
"use_cuda_kernel": True,
"use_deepspeed": True,
"use_accel": True,
"use_torch_compile": True,
},
)
self.assertTrue(calls[1][1]["verbose"])
def test_synth_returns_inference_error_when_indextts2_infer_fails(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--output",
str(output_path),
],
fail_infer=True,
)
self.assertEqual(exit_code, 4)
self.assertEqual(stdout, "")
self.assertIn("ERROR: inference failed: boom", stderr)
self.assertEqual(calls[0][0], "init")
self.assertEqual(calls[1][0], "infer")
def test_synth_returns_inference_error_when_indextts2_initialization_fails(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--output",
str(output_path),
],
fail_init=True,
)
self.assertEqual(exit_code, 4)
self.assertEqual(stdout, "")
self.assertIn("ERROR: inference failed: load boom", stderr)
self.assertEqual(calls[0][0], "init")
def test_synth_returns_resource_error_when_model_directory_is_missing(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
missing_model_dir = temp_path / "missing-models"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--output",
str(output_path),
"--model-dir",
str(missing_model_dir),
],
add_model_dir=False,
)
self.assertEqual(exit_code, 2)
self.assertEqual(stdout, "")
self.assertIn("ERROR: model directory does not exist", stderr)
self.assertIn(str(missing_model_dir), stderr)
self.assertEqual(calls, [])
def test_synth_returns_resource_error_when_model_file_is_missing(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
model_dir = temp_path / "models"
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
model_dir.mkdir()
(model_dir / "config.yaml").write_text("placeholder", encoding="utf-8")
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--output",
str(output_path),
"--model-dir",
str(model_dir),
],
add_model_dir=False,
)
self.assertEqual(exit_code, 2)
self.assertEqual(stdout, "")
self.assertIn("ERROR: missing required model files", stderr)
self.assertIn("bpe.model", stderr)
assert_model_resource_help(self, stderr, model_dir)
self.assertEqual(calls, [])
def test_synth_returns_runtime_error_when_indextts2_import_fails(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
from indextts.cli_v2 import main
stdout = io.StringIO()
stderr = io.StringIO()
with mock.patch("indextts.cli_v2._load_indextts2", side_effect=ImportError("torch")):
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
exit_code = main(
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--output",
str(output_path),
"--model-dir",
str(model_dir),
]
)
self.assertEqual(exit_code, 3)
self.assertEqual(stdout.getvalue(), "")
self.assertIn("ERROR: runtime unavailable: torch", stderr.getvalue())
def test_load_indextts2_points_huggingface_cache_at_model_resource_directory(self):
with tempfile.TemporaryDirectory() as temp_dir:
model_dir = Path(temp_dir).resolve() / "models"
class FakeIndexTTS2:
pass
from indextts.cli_v2 import _load_indextts2
with mock.patch.dict(os.environ, {"HF_HUB_CACHE": "legacy-cache"}, clear=False):
with mock.patch.dict(
sys.modules,
{"indextts.infer_v2": SimpleNamespace(IndexTTS2=FakeIndexTTS2)},
clear=False,
):
loaded = _load_indextts2(model_dir)
hf_hub_cache = os.environ["HF_HUB_CACHE"]
self.assertIs(loaded, FakeIndexTTS2)
self.assertEqual(hf_hub_cache, str(model_dir / "hf_cache"))
def test_synth_suppresses_model_stdout_when_not_verbose(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--output",
str(output_path),
],
noisy=True,
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertNotIn("model init noise", stdout)
self.assertNotIn("model infer noise", stdout)
self.assertEqual(stderr, "")
self.assertEqual(calls[1][0], "infer")
def test_synth_allows_model_stdout_when_verbose(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
exit_code, stdout, stderr, calls = self.run_synth(
temp_path,
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--output",
str(output_path),
"--verbose",
],
noisy=True,
)
self.assertEqual(exit_code, 0)
self.assertIn("model init noise", stdout)
self.assertIn("model infer noise", stdout)
self.assertIn(f"Generated: {output_path}\n", stdout)
self.assertEqual(stderr, "")
self.assertEqual(calls[1][0], "infer")
if __name__ == "__main__":
unittest.main()