| import json |
| import logging |
| import re |
| import tempfile |
| import time |
| from pathlib import Path |
| from typing import Optional, Union |
|
|
| import safetensors.torch |
| import torch |
| import torchaudio |
| from huggingface_hub import hf_hub_download, list_repo_files |
| from lhotse.utils import fix_random_seed |
|
|
| from zipvoice.bin.infer_zipvoice import generate_sentence, get_vocoder |
| from zipvoice.models.zipvoice import ZipVoice |
| from zipvoice.tokenizer.tokenizer import SimpleTokenizer |
| from zipvoice.utils.checkpoint import load_checkpoint |
| from zipvoice.utils.feature import VocosFbank |
|
|
| DEFAULT_REPO_ID = "contextboxai/ViZipvoice" |
| DEFAULT_CHECKPOINT_NAME = "latest" |
| CHECKPOINT_RE = re.compile(r"^checkpoint-(\d+)\.pt$") |
| SENTENCE_SPLIT_PATTERN = re.compile(r"[^.??。]+(?:[.??。]+|$)", re.S) |
| PUNCTUATION_NO_SPACE_BEFORE = r",.;:!?…%" |
| OPENING_QUOTES_AND_BRACKETS = r"\(\[\{«“‘" |
| CLOSING_QUOTES_AND_BRACKETS = r"\)\]\}»”’" |
|
|
|
|
| def _resolve_device(device: Optional[Union[str, torch.device]] = None) -> torch.device: |
| if device is not None: |
| return torch.device(device) |
| if torch.cuda.is_available(): |
| return torch.device("cuda", 0) |
| if torch.backends.mps.is_available(): |
| return torch.device("mps") |
| return torch.device("cpu") |
|
|
|
|
| def _download_model_files( |
| repo_id: str, |
| revision: Optional[str], |
| checkpoint_name: str, |
| ) -> tuple[Path, Path, Path]: |
| checkpoint_name = _resolve_hf_checkpoint_name( |
| repo_id=repo_id, |
| revision=revision, |
| checkpoint_name=checkpoint_name, |
| ) |
| checkpoint_path = Path( |
| hf_hub_download( |
| repo_id=repo_id, |
| filename=checkpoint_name, |
| revision=revision, |
| ) |
| ) |
| model_config_path = _download_config_file( |
| repo_id=repo_id, |
| revision=revision, |
| ) |
| token_file = Path( |
| hf_hub_download( |
| repo_id=repo_id, |
| filename="tokens.txt", |
| revision=revision, |
| ) |
| ) |
| return checkpoint_path, model_config_path, token_file |
|
|
|
|
| def _download_config_file(repo_id: str, revision: Optional[str]) -> Path: |
| last_error = None |
| for filename in ("config.json", "model.json"): |
| try: |
| return Path( |
| hf_hub_download( |
| repo_id=repo_id, |
| filename=filename, |
| revision=revision, |
| ) |
| ) |
| except Exception as exc: |
| last_error = exc |
| raise FileNotFoundError("No config.json or model.json file found.") from last_error |
|
|
|
|
| def _checkpoint_step(filename: str) -> int: |
| match = CHECKPOINT_RE.match(Path(filename).name) |
| return int(match.group(1)) if match else -1 |
|
|
|
|
| def _select_latest_checkpoint(filenames: list[str]) -> str: |
| checkpoints = [ |
| filename for filename in filenames if _checkpoint_step(filename) >= 0 |
| ] |
| if checkpoints: |
| return max(checkpoints, key=lambda filename: _checkpoint_step(filename)) |
| raise FileNotFoundError("No checkpoint-<step>.pt file found.") |
|
|
|
|
| def _resolve_hf_checkpoint_name( |
| repo_id: str, |
| revision: Optional[str], |
| checkpoint_name: str, |
| ) -> str: |
| if checkpoint_name != "latest": |
| return checkpoint_name |
| filenames = list_repo_files(repo_id=repo_id, revision=revision) |
| return _select_latest_checkpoint(filenames) |
|
|
|
|
| def _resolve_local_checkpoint_path( |
| model_dir: Path, |
| checkpoint_name: str, |
| ) -> Path: |
| if checkpoint_name != "latest": |
| return model_dir / checkpoint_name |
| filenames = [path.name for path in model_dir.iterdir() if path.is_file()] |
| return model_dir / _select_latest_checkpoint(filenames) |
|
|
|
|
| def _resolve_local_config_path(model_dir: Path) -> Path: |
| for filename in ("config.json", "model.json"): |
| config_path = model_dir / filename |
| if config_path.is_file(): |
| return config_path |
| raise FileNotFoundError(f"No config.json or model.json file found in {model_dir}") |
|
|
|
|
| def cleanup_vietnamese_spacing(text: str) -> str: |
| text = re.sub(r"\s+", " ", text.strip()) |
| text = re.sub( |
| rf"\s+([{re.escape(PUNCTUATION_NO_SPACE_BEFORE)}])", |
| r"\1", |
| text, |
| ) |
| text = re.sub( |
| rf"\s+([{CLOSING_QUOTES_AND_BRACKETS}])", |
| r"\1", |
| text, |
| ) |
| text = re.sub( |
| rf"([{OPENING_QUOTES_AND_BRACKETS}])\s+", |
| r"\1", |
| text, |
| ) |
| text = re.sub( |
| rf"([{re.escape(PUNCTUATION_NO_SPACE_BEFORE)}])" |
| rf"([^\s{CLOSING_QUOTES_AND_BRACKETS}])", |
| r"\1 \2", |
| text, |
| ) |
| return text.strip() |
|
|
|
|
| def normalize_vietnamese_text(text: str, enabled: bool = True) -> str: |
| if not enabled: |
| return text.strip() |
|
|
| try: |
| from soe_vinorm import normalize_text |
| except ImportError as exc: |
| raise RuntimeError( |
| "Vietnamese normalization requires soe-vinorm. " |
| "Install it with `pip install soe-vinorm`." |
| ) from exc |
|
|
| return cleanup_vietnamese_spacing(normalize_text(text)) |
|
|
|
|
| def split_text_into_sentences(text: str) -> list[str]: |
| text = text.strip() |
| if not text: |
| return [] |
|
|
| sentences = [ |
| match.group(0).strip() |
| for match in SENTENCE_SPLIT_PATTERN.finditer(text) |
| if match.group(0).strip() |
| ] |
| return sentences or [text] |
|
|
|
|
| def count_sentence_words(sentence: str) -> int: |
| return len(re.findall(r"\w+", sentence, flags=re.UNICODE)) |
|
|
|
|
| def get_sentence_inference_params( |
| sentence: str, |
| base_num_step: int, |
| base_speed: float, |
| ) -> tuple[int, float, int]: |
| word_count = count_sentence_words(sentence) |
| if word_count == 1: |
| return max(base_num_step, 24), 0.6, word_count |
| if 2 <= word_count <= 4: |
| return base_num_step, 0.8, word_count |
| return base_num_step, base_speed, word_count |
|
|
|
|
| def match_audio_channels( |
| first: torch.Tensor, |
| second: torch.Tensor, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| if first.shape[0] == second.shape[0]: |
| return first, second |
| if first.shape[0] == 1: |
| return first.expand(second.shape[0], -1), second |
| if second.shape[0] == 1: |
| return first, second.expand(first.shape[0], -1) |
|
|
| channels = min(first.shape[0], second.shape[0]) |
| return first[:channels], second[:channels] |
|
|
|
|
| def append_with_crossfade( |
| first: torch.Tensor, |
| second: torch.Tensor, |
| crossfade_samples: int, |
| ) -> torch.Tensor: |
| first, second = match_audio_channels(first, second) |
| fade_len = min(crossfade_samples, first.shape[1], second.shape[1]) |
| if fade_len <= 0: |
| return torch.cat([first, second], dim=1) |
|
|
| fade_out = torch.linspace( |
| 1.0, |
| 0.0, |
| fade_len, |
| dtype=first.dtype, |
| device=first.device, |
| ).unsqueeze(0) |
| fade_in = torch.linspace( |
| 0.0, |
| 1.0, |
| fade_len, |
| dtype=second.dtype, |
| device=second.device, |
| ).unsqueeze(0) |
| overlap = first[:, -fade_len:] * fade_out + second[:, :fade_len] * fade_in |
| return torch.cat([first[:, :-fade_len], overlap, second[:, fade_len:]], dim=1) |
|
|
|
|
| def apply_fade(audio: torch.Tensor, fade_in_samples: int, fade_out_samples: int) -> torch.Tensor: |
| if audio.numel() == 0: |
| return audio |
|
|
| audio = audio.clone() |
| if fade_in_samples > 0: |
| fade_len = min(fade_in_samples, audio.shape[1]) |
| fade = torch.linspace( |
| 0.0, |
| 1.0, |
| fade_len, |
| dtype=audio.dtype, |
| device=audio.device, |
| ).unsqueeze(0) |
| audio[:, :fade_len] *= fade |
|
|
| if fade_out_samples > 0: |
| fade_len = min(fade_out_samples, audio.shape[1]) |
| fade = torch.linspace( |
| 1.0, |
| 0.0, |
| fade_len, |
| dtype=audio.dtype, |
| device=audio.device, |
| ).unsqueeze(0) |
| audio[:, -fade_len:] *= fade |
|
|
| return audio |
|
|
|
|
| def postprocess_audio_segments( |
| segment_paths: list[Path], |
| output_path: Path, |
| sampling_rate: int, |
| crossfade_ms: int, |
| silence_ms: int, |
| fade_in_ms: int, |
| fade_out_ms: int, |
| ) -> None: |
| if not segment_paths: |
| raise RuntimeError("No generated audio segments to postprocess.") |
|
|
| crossfade_samples = int(sampling_rate * max(crossfade_ms, 0) / 1000) |
| silence_samples = int(sampling_rate * max(silence_ms, 0) / 1000) |
| fade_in_samples = int(sampling_rate * max(fade_in_ms, 0) / 1000) |
| fade_out_samples = int(sampling_rate * max(fade_out_ms, 0) / 1000) |
|
|
| combined = None |
| for index, segment_path in enumerate(segment_paths): |
| audio, sr = torchaudio.load(str(segment_path)) |
| if sr != sampling_rate: |
| audio = torchaudio.functional.resample(audio, sr, sampling_rate) |
|
|
| if index < len(segment_paths) - 1 and silence_samples > 0: |
| silence = torch.zeros( |
| audio.shape[0], |
| silence_samples, |
| dtype=audio.dtype, |
| device=audio.device, |
| ) |
| audio = torch.cat([audio, silence], dim=1) |
|
|
| if combined is None: |
| combined = audio |
| else: |
| combined = append_with_crossfade(combined, audio, crossfade_samples) |
|
|
| combined = apply_fade( |
| combined, |
| fade_in_samples=fade_in_samples, |
| fade_out_samples=fade_out_samples, |
| ) |
| combined = combined.clamp(min=-1.0, max=1.0).cpu() |
| torchaudio.save(str(output_path), combined, sampling_rate) |
|
|
|
|
| def wav_seconds(path: Union[str, Path]) -> float: |
| try: |
| import soundfile as sf |
|
|
| info = sf.info(str(path)) |
| return float(info.frames) / float(info.samplerate) |
| except Exception: |
| audio, sr = torchaudio.load(str(path)) |
| return float(audio.shape[-1]) / float(sr) |
|
|
|
|
| class ViZipVoiceTTS: |
| """Small wrapper for Vietnamese ZipVoice inference. |
| |
| The wrapper downloads model files from Hugging Face by default, builds the |
| ZipVoice model with the Vietnamese character tokenizer, and exposes a |
| single synthesize method. |
| """ |
|
|
| def __init__( |
| self, |
| repo_id: str = DEFAULT_REPO_ID, |
| revision: Optional[str] = None, |
| model_dir: Optional[Union[str, Path]] = None, |
| checkpoint_name: str = DEFAULT_CHECKPOINT_NAME, |
| vocoder_path: Optional[Union[str, Path]] = None, |
| device: Optional[Union[str, torch.device]] = None, |
| use_fp16: bool = True, |
| num_threads: int = 1, |
| ) -> None: |
| try: |
| torch.set_num_threads(num_threads) |
| torch.set_num_interop_threads(num_threads) |
| except RuntimeError: |
| logging.debug("PyTorch thread settings were already initialized.") |
|
|
| self.repo_id = repo_id |
| self.revision = revision |
| self.device = _resolve_device(device) |
| self.use_fp16 = bool(use_fp16 and self.device.type == "cuda") |
|
|
| if model_dir is None: |
| checkpoint_path, model_config_path, token_file = _download_model_files( |
| repo_id=repo_id, |
| revision=revision, |
| checkpoint_name=checkpoint_name, |
| ) |
| else: |
| model_dir = Path(model_dir) |
| checkpoint_path = _resolve_local_checkpoint_path( |
| model_dir=model_dir, |
| checkpoint_name=checkpoint_name, |
| ) |
| model_config_path = _resolve_local_config_path(model_dir) |
| token_file = model_dir / "tokens.txt" |
|
|
| self.checkpoint_path = Path(checkpoint_path) |
| self.model_config_path = Path(model_config_path) |
| self.token_file = Path(token_file) |
| self._validate_model_files() |
|
|
| with self.model_config_path.open("r", encoding="utf-8") as f: |
| self.model_config = json.load(f) |
|
|
| self.tokenizer = SimpleTokenizer(token_file=str(self.token_file)) |
| self.model = ZipVoice( |
| **self.model_config["model"], |
| vocab_size=self.tokenizer.vocab_size, |
| pad_id=self.tokenizer.pad_id, |
| ) |
| self._load_checkpoint() |
| self.model.to(self.device) |
| self.model.eval() |
|
|
| self.feature_extractor = VocosFbank() |
| self.vocoder = get_vocoder(str(vocoder_path) if vocoder_path else None) |
| self.vocoder.to(self.device) |
| self.vocoder.eval() |
| self.sampling_rate = int(self.model_config["feature"]["sampling_rate"]) |
|
|
| logging.info( |
| "Loaded ViZipVoice from %s on %s | fp16 autocast: %s", |
| self.checkpoint_path, |
| self.device, |
| self.use_fp16, |
| ) |
|
|
| def _validate_model_files(self) -> None: |
| missing = [ |
| path |
| for path in [self.checkpoint_path, self.model_config_path, self.token_file] |
| if not path.is_file() |
| ] |
| if missing: |
| missing_text = ", ".join(str(path) for path in missing) |
| raise FileNotFoundError(f"Missing ViZipVoice model file(s): {missing_text}") |
|
|
| def _load_checkpoint(self) -> None: |
| suffix = self.checkpoint_path.suffix.lower() |
| if suffix == ".safetensors": |
| safetensors.torch.load_model(self.model, str(self.checkpoint_path)) |
| elif suffix == ".pt": |
| load_checkpoint( |
| filename=self.checkpoint_path, |
| model=self.model, |
| strict=True, |
| ) |
| else: |
| raise ValueError(f"Unsupported checkpoint format: {self.checkpoint_path}") |
|
|
| @torch.inference_mode() |
| def synthesize( |
| self, |
| prompt_wav: Union[str, Path], |
| prompt_text: str, |
| text: str, |
| output_path: Union[str, Path] = "output.wav", |
| num_step: int = 16, |
| guidance_scale: float = 1.0, |
| speed: float = 1.0, |
| t_shift: float = 0.5, |
| target_rms: float = 0.1, |
| feat_scale: float = 0.1, |
| max_duration: float = 100, |
| remove_long_sil: bool = False, |
| seed: Optional[int] = 666, |
| normalize_vietnamese: bool = True, |
| split_sentences: bool = True, |
| crossfade_ms: int = 80, |
| silence_ms: int = 180, |
| fade_in_ms: int = 20, |
| fade_out_ms: int = 80, |
| ) -> dict: |
| if seed is not None and seed >= 0: |
| fix_random_seed(int(seed)) |
|
|
| prompt_text = normalize_vietnamese_text( |
| prompt_text, |
| enabled=normalize_vietnamese, |
| ) |
| text = normalize_vietnamese_text( |
| text, |
| enabled=normalize_vietnamese, |
| ) |
| target_sentences = split_text_into_sentences(text) if split_sentences else [text] |
| if not target_sentences: |
| raise ValueError("No valid text to synthesize.") |
|
|
| output_path = Path(output_path) |
| output_path.parent.mkdir(parents=True, exist_ok=True) |
|
|
| segment_paths = [] |
| segment_metrics = [] |
| segment_settings = [] |
| start_time = time.time() |
| with tempfile.TemporaryDirectory( |
| prefix=f"{output_path.stem}_segments_", |
| dir=str(output_path.parent), |
| ) as segment_dir_name: |
| segment_dir = Path(segment_dir_name) |
| with torch.autocast( |
| device_type="cuda", |
| dtype=torch.float16, |
| enabled=self.use_fp16, |
| ): |
| for index, sentence in enumerate(target_sentences, start=1): |
| sentence_num_step, sentence_speed, word_count = ( |
| get_sentence_inference_params( |
| sentence=sentence, |
| base_num_step=int(num_step), |
| base_speed=float(speed), |
| ) |
| ) |
| segment_path = segment_dir / f"segment_{index:03d}.wav" |
| metrics = generate_sentence( |
| save_path=str(segment_path), |
| prompt_text=prompt_text, |
| prompt_wav=str(prompt_wav), |
| text=sentence, |
| model=self.model, |
| vocoder=self.vocoder, |
| tokenizer=self.tokenizer, |
| feature_extractor=self.feature_extractor, |
| device=self.device, |
| num_step=sentence_num_step, |
| guidance_scale=float(guidance_scale), |
| speed=sentence_speed, |
| t_shift=float(t_shift), |
| target_rms=float(target_rms), |
| feat_scale=float(feat_scale), |
| sampling_rate=self.sampling_rate, |
| max_duration=float(max_duration), |
| remove_long_sil=bool(remove_long_sil), |
| ) |
| segment_paths.append(segment_path) |
| segment_metrics.append(metrics) |
| segment_settings.append( |
| { |
| "index": index, |
| "word_count": word_count, |
| "speed": sentence_speed, |
| "num_step": sentence_num_step, |
| "text": sentence, |
| } |
| ) |
|
|
| postprocess_audio_segments( |
| segment_paths=segment_paths, |
| output_path=output_path, |
| sampling_rate=self.sampling_rate, |
| crossfade_ms=int(crossfade_ms), |
| silence_ms=int(silence_ms), |
| fade_in_ms=int(fade_in_ms), |
| fade_out_ms=int(fade_out_ms), |
| ) |
|
|
| elapsed = time.time() - start_time |
| audio_seconds = wav_seconds(output_path) |
| t_no_vocoder = sum(item.get("t_no_vocoder", 0.0) for item in segment_metrics) |
| t_vocoder = sum(item.get("t_vocoder", 0.0) for item in segment_metrics) |
| rtf = elapsed / audio_seconds if audio_seconds else 0.0 |
| return { |
| "t": elapsed, |
| "t_no_vocoder": t_no_vocoder, |
| "t_vocoder": t_vocoder, |
| "wav_seconds": audio_seconds, |
| "rtf": rtf, |
| "rtf_no_vocoder": t_no_vocoder / audio_seconds if audio_seconds else 0.0, |
| "rtf_vocoder": t_vocoder / audio_seconds if audio_seconds else 0.0, |
| "segments": len(segment_paths), |
| "segment_settings": segment_settings, |
| "segment_metrics": segment_metrics, |
| "crossfade_ms": int(crossfade_ms), |
| "silence_ms": int(silence_ms), |
| "fade_in_ms": int(fade_in_ms), |
| "fade_out_ms": int(fade_out_ms), |
| } |
|
|