from __future__ import annotations import argparse import logging import re import sys from pathlib import Path import numpy as np import soundfile as sf import torch PACKAGE_ROOT = Path(__file__).resolve().parent RUNTIME_ROOT = PACKAGE_ROOT / "runtime" sys.path.insert(0, str(RUNTIME_ROOT)) sys.path.insert(0, str(PACKAGE_ROOT)) import commons # noqa: E402 import utils # noqa: E402 from inflect_vits_frontend import run_vits_frontend # noqa: E402 from models import SynthesizerTrn # noqa: E402 from text import cleaned_text_to_sequence # noqa: E402 from text.symbols import symbols # noqa: E402 def split_text(text: str, limit: int = 280) -> list[str]: normalized = " ".join(text.split()) sentences = [ part.strip() for part in re.split(r"(?<=[.!?;:])\s+", normalized) if part.strip() ] chunks: list[str] = [] for sentence in sentences or [normalized]: while len(sentence) > limit: search = sentence[: limit + 1] punctuation = max(search.rfind(mark) for mark in (",", ";", ":")) split_at = ( punctuation + 1 if punctuation >= limit // 2 else sentence.rfind(" ", 0, limit + 1) ) if split_at < limit // 2: split_at = limit chunks.append(sentence[:split_at].strip()) sentence = sentence[split_at:].strip() if sentence: chunks.append(sentence) return chunks def boundary_pause_seconds(chunk: str) -> float: ending = chunk.rstrip()[-1:] if chunk.strip() else "" return { "?": 0.28, "!": 0.24, ".": 0.22, ";": 0.16, ":": 0.13, ",": 0.09, }.get(ending, 0.08) def edge_fade(waveform: np.ndarray, sample_rate: int, milliseconds: float = 5.0) -> np.ndarray: frames = min(round(sample_rate * milliseconds / 1000.0), waveform.size // 2) if frames <= 0: return waveform output = waveform.copy() ramp = np.linspace(0.0, 1.0, frames, endpoint=True, dtype=np.float32) output[:frames] *= ramp output[-frames:] *= ramp[::-1] return output class InflectTTS: def __init__(self, model_dir: str | Path = PACKAGE_ROOT, device: str = "cpu") -> None: self.root = Path(model_dir).resolve() self.device = torch.device(device) self.hps = utils.get_hparams_from_file(str(self.root / "config.json")) self.model = SynthesizerTrn( len(symbols), self.hps.data.filter_length // 2 + 1, self.hps.train.segment_size // self.hps.data.hop_length, **self.hps.model, ).to(self.device).eval() root_logger = logging.getLogger() previous_level = root_logger.level try: root_logger.setLevel(logging.WARNING) utils.load_checkpoint(str(self.root / "model.pth"), self.model, None) finally: root_logger.setLevel(previous_level) self.sample_rate = int(self.hps.data.sampling_rate) def _tokens(self, text: str) -> tuple[torch.Tensor, torch.Tensor]: phonemes = run_vits_frontend(text).phoneme_text sequence = cleaned_text_to_sequence(phonemes) if self.hps.data.add_blank: sequence = commons.intersperse(sequence, 0) if not sequence: raise ValueError("The text frontend produced no speakable tokens.") tokens = torch.LongTensor(sequence).to(self.device).unsqueeze(0) lengths = torch.LongTensor([tokens.size(1)]).to(self.device) return tokens, lengths @torch.inference_mode() def synthesize( self, text: str, *, speed: float = 1.0, variation: float = 0.667, seed: int = 0, ) -> np.ndarray: normalized = " ".join(text.split()) if not normalized: raise ValueError("Text must not be empty.") if not 0.5 <= speed <= 2.0: raise ValueError("speed must be between 0.5 and 2.0") if not 0.0 <= variation <= 1.0: raise ValueError("variation must be between 0.0 and 1.0") chunks = split_text(normalized) pieces: list[np.ndarray] = [] for index, chunk in enumerate(chunks): if index: pieces.append( np.zeros( round(self.sample_rate * boundary_pause_seconds(chunks[index - 1])), dtype=np.float32, ) ) tokens, lengths = self._tokens(chunk) torch.manual_seed(seed + index) if self.device.type == "cuda": torch.cuda.manual_seed_all(seed + index) waveform = self.model.infer( tokens, lengths, noise_scale=variation, noise_scale_w=0.8, length_scale=1.0 / speed, max_len=4000, )[0][0, 0].float().cpu().numpy() pieces.append(edge_fade(waveform, self.sample_rate)) return np.clip(np.concatenate(pieces), -1.0, 1.0) def save(self, text: str, output: str | Path, **kwargs: object) -> Path: destination = Path(output) destination.parent.mkdir(parents=True, exist_ok=True) sf.write(destination, self.synthesize(text, **kwargs), self.sample_rate) return destination def main() -> None: parser = argparse.ArgumentParser(description="Run standalone Inflect v2 synthesis.") parser.add_argument("--model-dir", type=Path, default=PACKAGE_ROOT) parser.add_argument("--text", required=True) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--device", default="cpu") parser.add_argument("--speed", type=float, default=1.0) parser.add_argument("--variation", type=float, default=0.667) parser.add_argument("--seed", type=int, default=0) args = parser.parse_args() engine = InflectTTS(args.model_dir, args.device) engine.save( args.text, args.output, speed=args.speed, variation=args.variation, seed=args.seed, ) print(f"wrote {args.output} at {engine.sample_rate} Hz") if __name__ == "__main__": main()