mirror of
https://github.com/index-tts/index-tts.git
synced 2026-08-28 23:01:17 +08:00
bfaf561d7b
* 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>
1449 lines
54 KiB
Python
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()
|