Add optional timestamp alignment

This commit is contained in:
cccpro12
2026-06-06 16:29:46 +08:00
parent f3b65758c6
commit 87af7d0854
10 changed files with 523 additions and 117 deletions
+17
View File
@@ -232,6 +232,23 @@ voxcpm clone \
# Batch processing
voxcpm batch --input examples/input.txt --output-dir outs
# Optional post-generation timestamps with stable-ts
pip install "voxcpm[timestamps]"
voxcpm design \
--text "VoxCPM2 brings studio-quality multilingual speech synthesis." \
--output out.wav \
--timestamps \
--timestamp-level word \
--timestamp-language en
# Character timestamps are best-effort and are derived from word alignment
voxcpm design \
--text "欢迎使用 VoxCPM2。" \
--output out.wav \
--timestamps \
--timestamp-level char \
--timestamp-language zh
# Help
voxcpm --help
```
+17
View File
@@ -231,6 +231,23 @@ voxcpm clone \
# 批量处理
voxcpm batch --input examples/input.txt --output-dir outs
# 可选的生成后时间戳对齐(基于 stable-ts)
pip install "voxcpm[timestamps]"
voxcpm design \
--text "VoxCPM2带来全新语音合成体验。" \
--output out.wav \
--timestamps \
--timestamp-level word \
--timestamp-language zh
# 字级时间戳是 best-effort,会基于词级对齐结果拆分
voxcpm design \
--text "欢迎使用 VoxCPM2。" \
--output out.wav \
--timestamps \
--timestamp-level char \
--timestamp-language zh
# 帮助
voxcpm --help
```
+3
View File
@@ -59,6 +59,9 @@ dev = [
"flake8>=3.8",
"pre-commit>=2.0",
]
timestamps = [
"stable-ts>=2.19.1",
]
[project.scripts]
voxcpm = "voxcpm.cli:main"
+136 -110
View File
@@ -11,6 +11,8 @@ import os
import sys
from pathlib import Path
from voxcpm.timestamps import align_audio_file
DEFAULT_HF_MODEL_ID = "openbmb/VoxCPM2"
# -----------------------------
@@ -86,9 +88,7 @@ def resolve_prompt_text(args, parser) -> str | None:
def detect_model_architecture(args) -> str | None:
model_location = getattr(args, "model_path", None) or getattr(
args, "hf_model_id", None
)
model_location = getattr(args, "model_path", None) or getattr(args, "hf_model_id", None)
if not model_location:
return None
@@ -103,11 +103,7 @@ def detect_model_architecture(args) -> str | None:
model_hint = str(model_location).lower()
if "voxcpm2" in model_hint:
return "voxcpm2"
if (
"voxcpm1.5" in model_hint
or "voxcpm-1.5" in model_hint
or "voxcpm_1.5" in model_hint
):
if "voxcpm1.5" in model_hint or "voxcpm-1.5" in model_hint or "voxcpm_1.5" in model_hint:
return "voxcpm"
return None
@@ -121,9 +117,7 @@ def validate_prompt_related_args(args, parser, prompt_text: str | None):
parser.error("--prompt-audio requires --prompt-text or --prompt-file.")
if args.control and prompt_text:
parser.error(
"--control cannot be used together with --prompt-text or --prompt-file."
)
parser.error("--control cannot be used together with --prompt-text or --prompt-file.")
def validate_reference_support(args, parser):
@@ -138,9 +132,7 @@ def validate_reference_support(args, parser):
def validate_design_args(args, parser):
prompt_text = resolve_prompt_text(args, parser)
if args.prompt_audio or args.reference_audio or prompt_text:
parser.error(
"`design` does not accept prompt/reference audio. Use `clone` instead."
)
parser.error("`design` does not accept prompt/reference audio. Use `clone` instead.")
def validate_clone_args(args, parser):
@@ -149,9 +141,7 @@ def validate_clone_args(args, parser):
validate_reference_support(args, parser)
if not args.prompt_audio and not args.reference_audio:
parser.error(
"`clone` requires --reference-audio, or --prompt-audio with --prompt-text/--prompt-file."
)
parser.error("`clone` requires --reference-audio, or --prompt-audio with --prompt-text/--prompt-file.")
return prompt_text
@@ -173,9 +163,7 @@ def load_model(args):
print("Loading VoxCPM model...", file=sys.stderr)
zipenhancer_path = getattr(args, "zipenhancer_path", None) or os.environ.get(
"ZIPENHANCER_MODEL_PATH", None
)
zipenhancer_path = getattr(args, "zipenhancer_path", None) or os.environ.get("ZIPENHANCER_MODEL_PATH", None)
# Build LoRA config if provided
lora_config = None
@@ -259,8 +247,7 @@ def _run_single(args, parser, *, text: str, output: str, prompt_text: str | None
cfg_value=args.cfg_value,
inference_timesteps=args.inference_timesteps,
normalize=args.normalize,
denoise=args.denoise
and (args.prompt_audio is not None or args.reference_audio is not None),
denoise=args.denoise and (args.prompt_audio is not None or args.reference_audio is not None),
)
import soundfile as sf
@@ -269,22 +256,24 @@ def _run_single(args, parser, *, text: str, output: str, prompt_text: str | None
duration = len(audio_array) / model.tts_model.sample_rate
print(f"Saved audio to: {output_path} ({duration:.2f}s)", file=sys.stderr)
maybe_write_timestamps(
args,
text=text,
audio_path=output_path,
sample_rate=model.tts_model.sample_rate,
)
def cmd_design(args, parser):
validate_design_args(args, parser)
final_text = build_final_text(args.text, args.control)
return _run_single(
args, parser, text=final_text, output=args.output, prompt_text=None
)
return _run_single(args, parser, text=final_text, output=args.output, prompt_text=None)
def cmd_clone(args, parser):
prompt_text = validate_clone_args(args, parser)
final_text = build_final_text(args.text, args.control)
return _run_single(
args, parser, text=final_text, output=args.output, prompt_text=prompt_text
)
return _run_single(args, parser, text=final_text, output=args.output, prompt_text=prompt_text)
def cmd_validate(args, parser):
@@ -306,8 +295,6 @@ def cmd_validate(args, parser):
def cmd_batch(args, parser):
import soundfile as sf
input_file = require_file_exists(args.input, parser, "input file")
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
@@ -321,17 +308,15 @@ def cmd_batch(args, parser):
prompt_text = validate_batch_args(args, parser)
model = load_model(args)
import soundfile as sf
prompt_audio_path = None
if args.prompt_audio:
prompt_audio_path = str(
require_file_exists(args.prompt_audio, parser, "prompt audio file")
)
prompt_audio_path = str(require_file_exists(args.prompt_audio, parser, "prompt audio file"))
reference_audio_path = None
if args.reference_audio:
reference_audio_path = str(
require_file_exists(args.reference_audio, parser, "reference audio file")
)
reference_audio_path = str(require_file_exists(args.reference_audio, parser, "reference audio file"))
success_count = 0
@@ -346,8 +331,7 @@ def cmd_batch(args, parser):
cfg_value=args.cfg_value,
inference_timesteps=args.inference_timesteps,
normalize=args.normalize,
denoise=args.denoise
and (prompt_audio_path is not None or reference_audio_path is not None),
denoise=args.denoise and (prompt_audio_path is not None or reference_audio_path is not None),
)
output_file = output_dir / f"output_{i:03d}.wav"
@@ -355,6 +339,12 @@ def cmd_batch(args, parser):
duration = len(audio_array) / model.tts_model.sample_rate
print(f"Saved: {output_file} ({duration:.2f}s)", file=sys.stderr)
maybe_write_timestamps(
args,
text=final_text,
audio_path=output_file,
sample_rate=model.tts_model.sample_rate,
)
success_count += 1
except Exception as e:
@@ -363,6 +353,41 @@ def cmd_batch(args, parser):
print(f"\nBatch finished: {success_count}/{len(texts)} succeeded", file=sys.stderr)
def default_timestamp_path(audio_path: Path) -> Path:
return audio_path.with_suffix(".timestamps.json")
def maybe_write_timestamps(args, *, text: str, audio_path: Path, sample_rate: int) -> None:
if not getattr(args, "timestamps", False):
return
timestamp_output = getattr(args, "timestamp_output", None)
output_path = Path(timestamp_output) if timestamp_output else default_timestamp_path(audio_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
result = align_audio_file(
audio_path=str(audio_path),
text=text,
sample_rate=sample_rate,
backend=args.timestamp_backend,
level=args.timestamp_level,
model_name=args.timestamp_model,
device=args.timestamp_device,
language=args.timestamp_language,
)
except Exception as exc:
if getattr(args, "timestamp_strict", False):
raise SystemExit(f"Timestamp alignment failed: {exc}") from exc
print(f"Warning: Timestamp alignment failed: {exc}", file=sys.stderr)
return
with open(output_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
f.write("\n")
print(f"Saved timestamps to: {output_path}", file=sys.stderr)
# -----------------------------
# Parser
# -----------------------------
@@ -387,9 +412,7 @@ def _add_common_generation_args(parser):
default=10,
help="Inference steps (int, recommended 430, default: 10)",
)
parser.add_argument(
"--normalize", action="store_true", help="Enable text normalization"
)
parser.add_argument("--normalize", action="store_true", help="Enable text normalization")
def _add_prompt_reference_args(parser):
@@ -398,12 +421,8 @@ def _add_prompt_reference_args(parser):
"-pa",
help="Prompt audio file path (continuation mode, requires --prompt-text or --prompt-file)",
)
parser.add_argument(
"--prompt-text", "-pt", help="Text corresponding to the prompt audio"
)
parser.add_argument(
"--prompt-file", type=str, help="Text file corresponding to the prompt audio"
)
parser.add_argument("--prompt-text", "-pt", help="Text corresponding to the prompt audio")
parser.add_argument("--prompt-file", type=str, help="Text file corresponding to the prompt audio")
parser.add_argument(
"--reference-audio",
"-ra",
@@ -430,15 +449,9 @@ def _add_model_args(parser):
default="auto",
help="Runtime device: auto, cpu, mps, cuda, or cuda:N (default: auto)",
)
parser.add_argument(
"--cache-dir", type=str, help="Cache directory for Hub downloads"
)
parser.add_argument(
"--local-files-only", action="store_true", help="Disable network access"
)
parser.add_argument(
"--no-denoiser", action="store_true", help="Disable denoiser model loading"
)
parser.add_argument("--cache-dir", type=str, help="Cache directory for Hub downloads")
parser.add_argument("--local-files-only", action="store_true", help="Disable network access")
parser.add_argument("--no-denoiser", action="store_true", help="Disable denoiser model loading")
parser.add_argument(
"--no-optimize",
action="store_true",
@@ -453,9 +466,7 @@ def _add_model_args(parser):
def _add_lora_args(parser):
parser.add_argument("--lora-path", type=str, help="Path to LoRA weights")
parser.add_argument(
"--lora-r", type=int, default=32, help="LoRA rank (positive int, default: 32)"
)
parser.add_argument("--lora-r", type=int, default=32, help="LoRA rank (positive int, default: 32)")
parser.add_argument(
"--lora-alpha",
type=int,
@@ -468,12 +479,8 @@ def _add_lora_args(parser):
default=0.0,
help="LoRA dropout rate (0.01.0, default: 0.0)",
)
parser.add_argument(
"--lora-disable-lm", action="store_true", help="Disable LoRA on LM layers"
)
parser.add_argument(
"--lora-disable-dit", action="store_true", help="Disable LoRA on DiT layers"
)
parser.add_argument("--lora-disable-lm", action="store_true", help="Disable LoRA on LM layers")
parser.add_argument("--lora-disable-dit", action="store_true", help="Disable LoRA on DiT layers")
parser.add_argument(
"--lora-enable-proj",
action="store_true",
@@ -481,6 +488,52 @@ def _add_lora_args(parser):
)
def _add_timestamp_args(parser, *, include_output: bool = True):
parser.add_argument(
"--timestamps",
action="store_true",
help="Run post-generation timestamp alignment and write a JSON sidecar file",
)
if include_output:
parser.add_argument(
"--timestamp-output",
type=str,
help="Output timestamp JSON path (default: output audio path with .timestamps.json suffix)",
)
parser.add_argument(
"--timestamp-level",
choices=["segment", "word", "char"],
default="word",
help="Timestamp granularity (default: word; char is best-effort)",
)
parser.add_argument(
"--timestamp-backend",
choices=["stable-ts"],
default="stable-ts",
help="Timestamp alignment backend (default: stable-ts)",
)
parser.add_argument(
"--timestamp-model",
default="base",
help="stable-ts Whisper model name (default: base)",
)
parser.add_argument(
"--timestamp-language",
default=None,
help="Language hint for timestamp alignment, e.g. zh or en",
)
parser.add_argument(
"--timestamp-device",
default=None,
help="Device for timestamp alignment, e.g. cuda or cpu",
)
parser.add_argument(
"--timestamp-strict",
action="store_true",
help="Fail the command if timestamp alignment fails",
)
def _build_parser():
parser = argparse.ArgumentParser(
description="VoxCPM CLI - VoxCPM2-first voice design, cloning, and batch processing",
@@ -496,37 +549,25 @@ Examples:
subparsers = parser.add_subparsers(dest="command")
design_parser = subparsers.add_parser(
"design", help="Generate speech with VoxCPM2-first voice design"
)
design_parser = subparsers.add_parser("design", help="Generate speech with VoxCPM2-first voice design")
_add_common_generation_args(design_parser)
_add_prompt_reference_args(design_parser)
_add_model_args(design_parser)
_add_lora_args(design_parser)
design_parser.add_argument(
"--output", "-o", required=True, help="Output audio file path"
)
_add_timestamp_args(design_parser)
design_parser.add_argument("--output", "-o", required=True, help="Output audio file path")
clone_parser = subparsers.add_parser(
"clone", help="Clone a voice with reference/prompt audio"
)
clone_parser = subparsers.add_parser("clone", help="Clone a voice with reference/prompt audio")
_add_common_generation_args(clone_parser)
_add_prompt_reference_args(clone_parser)
_add_model_args(clone_parser)
_add_lora_args(clone_parser)
clone_parser.add_argument(
"--output", "-o", required=True, help="Output audio file path"
)
_add_timestamp_args(clone_parser)
clone_parser.add_argument("--output", "-o", required=True, help="Output audio file path")
batch_parser = subparsers.add_parser(
"batch", help="Batch-generate one line per output file"
)
batch_parser.add_argument(
"--input", "-i", required=True, help="Input text file (one text per line)"
)
batch_parser.add_argument(
"--output-dir", "-od", required=True, help="Output directory"
)
batch_parser = subparsers.add_parser("batch", help="Batch-generate one line per output file")
batch_parser.add_argument("--input", "-i", required=True, help="Input text file (one text per line)")
batch_parser.add_argument("--output-dir", "-od", required=True, help="Output directory")
batch_parser.add_argument(
"--control",
type=str,
@@ -545,20 +586,17 @@ Examples:
default=10,
help="Inference steps (int, recommended 430, default: 10)",
)
batch_parser.add_argument(
"--normalize", action="store_true", help="Enable text normalization"
)
batch_parser.add_argument("--normalize", action="store_true", help="Enable text normalization")
_add_model_args(batch_parser)
_add_lora_args(batch_parser)
_add_timestamp_args(batch_parser, include_output=False)
# Validate subcommand
validate_parser = subparsers.add_parser(
"validate",
help="Validate a training data manifest (JSONL) before fine-tuning",
)
validate_parser.add_argument(
"--manifest", "-m", required=True, help="Path to JSONL training manifest"
)
validate_parser.add_argument("--manifest", "-m", required=True, help="Path to JSONL training manifest")
validate_parser.add_argument(
"--sample-rate",
type=int,
@@ -571,22 +609,17 @@ Examples:
default=0,
help="Maximum number of samples to validate (0 = all, default: 0)",
)
validate_parser.add_argument(
"--verbose", "-v", action="store_true", help="Print per-sample progress"
)
validate_parser.add_argument("--verbose", "-v", action="store_true", help="Print per-sample progress")
# Legacy root arguments
parser.add_argument("--input", "-i", help="Input text file (batch mode only)")
parser.add_argument(
"--output-dir", "-od", help="Output directory (batch mode only)"
)
parser.add_argument("--output-dir", "-od", help="Output directory (batch mode only)")
_add_common_generation_args(parser)
parser.add_argument(
"--output", "-o", help="Output audio file path (single or clone mode)"
)
parser.add_argument("--output", "-o", help="Output audio file path (single or clone mode)")
_add_prompt_reference_args(parser)
_add_model_args(parser)
_add_lora_args(parser)
_add_timestamp_args(parser)
return parser
@@ -595,9 +628,7 @@ def _dispatch_legacy(args, parser):
warn_legacy_mode()
if args.input and args.text:
parser.error(
"Use either batch mode (--input) or single mode (--text), not both."
)
parser.error("Use either batch mode (--input) or single mode (--text), not both.")
if args.input:
if not args.output_dir:
@@ -607,12 +638,7 @@ def _dispatch_legacy(args, parser):
if not args.text or not args.output:
parser.error("Single-sample legacy mode requires --text and --output")
if (
args.prompt_audio
or args.prompt_text
or args.prompt_file
or args.reference_audio
):
if args.prompt_audio or args.prompt_text or args.prompt_file or args.reference_audio:
return cmd_clone(args, parser)
return cmd_design(args, parser)
+8
View File
@@ -0,0 +1,8 @@
from .base import TimestampItem, TimestampResult
from .postprocess import align_audio_file
__all__ = [
"TimestampItem",
"TimestampResult",
"align_audio_file",
]
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Literal
TimestampLevel = Literal["segment", "word", "char"]
@dataclass
class TimestampItem:
text: str
start: float
end: float
level: str
def to_dict(self) -> dict:
return asdict(self)
@dataclass
class TimestampResult:
audio_path: str
sample_rate: int | None
backend: str
level: str
text: str
items: list[TimestampItem]
warning: str | None = None
def to_dict(self) -> dict:
payload = asdict(self)
payload["items"] = [item.to_dict() for item in self.items]
return payload
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from .base import TimestampLevel
def align_audio_file(
*,
audio_path: str,
text: str,
sample_rate: int | None = None,
backend: str = "stable-ts",
level: TimestampLevel = "word",
model_name: str = "base",
device: str | None = None,
language: str | None = None,
) -> dict:
if backend != "stable-ts":
raise ValueError(f"Unsupported timestamp backend: {backend}")
from .stable_ts import StableTSAligner
aligner = StableTSAligner(
model_name=model_name,
device=device,
language=language,
)
result = aligner.align(
audio_path=audio_path,
text=text,
sample_rate=sample_rate,
level=level,
)
return result.to_dict()
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from typing import Any
from .base import TimestampItem, TimestampLevel, TimestampResult
class StableTSAligner:
def __init__(
self,
model_name: str = "base",
device: str | None = None,
language: str | None = None,
) -> None:
try:
import stable_whisper
except ImportError as exc:
raise ImportError(
"stable-ts is required for timestamp alignment. " 'Install with: pip install "voxcpm[timestamps]"'
) from exc
self.model = stable_whisper.load_model(model_name, device=device)
self.model_name = model_name
self.device = device
self.language = language
def align(
self,
*,
audio_path: str,
text: str,
sample_rate: int | None = None,
level: TimestampLevel = "word",
) -> TimestampResult:
result = self.model.align(audio_path, text, language=self.language)
items = extract_timestamp_items(result, level)
return TimestampResult(
audio_path=audio_path,
sample_rate=sample_rate,
backend="stable-ts",
level=level,
text=text,
items=items,
)
def extract_timestamp_items(result: Any, level: TimestampLevel) -> list[TimestampItem]:
segments = _get_value(result, "segments", []) or []
if level == "segment":
return [
TimestampItem(
text=str(_get_value(segment, "text", "")).strip(),
start=float(_get_value(segment, "start", 0.0) or 0.0),
end=float(_get_value(segment, "end", 0.0) or 0.0),
level="segment",
)
for segment in segments
if str(_get_value(segment, "text", "")).strip()
]
words = []
for segment in segments:
for word in _get_value(segment, "words", []) or []:
text = str(_get_value(word, "word", _get_value(word, "text", ""))).strip()
if not text:
continue
words.append(
TimestampItem(
text=text,
start=float(_get_value(word, "start", 0.0) or 0.0),
end=float(_get_value(word, "end", 0.0) or 0.0),
level="word",
)
)
if level == "char":
return split_word_items_to_chars(words)
return words
def split_word_items_to_chars(words: list[TimestampItem]) -> list[TimestampItem]:
chars = []
for word in words:
text = word.text.strip()
if not text:
continue
duration = max(word.end - word.start, 0.0)
step = duration / len(text)
for idx, char in enumerate(text):
chars.append(
TimestampItem(
text=char,
start=word.start + idx * step,
end=word.start + (idx + 1) * step,
level="char",
)
)
return chars
def _get_value(obj: Any, key: str, default: Any = None) -> Any:
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
+137 -7
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import importlib.util
import json
import sys
import types
from pathlib import Path
@@ -322,6 +323,140 @@ def test_batch_subcommand_applies_control(monkeypatch, tmp_path):
]
def test_design_writes_timestamp_json_when_requested(monkeypatch, tmp_path):
dummy_model = DummyModel()
timestamp_calls = []
def fake_align_audio_file(**kwargs):
timestamp_calls.append(kwargs)
return {
"audio_path": kwargs["audio_path"],
"sample_rate": kwargs["sample_rate"],
"backend": kwargs["backend"],
"level": kwargs["level"],
"text": kwargs["text"],
"items": [{"text": "hello", "start": 0.0, "end": 0.5, "level": "word"}],
"warning": None,
}
monkeypatch.setattr(cli, "load_model", lambda args: dummy_model)
monkeypatch.setattr(cli, "align_audio_file", fake_align_audio_file)
patch_soundfile_write(monkeypatch)
output = tmp_path / "out.wav"
run_main(
monkeypatch,
[
"design",
"--text",
"hello",
"--output",
str(output),
"--timestamps",
"--timestamp-language",
"en",
],
)
timestamp_path = tmp_path / "out.timestamps.json"
payload = json.loads(timestamp_path.read_text(encoding="utf-8"))
assert payload["audio_path"] == str(output)
assert payload["text"] == "hello"
assert payload["items"][0]["text"] == "hello"
assert timestamp_calls[0]["language"] == "en"
assert timestamp_calls[0]["level"] == "word"
def test_timestamp_alignment_failure_warns_by_default(monkeypatch, tmp_path, capsys):
dummy_model = DummyModel()
def fake_align_audio_file(**kwargs):
raise RuntimeError("alignment failed")
monkeypatch.setattr(cli, "load_model", lambda args: dummy_model)
monkeypatch.setattr(cli, "align_audio_file", fake_align_audio_file)
patch_soundfile_write(monkeypatch)
run_main(
monkeypatch,
[
"design",
"--text",
"hello",
"--output",
str(tmp_path / "out.wav"),
"--timestamps",
],
)
assert "Timestamp alignment failed" in capsys.readouterr().err
def test_timestamp_alignment_failure_exits_in_strict_mode(monkeypatch, tmp_path):
dummy_model = DummyModel()
def fake_align_audio_file(**kwargs):
raise RuntimeError("alignment failed")
monkeypatch.setattr(cli, "load_model", lambda args: dummy_model)
monkeypatch.setattr(cli, "align_audio_file", fake_align_audio_file)
patch_soundfile_write(monkeypatch)
with pytest.raises(SystemExit):
run_main(
monkeypatch,
[
"design",
"--text",
"hello",
"--output",
str(tmp_path / "out.wav"),
"--timestamps",
"--timestamp-strict",
],
)
def test_batch_writes_one_timestamp_json_per_output(monkeypatch, tmp_path):
dummy_model = DummyModel()
def fake_align_audio_file(**kwargs):
return {
"audio_path": kwargs["audio_path"],
"sample_rate": kwargs["sample_rate"],
"backend": kwargs["backend"],
"level": kwargs["level"],
"text": kwargs["text"],
"items": [{"text": kwargs["text"], "start": 0.0, "end": 0.5, "level": "word"}],
"warning": None,
}
input_file = tmp_path / "texts.txt"
input_file.write_text("hello\nworld\n", encoding="utf-8")
output_dir = tmp_path / "outs"
monkeypatch.setattr(cli, "load_model", lambda args: dummy_model)
monkeypatch.setattr(cli, "align_audio_file", fake_align_audio_file)
patch_soundfile_write(monkeypatch)
run_main(
monkeypatch,
[
"batch",
"--input",
str(input_file),
"--output-dir",
str(output_dir),
"--timestamps",
],
)
first = json.loads((output_dir / "output_001.timestamps.json").read_text(encoding="utf-8"))
second = json.loads((output_dir / "output_002.timestamps.json").read_text(encoding="utf-8"))
assert first["text"] == "hello"
assert second["text"] == "world"
def test_legacy_clone_with_prompt_file_still_works(monkeypatch, tmp_path, capsys):
dummy_model = DummyModel()
prompt_audio = tmp_path / "prompt.wav"
@@ -455,10 +590,7 @@ def test_clone_rejects_prompt_audio_without_transcript(monkeypatch, tmp_path, ca
with pytest.raises(SystemExit):
cli.main()
assert (
"--prompt-audio requires --prompt-text or --prompt-file"
in capsys.readouterr().err
)
assert "--prompt-audio requires --prompt-text or --prompt-file" in capsys.readouterr().err
def test_clone_rejects_transcript_without_prompt_audio(monkeypatch, tmp_path, capsys):
@@ -480,9 +612,7 @@ def test_clone_rejects_transcript_without_prompt_audio(monkeypatch, tmp_path, ca
with pytest.raises(SystemExit):
cli.main()
assert (
"--prompt-text/--prompt-file requires --prompt-audio" in capsys.readouterr().err
)
assert "--prompt-text/--prompt-file requires --prompt-audio" in capsys.readouterr().err
def test_batch_rejects_control_with_prompt_transcript(monkeypatch, tmp_path, capsys):
+34
View File
@@ -0,0 +1,34 @@
from pathlib import Path
import sys
import types
ROOT = Path(__file__).resolve().parents[1]
pkg = types.ModuleType("voxcpm")
pkg.__path__ = [str(ROOT / "src" / "voxcpm")]
sys.modules.setdefault("voxcpm", pkg)
from voxcpm.timestamps.base import TimestampItem
from voxcpm.timestamps.stable_ts import split_word_items_to_chars
def test_split_word_items_to_chars_evenly_distributes_word_duration():
chars = split_word_items_to_chars([TimestampItem(text="欢迎", start=0.5, end=0.9, level="word")])
assert [item.text for item in chars] == ["", ""]
assert chars[0].start == 0.5
assert chars[0].end == 0.7
assert chars[1].start == 0.7
assert chars[1].end == 0.9
assert all(item.level == "char" for item in chars)
def test_split_word_items_to_chars_skips_empty_text():
chars = split_word_items_to_chars(
[
TimestampItem(text=" ", start=0.0, end=0.2, level="word"),
TimestampItem(text="", start=0.2, end=0.4, level="word"),
]
)
assert len(chars) == 1
assert chars[0].text == ""