Add optional timestamp alignment
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from .base import TimestampItem, TimestampResult
|
||||
from .postprocess import align_audio_file
|
||||
|
||||
__all__ = [
|
||||
"TimestampItem",
|
||||
"TimestampResult",
|
||||
"align_audio_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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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 == "你"
|
||||
Reference in New Issue
Block a user