From 87af7d08544faf9951f5faf38fc4d1b10a28186a Mon Sep 17 00:00:00 2001 From: cccpro12 Date: Sat, 6 Jun 2026 16:29:46 +0800 Subject: [PATCH] Add optional timestamp alignment --- README.md | 17 ++ README_zh.md | 17 ++ pyproject.toml | 3 + src/voxcpm/cli.py | 246 +++++++++++++++------------ src/voxcpm/timestamps/__init__.py | 8 + src/voxcpm/timestamps/base.py | 33 ++++ src/voxcpm/timestamps/postprocess.py | 33 ++++ src/voxcpm/timestamps/stable_ts.py | 105 ++++++++++++ tests/test_cli.py | 144 +++++++++++++++- tests/test_timestamps.py | 34 ++++ 10 files changed, 523 insertions(+), 117 deletions(-) create mode 100644 src/voxcpm/timestamps/__init__.py create mode 100644 src/voxcpm/timestamps/base.py create mode 100644 src/voxcpm/timestamps/postprocess.py create mode 100644 src/voxcpm/timestamps/stable_ts.py create mode 100644 tests/test_timestamps.py diff --git a/README.md b/README.md index dc1a544..796abc9 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/README_zh.md b/README_zh.md index ec781e4..4ceab37 100644 --- a/README_zh.md +++ b/README_zh.md @@ -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 ``` diff --git a/pyproject.toml b/pyproject.toml index 45ae2ee..95659fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,9 @@ dev = [ "flake8>=3.8", "pre-commit>=2.0", ] +timestamps = [ + "stable-ts>=2.19.1", +] [project.scripts] voxcpm = "voxcpm.cli:main" diff --git a/src/voxcpm/cli.py b/src/voxcpm/cli.py index f6d40d6..794659b 100644 --- a/src/voxcpm/cli.py +++ b/src/voxcpm/cli.py @@ -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 4–30, 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.0–1.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 4–30, 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) diff --git a/src/voxcpm/timestamps/__init__.py b/src/voxcpm/timestamps/__init__.py new file mode 100644 index 0000000..9974800 --- /dev/null +++ b/src/voxcpm/timestamps/__init__.py @@ -0,0 +1,8 @@ +from .base import TimestampItem, TimestampResult +from .postprocess import align_audio_file + +__all__ = [ + "TimestampItem", + "TimestampResult", + "align_audio_file", +] diff --git a/src/voxcpm/timestamps/base.py b/src/voxcpm/timestamps/base.py new file mode 100644 index 0000000..5cb44b1 --- /dev/null +++ b/src/voxcpm/timestamps/base.py @@ -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 diff --git a/src/voxcpm/timestamps/postprocess.py b/src/voxcpm/timestamps/postprocess.py new file mode 100644 index 0000000..697fafb --- /dev/null +++ b/src/voxcpm/timestamps/postprocess.py @@ -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() diff --git a/src/voxcpm/timestamps/stable_ts.py b/src/voxcpm/timestamps/stable_ts.py new file mode 100644 index 0000000..d87f013 --- /dev/null +++ b/src/voxcpm/timestamps/stable_ts.py @@ -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) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2b568b1..9402123 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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): diff --git a/tests/test_timestamps.py b/tests/test_timestamps.py new file mode 100644 index 0000000..ae4ff01 --- /dev/null +++ b/tests/test_timestamps.py @@ -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 == "你"