RootAccess4Life's picture
Upload folder using huggingface_hub
7be2f44 verified
Raw
History Blame Contribute Delete
84.7 kB
import contextlib
import io
import os
import sys
import tempfile
import unittest
import wave
from pathlib import Path
from unittest import mock
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):
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()
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")
return model_dir
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 write_wav_frames(path, frames, channels=1, sample_width=1, frame_rate=1000):
with wave.open(str(path), "wb") as wav_file:
wav_file.setnchannels(channels)
wav_file.setsampwidth(sample_width)
wav_file.setframerate(frame_rate)
wav_file.writeframes(frames)
def read_wav_frames(path):
with wave.open(str(path), "rb") as wav_file:
return wav_file.readframes(wav_file.getnframes())
class working_directory:
def __init__(self, path):
self.path = path
self.previous = None
def __enter__(self):
self.previous = Path.cwd()
os.chdir(self.path)
def __exit__(self, _exc_type, _exc, _tb):
os.chdir(self.previous)
class BatchCommandDryRunTests(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 run_batch(self, args, tts_factory=None):
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=tts_factory)
return exit_code, stdout.getvalue(), stderr.getvalue()
def test_batch_dry_run_validates_manifest_without_loading_model(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
batch_dir = temp_path / "batch"
batch_dir.mkdir()
voice_path = batch_dir / "voice.wav"
batch_file = batch_dir / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text(
'\n{"text": "hello", "voice": "voice.wav", "output": "out.wav"}\n\n',
encoding="utf-8",
)
def fail_if_called(**_kwargs):
raise AssertionError("tts factory must not be called during dry-run")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
],
tts_factory=fail_if_called,
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, "Batch file OK: 1 tasks\n")
self.assertEqual(stderr, "")
def test_batch_dry_run_rejects_non_object_json_with_1_based_line_number(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
batch_file = temp_path / "batch.jsonl"
batch_file.write_text('\n["not", "an", "object"]\n', encoding="utf-8")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 2", stderr)
self.assertIn("JSON object", stderr)
def test_batch_dry_run_rejects_unknown_fields(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "output": "out.wav", "bogus": true}\n',
encoding="utf-8",
)
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 1", stderr)
self.assertIn("unknown fields", stderr)
self.assertIn("bogus", stderr)
def test_batch_dry_run_rejects_conflicting_text_sources(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
text_path = temp_path / "input.txt"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
text_path.write_text("hello from file", encoding="utf-8")
batch_file.write_text(
'{"text": "hello", "text_file": "input.txt", "voice": "voice.wav", "output": "out.wav"}\n',
encoding="utf-8",
)
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 1", stderr)
self.assertIn("exactly one text source", stderr)
def test_batch_dry_run_rejects_missing_output(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "voice.wav"}\n', encoding="utf-8")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 1", stderr)
self.assertIn("missing required field: output", stderr)
def test_batch_dry_run_rejects_duplicate_output_paths_with_line_number(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text(
"\n".join(
[
'{"text": "hello", "voice": "voice.wav", "output": "out.wav"}',
'{"text": "world", "voice": "voice.wav", "output": "out.wav"}',
]
)
+ "\n",
encoding="utf-8",
)
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 2", stderr)
self.assertIn("duplicate output", stderr)
def test_batch_dry_run_resolves_text_file_and_voice_relative_to_batch_file_directory(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
batch_dir = temp_path / "batch"
assets_dir = batch_dir / "assets"
batch_dir.mkdir()
assets_dir.mkdir()
voice_path = assets_dir / "voice.wav"
text_path = assets_dir / "input.txt"
batch_file = batch_dir / "batch.jsonl"
voice_path.write_bytes(b"voice")
text_path.write_text("hello from file", encoding="utf-8")
batch_file.write_text(
'{"text_file": "assets/input.txt", "voice": "assets/voice.wav", "output": "out.wav"}\n',
encoding="utf-8",
)
def fail_if_called(**_kwargs):
raise AssertionError("tts factory must not be called during dry-run")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
],
tts_factory=fail_if_called,
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, "Batch file OK: 1 tasks\n")
self.assertEqual(stderr, "")
def test_batch_dry_run_checks_model_files_without_importing_runtime_packages(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
batch_file = temp_path / "batch.jsonl"
voice_path = temp_path / "voice.wav"
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "output": "out.wav"}\n',
encoding="utf-8",
)
with mock.patch("indextts.cli_v2._import_required_packages", side_effect=AssertionError("must not import")):
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
]
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, "Batch file OK: 1 tasks\n")
self.assertEqual(stderr, "")
def test_batch_dry_run_with_force_still_rejects_duplicate_output_paths(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text(
"\n".join(
[
'{"text": "hello", "voice": "voice.wav", "output": "out.wav"}',
'{"text": "world", "voice": "voice.wav", "output": "out.wav"}',
]
)
+ "\n",
encoding="utf-8",
)
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
"--force",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 2", stderr)
self.assertIn("duplicate output", stderr)
def test_batch_concat_dry_run_validates_manifest_without_loading_model_or_creating_output_parent(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
batch_dir = temp_path / "batch"
batch_dir.mkdir()
voice_path = batch_dir / "voice.wav"
batch_file = batch_dir / "batch.jsonl"
output_path = temp_path / "new-parent" / "final.wav"
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "first", "voice": "voice.wav", "silence_after_ms": 125}\n',
encoding="utf-8",
)
def fail_if_called(**_kwargs):
raise AssertionError("tts factory must not be called during concat dry-run")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(output_path),
"--dry-run",
],
tts_factory=fail_if_called,
)
output_parent_exists = output_path.parent.exists()
output_exists = output_path.exists()
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, "Batch concat OK: 1 tasks\n")
self.assertEqual(stderr, "")
self.assertFalse(output_parent_exists)
self.assertFalse(output_exists)
def test_batch_concat_rejects_invalid_command_output_contracts(self):
cases = [
(["--concat"], "--output is required with --concat"),
(["--concat", "--output", "final.mp3"], "--output must be a .wav file"),
(["--output", "final.wav"], "--output is only valid with --concat"),
(["--keep-temp"], "--keep-temp requires --concat"),
(["--concat", "--output", "final.wav", "--output-dir", "auto"], "--concat cannot be used with --output-dir"),
(
["--concat", "--output", "final.wav", "--output-prefix", "chapter"],
"--concat cannot be used with --output-prefix",
),
]
for extra_args, expected_message in cases:
with self.subTest(expected_message=expected_message):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "voice.wav"}\n', encoding="utf-8")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
*extra_args,
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn(expected_message, stderr)
def test_batch_concat_enforces_row_output_and_silence_after_ms_contracts(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "output": "row.wav"}\n',
encoding="utf-8",
)
concat_exit_code, concat_stdout, concat_stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(temp_path / "final.wav"),
"--dry-run",
]
)
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "silence_after_ms": 125, "output": "row.wav"}\n',
encoding="utf-8",
)
normal_exit_code, normal_stdout, normal_stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
]
)
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "silence_after_ms": 125}\n',
encoding="utf-8",
)
keep_temp_exit_code, keep_temp_stdout, keep_temp_stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(temp_path / "final.wav"),
"--keep-temp",
"--dry-run",
]
)
self.assertEqual(concat_exit_code, 1)
self.assertEqual(concat_stdout, "")
self.assertIn("line 1", concat_stderr)
self.assertIn("field 'output' is not allowed with --concat", concat_stderr)
self.assertEqual(normal_exit_code, 1)
self.assertEqual(normal_stdout, "")
self.assertIn("line 1", normal_stderr)
self.assertIn("silence_after_ms", normal_stderr)
self.assertIn("only valid with --concat", normal_stderr)
self.assertEqual(keep_temp_exit_code, 0)
self.assertEqual(keep_temp_stdout, "Batch concat OK: 1 tasks\n")
self.assertEqual(keep_temp_stderr, "")
def test_batch_concat_generates_final_wav_and_cleans_temp_dir_by_default(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
output_path = temp_path / "final.wav"
calls = []
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "first", "voice": "voice.wav", "silence_after_ms": 2}\n'
'{"text": "second", "voice": "voice.wav", "silence_after_ms": 1}\n',
encoding="utf-8",
)
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
def infer(self, **kwargs):
calls.append(("infer", kwargs))
frames = b"\x01\x02" if kwargs["text"] == "first" else b"\x03"
write_wav_frames(Path(kwargs["output_path"]), frames)
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(output_path),
],
tts_factory=FakeIndexTTS2,
)
output_frames = read_wav_frames(output_path)
temp_dirs = [path for path in temp_path.iterdir() if path.is_dir() and path.name.startswith(".final.wav.")]
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertEqual(stderr, "")
self.assertEqual([call[0] for call in calls], ["init", "infer", "infer"])
self.assertEqual(output_frames, b"\x01\x02\x00\x00\x03\x00")
self.assertEqual(temp_dirs, [])
def test_batch_concat_keep_temp_preserves_temp_dir_after_success(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
output_path = temp_path / "final.wav"
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "voice.wav"}\n', encoding="utf-8")
class FakeIndexTTS2:
def __init__(self, **_kwargs):
pass
def infer(self, **kwargs):
write_wav_frames(Path(kwargs["output_path"]), b"\x04")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(output_path),
"--keep-temp",
],
tts_factory=FakeIndexTTS2,
)
temp_dirs = [path for path in temp_path.iterdir() if path.is_dir() and path.name.startswith(".final.wav.")]
temp_segment_exists = (temp_dirs[0] / "0001.wav").exists() if temp_dirs else False
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\nTemp dir: {temp_dirs[0]}\n")
self.assertEqual(stderr, "")
self.assertEqual(len(temp_dirs), 1)
self.assertTrue(temp_segment_exists)
def test_batch_concat_stops_on_inference_failure_and_cleans_temp_dir_by_default(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
output_path = temp_path / "final.wav"
calls = []
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "first", "voice": "voice.wav"}\n'
'{"text": "second", "voice": "voice.wav"}\n'
'{"text": "third", "voice": "voice.wav"}\n',
encoding="utf-8",
)
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
def infer(self, **kwargs):
calls.append(("infer", kwargs))
if kwargs["text"] == "second":
raise RuntimeError("boom")
write_wav_frames(Path(kwargs["output_path"]), kwargs["text"].encode("utf-8"))
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(output_path),
],
tts_factory=FakeIndexTTS2,
)
temp_dirs = [path for path in temp_path.iterdir() if path.is_dir() and path.name.startswith(".final.wav.")]
self.assertEqual(exit_code, 4)
self.assertEqual(stdout, "")
self.assertIn("ERROR: batch file line 2 inference failed: boom", stderr)
self.assertEqual([call[0] for call in calls], ["init", "infer", "infer"])
self.assertFalse(output_path.exists())
self.assertEqual(temp_dirs, [])
def test_batch_concat_keep_temp_preserves_temp_dir_after_inference_failure(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
output_path = temp_path / "final.wav"
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "first", "voice": "voice.wav"}\n'
'{"text": "second", "voice": "voice.wav"}\n',
encoding="utf-8",
)
class FakeIndexTTS2:
def __init__(self, **_kwargs):
pass
def infer(self, **kwargs):
if kwargs["text"] == "second":
raise RuntimeError("boom")
write_wav_frames(Path(kwargs["output_path"]), b"\x05")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(output_path),
"--keep-temp",
],
tts_factory=FakeIndexTTS2,
)
temp_dirs = [path for path in temp_path.iterdir() if path.is_dir() and path.name.startswith(".final.wav.")]
temp_segment_exists = (temp_dirs[0] / "0001.wav").exists() if temp_dirs else False
self.assertEqual(exit_code, 4)
self.assertEqual(stdout, "")
self.assertIn("ERROR: batch file line 2 inference failed: boom", stderr)
self.assertEqual(len(temp_dirs), 1)
self.assertIn(f"Temp dir: {temp_dirs[0]}", stderr)
self.assertTrue(temp_segment_exists)
self.assertFalse(output_path.exists())
def test_batch_concat_rejects_mismatched_generated_segment_format_and_cleans_temp_dir(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
output_path = temp_path / "final.wav"
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "first", "voice": "voice.wav"}\n'
'{"text": "second", "voice": "voice.wav"}\n',
encoding="utf-8",
)
class FakeIndexTTS2:
def __init__(self, **_kwargs):
pass
def infer(self, **kwargs):
frame_rate = 1000 if kwargs["text"] == "first" else 2000
write_wav_frames(Path(kwargs["output_path"]), b"\x06", frame_rate=frame_rate)
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(output_path),
],
tts_factory=FakeIndexTTS2,
)
temp_dirs = [path for path in temp_path.iterdir() if path.is_dir() and path.name.startswith(".final.wav.")]
self.assertEqual(exit_code, 4)
self.assertEqual(stdout, "")
self.assertIn("ERROR: batch file line 2 inference failed", stderr)
self.assertIn("generated WAV format does not match baseline line 1", stderr)
self.assertFalse(output_path.exists())
self.assertEqual(temp_dirs, [])
def test_batch_concat_temp_cleanup_failure_does_not_override_inference_failure(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
output_path = temp_path / "final.wav"
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "voice.wav"}\n', encoding="utf-8")
class FakeIndexTTS2:
def __init__(self, **_kwargs):
pass
def infer(self, **_kwargs):
raise RuntimeError("boom")
import indextts.cli_v2 as cli_v2
original_cleanup = cli_v2._cleanup_batch_concat_temp_dir
def fail_cleanup(_temp_dir):
return OSError("cannot remove temp dir")
cli_v2._cleanup_batch_concat_temp_dir = fail_cleanup
try:
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(output_path),
],
tts_factory=FakeIndexTTS2,
)
finally:
cli_v2._cleanup_batch_concat_temp_dir = original_cleanup
self.assertEqual(exit_code, 4)
self.assertEqual(stdout, "")
self.assertIn("ERROR: batch file line 1 inference failed: boom", stderr)
self.assertIn("WARNING: cleanup failed: cannot remove temp dir", stderr)
self.assertLess(
stderr.index("ERROR: batch file line 1 inference failed: boom"),
stderr.index("WARNING: cleanup failed: cannot remove temp dir"),
)
self.assertFalse(output_path.exists())
def test_batch_concat_temp_cleanup_failure_after_success_returns_inference_error(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
output_path = temp_path / "final.wav"
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "voice.wav"}\n', encoding="utf-8")
class FakeIndexTTS2:
def __init__(self, **_kwargs):
pass
def infer(self, **kwargs):
write_wav_frames(Path(kwargs["output_path"]), b"\x07")
import indextts.cli_v2 as cli_v2
original_cleanup = cli_v2._cleanup_batch_concat_temp_dir
def fail_cleanup(_temp_dir):
return OSError("cannot remove temp dir")
cli_v2._cleanup_batch_concat_temp_dir = fail_cleanup
try:
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(output_path),
],
tts_factory=FakeIndexTTS2,
)
finally:
cli_v2._cleanup_batch_concat_temp_dir = original_cleanup
output_exists = output_path.exists()
self.assertEqual(exit_code, 4)
self.assertEqual(stdout, "")
self.assertIn("ERROR: cleanup failed: cannot remove temp dir", stderr)
self.assertTrue(output_exists)
def test_batch_concat_dry_run_rejects_final_output_path_conflicts_without_side_effects(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
output_path = temp_path / "final.wav"
voice_path.write_bytes(b"voice")
output_path.write_bytes(b"existing")
batch_file.write_text('{"text": "hello", "voice": "voice.wav"}\n', encoding="utf-8")
voice_exit_code, voice_stdout, voice_stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(voice_path),
"--dry-run",
"--force",
]
)
existing_exit_code, existing_stdout, existing_stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(output_path),
"--dry-run",
]
)
force_exit_code, force_stdout, force_stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(output_path),
"--dry-run",
"--force",
]
)
output_bytes = output_path.read_bytes()
self.assertEqual(voice_exit_code, 1)
self.assertEqual(voice_stdout, "")
self.assertIn("line 1", voice_stderr)
self.assertIn("conflicts with protected input path", voice_stderr)
self.assertIn(str(voice_path), voice_stderr)
self.assertEqual(existing_exit_code, 1)
self.assertEqual(existing_stdout, "")
self.assertIn("output file already exists", existing_stderr)
self.assertIn(str(output_path), existing_stderr)
self.assertEqual(force_exit_code, 0)
self.assertEqual(force_stdout, "Batch concat OK: 1 tasks\n")
self.assertEqual(force_stderr, "")
self.assertEqual(output_bytes, b"existing")
def test_batch_concat_dry_run_rejects_final_output_that_matches_batch_file(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.wav"
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "voice.wav"}\n', encoding="utf-8")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(batch_file),
"--dry-run",
"--force",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 1", stderr)
self.assertIn("conflicts with protected input path", stderr)
self.assertIn(str(batch_file), stderr)
def test_batch_concat_dry_run_rejects_final_output_that_matches_empty_batch_file(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
batch_file = temp_path / "batch.wav"
batch_file.write_text("", encoding="utf-8")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(batch_file),
"--dry-run",
"--force",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("conflicts with protected input path", stderr)
self.assertIn(str(batch_file), stderr)
def test_batch_concat_dry_run_rejects_invalid_silence_after_ms_values(self):
cases = [
('{"text": "hello", "voice": "voice.wav", "silence_after_ms": -1}\n', "non-negative integer"),
('{"text": "hello", "voice": "voice.wav", "silence_after_ms": 1.5}\n', "non-negative integer"),
('{"text": "hello", "voice": "voice.wav", "silence_after_ms": true}\n', "non-negative integer"),
]
for manifest, expected_message in cases:
with self.subTest(expected_message=expected_message):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text(manifest, encoding="utf-8")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--concat",
"--output",
str(temp_path / "final.wav"),
"--dry-run",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 1", stderr)
self.assertIn("silence_after_ms", stderr)
self.assertIn(expected_message, stderr)
class BatchCommandExecutionTests(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 run_batch(self, args, tts_factory=None):
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=tts_factory)
return exit_code, stdout.getvalue(), stderr.getvalue()
def test_batch_executes_tasks_in_order_with_one_model_initialization_and_summary(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
batch_dir = temp_path / "batch"
batch_dir.mkdir()
voice_path = batch_dir / "voice.wav"
first_output = batch_dir / "first.wav"
second_output = batch_dir / "second.wav"
batch_file = batch_dir / "batch.jsonl"
calls = []
voice_path.write_bytes(b"voice")
batch_file.write_text(
"\n".join(
[
'{"text": "first", "voice": "voice.wav", "output": "first.wav"}',
'{"text": "second", "voice": "voice.wav", "output": "second.wav"}',
]
)
+ "\n",
encoding="utf-8",
)
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
def infer(self, **kwargs):
calls.append(("infer", kwargs))
Path(kwargs["output_path"]).write_bytes(kwargs["text"].encode("utf-8"))
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
],
tts_factory=FakeIndexTTS2,
)
first_output_bytes = first_output.read_bytes()
second_output_bytes = second_output.read_bytes()
self.assertEqual(exit_code, 0)
self.assertEqual(
stdout,
f"Generated: {first_output}\nGenerated: {second_output}\nBatch complete: 2 tasks generated\n",
)
self.assertEqual(stderr, "")
self.assertEqual([call[0] for call in calls], ["init", "infer", "infer"])
self.assertEqual(calls[1][1]["text"], "first")
self.assertEqual(calls[2][1]["text"], "second")
self.assertEqual(calls[1][1]["spk_audio_prompt"], str(voice_path))
self.assertEqual(calls[2][1]["spk_audio_prompt"], str(voice_path))
self.assertEqual(first_output_bytes, b"first")
self.assertEqual(second_output_bytes, b"second")
def test_batch_auto_output_dir_generates_numbered_outputs(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
batch_dir = temp_path / "batch"
output_dir = temp_path / "auto"
batch_dir.mkdir()
voice_path = batch_dir / "voice.wav"
first_output = output_dir / "0001.wav"
second_output = output_dir / "0002.wav"
batch_file = batch_dir / "batch.jsonl"
calls = []
voice_path.write_bytes(b"voice")
batch_file.write_text(
"\n".join(
[
'{"text": "first", "voice": "voice.wav"}',
"",
'{"text": "second", "voice": "voice.wav"}',
]
)
+ "\n",
encoding="utf-8",
)
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
def infer(self, **kwargs):
calls.append(("infer", kwargs))
Path(kwargs["output_path"]).write_bytes(kwargs["text"].encode("utf-8"))
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--output-dir",
str(output_dir),
],
tts_factory=FakeIndexTTS2,
)
first_output_bytes = first_output.read_bytes()
second_output_bytes = second_output.read_bytes()
self.assertEqual(exit_code, 0)
self.assertEqual(
stdout,
f"Generated: {first_output}\nGenerated: {second_output}\nBatch complete: 2 tasks generated\n",
)
self.assertEqual(stderr, "")
self.assertEqual([call[0] for call in calls], ["init", "infer", "infer"])
self.assertEqual(calls[1][1]["output_path"], str(first_output))
self.assertEqual(calls[2][1]["output_path"], str(second_output))
self.assertEqual(first_output_bytes, b"first")
self.assertEqual(second_output_bytes, b"second")
def test_batch_auto_output_dir_rejects_generated_output_that_conflicts_with_inputs_even_with_force(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
output_dir = temp_path / "auto"
output_dir.mkdir()
voice_path = output_dir / "0001.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "auto/0001.wav"}\n', encoding="utf-8")
def fail_if_called(**_kwargs):
raise AssertionError("tts factory must not be called when output precheck fails")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--output-dir",
str(output_dir),
"--force",
],
tts_factory=fail_if_called,
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 1", stderr)
self.assertIn("conflicts with protected input path", stderr)
self.assertIn(str(voice_path), stderr)
def test_batch_auto_output_dir_rejects_generated_output_that_conflicts_with_batch_file_even_with_force(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
output_dir = temp_path / "auto"
output_dir.mkdir()
voice_path = temp_path / "voice.wav"
batch_file = output_dir / "0001.wav"
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "../voice.wav"}\n', encoding="utf-8")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--output-dir",
str(output_dir),
"--force",
"--dry-run",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 1", stderr)
self.assertIn("conflicts with protected input path", stderr)
self.assertIn(str(batch_file), stderr)
def test_batch_auto_output_dir_uses_output_prefix(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
output_dir = temp_path / "auto"
voice_path = temp_path / "voice.wav"
output_path = output_dir / "chapter-0001.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "voice.wav"}\n', encoding="utf-8")
class FakeIndexTTS2:
def __init__(self, **_kwargs):
pass
def infer(self, **kwargs):
Path(kwargs["output_path"]).write_bytes(b"audio")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--output-dir",
str(output_dir),
"--output-prefix",
"chapter",
],
tts_factory=FakeIndexTTS2,
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\nBatch complete: 1 tasks generated\n")
self.assertEqual(stderr, "")
def test_batch_auto_output_dir_rejects_invalid_output_configuration(self):
cases = [
(["--output-prefix", "chapter"], "--output-prefix requires --output-dir"),
(["--output-dir", "auto", "--output-prefix", "chapter.wav"], "file extension"),
(["--output-dir", "auto", "--output-prefix", "nested/chapter"], "path separators"),
(["--output-dir", "auto", "--output-prefix", "nested\\chapter"], "path separators"),
]
for extra_args, expected_message in cases:
with self.subTest(expected_message=expected_message):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "voice.wav"}\n', encoding="utf-8")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
*extra_args,
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn(expected_message, stderr)
def test_batch_auto_output_dir_rejects_row_output(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "output": "row.wav"}\n',
encoding="utf-8",
)
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--output-dir",
str(temp_path / "auto"),
"--dry-run",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 1", stderr)
self.assertIn("not allowed with --output-dir", stderr)
def test_batch_auto_output_dir_rejects_concat_output_configuration(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "voice.wav"}\n', encoding="utf-8")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--output-dir",
str(temp_path / "auto"),
"--concat",
"--output",
str(temp_path / "final.wav"),
"--dry-run",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("--concat", stderr)
self.assertIn("--output-dir", stderr)
def test_batch_auto_output_dir_dry_run_does_not_create_output_dir(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
output_dir = temp_path / "auto"
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "voice.wav"}\n', encoding="utf-8")
def fail_if_called(**_kwargs):
raise AssertionError("tts factory must not be called during dry-run")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--output-dir",
str(output_dir),
"--dry-run",
],
tts_factory=fail_if_called,
)
output_dir_exists = output_dir.exists()
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, "Batch file OK: 1 tasks\n")
self.assertEqual(stderr, "")
self.assertFalse(output_dir_exists)
def test_batch_auto_output_dir_respects_force_for_existing_external_outputs(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
output_dir = temp_path / "auto"
output_dir.mkdir()
voice_path = temp_path / "voice.wav"
output_path = output_dir / "0001.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
output_path.write_bytes(b"existing")
batch_file.write_text('{"text": "hello", "voice": "voice.wav"}\n', encoding="utf-8")
class FakeIndexTTS2:
def __init__(self, **_kwargs):
pass
def infer(self, **kwargs):
Path(kwargs["output_path"]).write_bytes(b"new audio")
reject_exit_code, reject_stdout, reject_stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--output-dir",
str(output_dir),
],
tts_factory=FakeIndexTTS2,
)
force_exit_code, force_stdout, force_stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--output-dir",
str(output_dir),
"--force",
],
tts_factory=FakeIndexTTS2,
)
output_bytes = output_path.read_bytes()
self.assertEqual(reject_exit_code, 1)
self.assertEqual(reject_stdout, "")
self.assertIn("output file already exists", reject_stderr)
self.assertEqual(force_exit_code, 0)
self.assertEqual(force_stdout, f"Generated: {output_path}\nBatch complete: 1 tasks generated\n")
self.assertEqual(force_stderr, "")
self.assertEqual(output_bytes, b"new audio")
def test_batch_auto_output_dir_resolves_relative_to_current_working_directory(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
cwd_path = temp_path / "cwd"
batch_dir = temp_path / "batch"
cwd_path.mkdir()
batch_dir.mkdir()
voice_path = batch_dir / "voice.wav"
batch_file = batch_dir / "batch.jsonl"
expected_output = cwd_path / "auto" / "0001.wav"
batch_relative_to_cwd = Path("..") / "batch" / "batch.jsonl"
model_relative_to_cwd = Path("..") / "checkpoints"
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "voice.wav"}\n', encoding="utf-8")
class FakeIndexTTS2:
def __init__(self, **_kwargs):
pass
def infer(self, **kwargs):
Path(kwargs["output_path"]).write_bytes(b"audio")
with working_directory(cwd_path):
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_relative_to_cwd),
"--model-dir",
str(model_relative_to_cwd),
"--output-dir",
"auto",
],
tts_factory=FakeIndexTTS2,
)
output_exists = expected_output.exists()
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {expected_output}\nBatch complete: 1 tasks generated\n")
self.assertEqual(stderr, "")
self.assertTrue(output_exists)
def test_batch_auto_output_dir_rejects_output_parent_that_is_a_file_during_dry_run(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
blocked_output_dir = temp_path / "blocked"
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
blocked_output_dir.write_text("file blocks output directory", encoding="utf-8")
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "voice.wav"}\n', encoding="utf-8")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--output-dir",
str(blocked_output_dir),
"--dry-run",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("output parent path cannot be created", stderr)
self.assertIn(str(blocked_output_dir), stderr)
def test_batch_maps_command_runtime_options_to_indextts2_once(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
batch_file = temp_path / "batch.jsonl"
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
calls = []
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "output": "out.wav"}\n',
encoding="utf-8",
)
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
def infer(self, **kwargs):
calls.append(("infer", kwargs))
Path(kwargs["output_path"]).write_bytes(b"audio")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--device",
"cuda:0",
"--fp16",
"--deepspeed",
"--cuda-kernel",
"--verbose",
],
tts_factory=FakeIndexTTS2,
)
self.assertEqual(exit_code, 0)
self.assertEqual(stderr, "")
self.assertIn(f"Generated: {output_path}\n", stdout)
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,
},
)
self.assertTrue(calls[1][1]["verbose"])
def test_batch_applies_command_defaults_and_row_emotion_overrides(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
batch_dir = temp_path / "batch"
batch_dir.mkdir()
default_voice = temp_path / "default_voice.wav"
row_voice = batch_dir / "row_voice.wav"
default_emotion = temp_path / "default_emotion.wav"
row_emotion = batch_dir / "row_emotion.wav"
first_output = batch_dir / "first.wav"
second_output = batch_dir / "second.wav"
third_output = batch_dir / "third.wav"
batch_file = batch_dir / "batch.jsonl"
calls = []
default_voice.write_bytes(b"default voice")
row_voice.write_bytes(b"row voice")
default_emotion.write_bytes(b"default emotion")
row_emotion.write_bytes(b"row emotion")
batch_file.write_text(
"\n".join(
[
'{"text": "first", "output": "first.wav"}',
'{"text": "second", "voice": "row_voice.wav", "emotion_audio": "row_emotion.wav", "emotion_weight": 0.25, "output": "second.wav"}',
'{"text": "third", "emotion_vector": [0, 0, 0.5, 0, 0, 0, 0, 0], "emotion_weight": "0.4", "output": "third.wav"}',
]
)
+ "\n",
encoding="utf-8",
)
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
def infer(self, **kwargs):
calls.append(("infer", kwargs))
Path(kwargs["output_path"]).write_bytes(b"audio")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--voice",
str(default_voice),
"--emotion-audio",
str(default_emotion),
"--emotion-weight",
"0.75",
],
tts_factory=FakeIndexTTS2,
)
self.assertEqual(exit_code, 0)
self.assertEqual(stderr, "")
self.assertEqual(
stdout,
f"Generated: {first_output}\nGenerated: {second_output}\nGenerated: {third_output}\nBatch complete: 3 tasks generated\n",
)
self.assertEqual(calls[1][1]["spk_audio_prompt"], str(default_voice))
self.assertEqual(calls[1][1]["emo_audio_prompt"], str(default_emotion))
self.assertEqual(calls[1][1]["emo_alpha"], 0.75)
self.assertEqual(calls[2][1]["spk_audio_prompt"], str(row_voice))
self.assertEqual(calls[2][1]["emo_audio_prompt"], str(row_emotion))
self.assertEqual(calls[2][1]["emo_alpha"], 0.25)
self.assertEqual(calls[3][1]["spk_audio_prompt"], str(default_voice))
self.assertEqual(calls[3][1]["emo_vector"], [0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0])
self.assertEqual(calls[3][1]["emo_alpha"], 0.4)
self.assertNotIn("emo_audio_prompt", calls[3][1])
def test_batch_row_emotion_weight_inherits_command_emotion_source(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
batch_file = temp_path / "batch.jsonl"
calls = []
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "hello", "emotion_weight": 0.3, "output": "out.wav"}\n',
encoding="utf-8",
)
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
def infer(self, **kwargs):
calls.append(("infer", kwargs))
Path(kwargs["output_path"]).write_bytes(b"audio")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--voice",
str(voice_path),
"--emotion-text",
"warm and calm",
"--emotion-weight",
"0.8",
],
tts_factory=FakeIndexTTS2,
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\nBatch complete: 1 tasks generated\n")
self.assertEqual(stderr, "")
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.3)
def test_batch_inherits_command_emotion_vector(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
batch_file = temp_path / "batch.jsonl"
calls = []
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "output": "out.wav"}\n', encoding="utf-8")
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
def infer(self, **kwargs):
calls.append(("infer", kwargs))
Path(kwargs["output_path"]).write_bytes(b"audio")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--voice",
str(voice_path),
"--emotion-vector",
"0,0,0.8,0,0,0,0,0",
"--emotion-weight",
"0.6",
],
tts_factory=FakeIndexTTS2,
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\nBatch complete: 1 tasks generated\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.6)
def test_batch_accepts_row_emotion_vector_cli_style_string(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
batch_file = temp_path / "batch.jsonl"
calls = []
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "emotion_vector": "0,0,0.8,0,0,0,0,0", "emotion_weight": 0.45, "output": "out.wav"}\n',
encoding="utf-8",
)
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
def infer(self, **kwargs):
calls.append(("infer", kwargs))
Path(kwargs["output_path"]).write_bytes(b"audio")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
],
tts_factory=FakeIndexTTS2,
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\nBatch complete: 1 tasks generated\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.45)
def test_batch_rejects_row_emotion_weight_without_emotion_source(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "emotion_weight": 0.3, "output": "out.wav"}\n',
encoding="utf-8",
)
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 1", stderr)
self.assertIn("emotion_weight", stderr)
self.assertIn("requires an emotion source", stderr)
def test_batch_rejects_conflicting_row_emotion_sources(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
emotion_path = temp_path / "emotion.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
emotion_path.write_bytes(b"emotion")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "emotion_audio": "emotion.wav", "emotion_text": "calm", "output": "out.wav"}\n',
encoding="utf-8",
)
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 1", stderr)
self.assertIn("mutually exclusive", stderr)
def test_batch_reuses_synth_emotion_vector_validation_for_rows(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "emotion_vector": "0.5,0.5,0,0,0,0,0,0", "output": "out.wav"}\n',
encoding="utf-8",
)
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 1", stderr)
self.assertIn("emotion_vector", stderr)
self.assertIn("sum must be <= 0.8", stderr)
def test_batch_rejects_boolean_entries_in_json_emotion_vector(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "emotion_vector": [true, 0, 0, 0, 0, 0, 0, 0], "output": "out.wav"}\n',
encoding="utf-8",
)
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 1", stderr)
self.assertIn("emotion_vector", stderr)
self.assertIn("entries must be numeric", stderr)
def test_batch_stops_on_first_inference_failure_and_keeps_prior_outputs(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
batch_file = temp_path / "batch.jsonl"
voice_path = temp_path / "voice.wav"
first_output = temp_path / "first.wav"
second_output = temp_path / "second.wav"
third_output = temp_path / "third.wav"
calls = []
voice_path.write_bytes(b"voice")
batch_file.write_text(
"\n".join(
[
'{"text": "first", "voice": "voice.wav", "output": "first.wav"}',
'{"text": "second", "voice": "voice.wav", "output": "second.wav"}',
'{"text": "third", "voice": "voice.wav", "output": "third.wav"}',
]
)
+ "\n",
encoding="utf-8",
)
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
def infer(self, **kwargs):
calls.append(("infer", kwargs))
if kwargs["text"] == "second":
raise RuntimeError("boom")
Path(kwargs["output_path"]).write_bytes(kwargs["text"].encode("utf-8"))
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
],
tts_factory=FakeIndexTTS2,
)
first_output_bytes = first_output.read_bytes()
second_output_exists = second_output.exists()
third_output_exists = third_output.exists()
self.assertEqual(exit_code, 4)
self.assertEqual(stdout, f"Generated: {first_output}\n")
self.assertIn("ERROR: batch file line 2 inference failed: boom", stderr)
self.assertEqual([call[0] for call in calls], ["init", "infer", "infer"])
self.assertEqual(first_output_bytes, b"first")
self.assertFalse(second_output_exists)
self.assertFalse(third_output_exists)
self.assertNotIn("Batch complete", stdout)
def test_batch_rejects_existing_external_output_without_force_before_model_initialization(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
batch_file = temp_path / "batch.jsonl"
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
output_path.write_bytes(b"existing")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "output": "out.wav"}\n',
encoding="utf-8",
)
def fail_if_called(**_kwargs):
raise AssertionError("tts factory must not be called when output precheck fails")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
],
tts_factory=fail_if_called,
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("ERROR: batch file line 1 output file already exists", stderr)
self.assertIn(str(output_path), stderr)
def test_batch_force_allows_existing_external_output(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
batch_file = temp_path / "batch.jsonl"
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
voice_path.write_bytes(b"voice")
output_path.write_bytes(b"existing")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "output": "out.wav"}\n',
encoding="utf-8",
)
class FakeIndexTTS2:
def __init__(self, **_kwargs):
pass
def infer(self, **kwargs):
Path(kwargs["output_path"]).write_bytes(b"new audio")
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--force",
],
tts_factory=FakeIndexTTS2,
)
output_bytes = output_path.read_bytes()
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\nBatch complete: 1 tasks generated\n")
self.assertEqual(stderr, "")
self.assertEqual(output_bytes, b"new audio")
def test_batch_rejects_runtime_options_inside_batch_rows(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
batch_file = temp_path / "batch.jsonl"
voice_path = temp_path / "voice.wav"
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "output": "out.wav", "device": "cpu"}\n',
encoding="utf-8",
)
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
"--dry-run",
]
)
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("line 1", stderr)
self.assertIn("unknown fields", stderr)
self.assertIn("device", stderr)
def test_batch_returns_resource_error_when_model_directory_is_missing(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
batch_file = temp_path / "batch.jsonl"
voice_path = temp_path / "voice.wav"
missing_model_dir = temp_path / "missing-models"
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "output": "out.wav"}\n',
encoding="utf-8",
)
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(missing_model_dir),
]
)
self.assertEqual(exit_code, 2)
self.assertEqual(stdout, "")
self.assertIn("ERROR: model directory does not exist", stderr)
self.assertIn(str(missing_model_dir), stderr)
assert_model_resource_help(self, stderr, missing_model_dir)
def test_batch_returns_resource_error_with_download_help_when_model_file_is_missing(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = temp_path / "models"
batch_file = temp_path / "batch.jsonl"
voice_path = temp_path / "voice.wav"
model_dir.mkdir()
(model_dir / "config.yaml").write_text("placeholder", encoding="utf-8")
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "output": "out.wav"}\n',
encoding="utf-8",
)
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
]
)
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)
def test_batch_returns_runtime_error_when_indextts2_import_fails(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
model_dir = make_model_dir(temp_path)
batch_file = temp_path / "batch.jsonl"
voice_path = temp_path / "voice.wav"
voice_path.write_bytes(b"voice")
batch_file.write_text(
'{"text": "hello", "voice": "voice.wav", "output": "out.wav"}\n',
encoding="utf-8",
)
with mock.patch("indextts.cli_v2._load_indextts2", side_effect=ImportError("torch")):
exit_code, stdout, stderr = self.run_batch(
[
"batch",
"--batch-file",
str(batch_file),
"--model-dir",
str(model_dir),
]
)
self.assertEqual(exit_code, 3)
self.assertEqual(stdout, "")
self.assertIn("ERROR: runtime unavailable: torch", stderr)
if __name__ == "__main__":
unittest.main()