#!/usr/bin/env python3
"""Standalone ONNX Runtime command-line inference for the encoder-free release."""
from __future__ import annotations
import json
import math
import os
import re
import unicodedata
import warnings
import wave
from collections.abc import Iterator
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
import numpy as np
import onnxruntime as ort
try: # Local CLI import and Hugging Face remote-code package import.
from .teratts_ruaccent import RUAccent
except ImportError: # pragma: no cover - exercised by ``python teratts.py``.
from teratts_ruaccent import RUAccent
SAMPLE_RATE = 44_100
SAMPLES_PER_COMPRESSED_FRAME = 3_072
VOCODER_CONTEXT_FRAMES = 20
DEFAULT_STREAM_CHUNK_FRAMES = 16
SPEED = 1.05
SEED = 1234
RUSSIAN_TAG = re.compile(r"(.*?)", flags=re.DOTALL)
LANGUAGE_TAG = re.compile(r"<(ru|en)>(.*?)\1>", flags=re.DOTALL)
LANGUAGE_TAG_TOKEN = re.compile(r"<(/?)([a-z]{2})>")
TAGGED_NUMBER = re.compile(r"(? tuple[str, str]:
model_text = unicodedata.normalize("NFKD", raw_text)
return model_text, model_text.replace("+", "")
def _add_punctuation_spaces(text: str) -> str:
"""Separate punctuation without splitting decimal literals or closing tags."""
def space_after(match: re.Match[str]) -> str:
punctuation = match.group(0)
index = match.start()
previous = text[index - 1] if index else ""
following = text[index + 1] if index + 1 < len(text) else ""
if punctuation in ".," and previous.isdigit() and following.isdigit():
return punctuation
return punctuation + " "
return PUNCTUATION_NEEDS_SPACE.sub(space_after, text)
def validate_language_tags(text: str) -> None:
"""Require balanced ```` / ```` spans for all public synthesis."""
tokens = list(LANGUAGE_TAG_TOKEN.finditer(text))
if not tokens or not LANGUAGE_TAG.search(text):
raise ValueError(
"text must contain a language tag: wrap text in ... or ..."
)
stack: list[str] = []
for token in tokens:
closing, language = token.groups()
if language not in {"ru", "en"}:
raise ValueError(f"unsupported language tag <{language}>; use or ")
if not closing:
stack.append(language)
elif not stack or stack.pop() != language:
raise ValueError("language tags must be balanced: use ... or ...")
if stack:
raise ValueError("language tags must be balanced: use ... or ...")
# Angle brackets that did not form a valid tag would be accepted by the
# character vocabulary but are not meaningful model input.
if "<" in LANGUAGE_TAG_TOKEN.sub("", text) or ">" in LANGUAGE_TAG_TOKEN.sub("", text):
raise ValueError("invalid language tags; use only ... or ...")
def _skip_unsupported_characters(
text: str,
indexer: "UnicodeIndexer",
*,
preserve_digits: bool = False,
) -> str:
"""Return supported text and issue one clear warning for skipped characters."""
kept: list[str] = []
skipped: list[str] = []
for character in text:
# The released table was trained on NFKD text. Keep the human-readable
# NFC spelling here (especially ``й`` and ``ё``) as long as all of its
# decomposed codepoints exist in the table. RUAccent must receive this
# spelling: passing ``и`` + COMBINING BREVE makes its text cleaner drop
# the breve and turn ``й`` into ``и``.
encoded = unicodedata.normalize("NFKD", character)
supported = bool(encoded) and all(
(indexer.table[ord(item)] if ord(item) < 65_536 else -1) >= 0
for item in encoded
)
if not supported and not (preserve_digits and character.isdigit()):
skipped.append(character)
else:
kept.append(character)
if skipped:
labels = ", ".join(
f"{character!r} (U+{ord(character):04X})" for character in sorted(set(skipped))
)
warnings.warn(
f"skipped unsupported characters not present in the TeraTTS vocabulary: {labels}",
RuntimeWarning,
stacklevel=2,
)
return "".join(kept)
def normalize_input_text(raw_text: str, indexer: "UnicodeIndexer") -> str:
"""Normalize spacing and skip unsupported vocabulary characters with a warning."""
if not isinstance(raw_text, str) or not raw_text.strip():
raise ValueError("text must not be empty; use ... or ...")
# Retain composed characters through RUAccent. ``prepare_raw_text``
# performs the required NFKD conversion immediately before ONNX encoding.
text = unicodedata.normalize("NFC", raw_text)
text = _add_punctuation_spaces(text)
text = NUMBER_NEEDS_SPACE.sub(" ", text)
# Digits are retained only long enough for tagged ``num2words`` expansion;
# any remaining unsupported digits are skipped after that expansion.
text = _skip_unsupported_characters(text, indexer, preserve_digits=True)
validate_language_tags(text)
return text
def load_ruaccent(
*,
model_size: str = "turbo3.1",
device: str = "CPU",
workdir: Path | None = None,
mode: str = "full",
) -> object:
"""Load the bundled RUAccent-derived ONNX models without downloading."""
if workdir is None:
raise ValueError("load_ruaccent requires the release's ruaccent asset directory")
return RUAccent(workdir, model_size=model_size, device=device, mode=mode)
def add_russian_stress(text: str, accentizer: object | None) -> str:
"""Fill stress marks in ```` spans while preserving manual markers."""
if accentizer is None:
return text
def accent(match: re.Match[str]) -> str:
content = match.group(1)
# Explicit stress from the caller is authoritative. RUAccent is only
# used for spans that have not already been annotated.
if "+" in content:
return match.group(0)
process_all = getattr(accentizer, "process_all")
return f"{process_all(content)}"
return RUSSIAN_TAG.sub(accent, text)
def expand_tagged_numbers(text: str) -> str:
"""Spell out numeric literals inside ```` and ```` text spans.
Language tags are intentionally required: this avoids guessing a language
for bare text or for identifiers such as versions and file names.
"""
spans = list(LANGUAGE_TAG.finditer(text))
if not any(TAGGED_NUMBER.search(match.group(2)) for match in spans):
return text
try:
from num2words import num2words
except ImportError as error:
raise RuntimeError(
"number expansion requires num2words; install the model requirements"
) from error
def expand_span(match: re.Match[str]) -> str:
language, content = match.groups()
def expand_number(number: re.Match[str]) -> str:
literal = number.group(0).replace("−", "-")
value: int | float
if "." in literal or "," in literal:
value = float(literal.replace(",", "."))
else:
value = int(literal)
return str(num2words(value, lang=language))
return f"<{language}>{TAGGED_NUMBER.sub(expand_number, content)}{language}>"
return LANGUAGE_TAG.sub(expand_span, text)
def normalize_text(loaded: "LoadedTTS", text: str) -> str:
"""Return the exact text tensorized by the text encoder for an utterance."""
normalized_input = normalize_input_text(text, loaded.indexer)
expanded_text = _skip_unsupported_characters(
expand_tagged_numbers(normalized_input), loaded.indexer
)
model_text, _ = prepare_raw_text(add_russian_stress(expanded_text, loaded.accentizer))
return model_text
class UnicodeIndexer:
def __init__(self, indexer_path: Path):
self.table = json.loads(indexer_path.read_text())
if len(self.table) != 65_536:
raise ValueError("unicode_indexer.json must have 65,536 entries")
def batch(self, text: str) -> tuple[np.ndarray, np.ndarray]:
ids = []
for character in text:
token = self.table[ord(character)] if ord(character) < 65_536 else -1
if token < 0:
raise ValueError(
f"unsupported character {character!r} (U+{ord(character):04X})"
)
ids.append(token)
if not ids:
raise ValueError("text produced no tokens")
values = np.asarray(ids, dtype=np.int64)[None, :]
return values, np.ones((1, 1, values.shape[1]), dtype=np.float32)
def write_wav(path: Path, samples: np.ndarray) -> None:
pcm16 = np.clip(samples, -1.0, 1.0)
pcm16 = np.rint(pcm16 * 32767.0).astype(" None:
"""Play streamed mono float32 chunks through a Windows/Linux/macOS device.
This requires the optional ``sounddevice`` dependency. ``device`` accepts
a PortAudio device ID or name; ``None`` uses the operating-system default.
The supplied iterator is consumed exactly once.
"""
try:
import sounddevice as sd
except ImportError as error:
raise RuntimeError(
"stream playback requires sounddevice; install the project's audio extra"
) from error
# RawOutputStream accepts buffer objects, avoiding any extra float32 copy
# after the unavoidable PCM conversion for the audio device.
with sd.RawOutputStream(
samplerate=SAMPLE_RATE,
channels=1,
dtype="int16",
device=device,
) as output:
for chunk in chunks:
pcm16 = np.rint(np.clip(chunk, -1.0, 1.0) * 32767.0).astype(" int | None:
"""Choose CPU inference parallelism, defaulting to physical-core scale."""
if execution_providers != ("CPUExecutionProvider",):
return None
if threads is not None:
if threads < 1:
raise ValueError("threads must be positive")
return threads
available = os.cpu_count() or 1
# Most desktop CPUs expose two logical threads per physical core. Limiting
# one inference to that physical-core count avoids the oversubscription
# measured on the target Ryzen 5 5600X; pass ``threads`` when another CPU
# topology needs a different choice.
return max(1, available // 2)
@lru_cache(maxsize=12)
def _cached_session(
model_path: str,
execution_providers: tuple[str, ...],
cpu_threads: int | None,
revision: tuple[int, int],
) -> ort.InferenceSession:
del revision # It is part of the cache key so replaced model files reload.
options = ort.SessionOptions()
if cpu_threads is not None:
options.intra_op_num_threads = cpu_threads
options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
return ort.InferenceSession(model_path, sess_options=options, providers=list(execution_providers))
def session(
models: Path,
name: str,
execution_providers: list[str],
*,
threads: int | None = None,
) -> ort.InferenceSession:
"""Load a reusable session, reloading automatically after a file swap."""
model_path = (models / name).resolve()
stamp = model_path.stat()
providers = tuple(execution_providers)
return _cached_session(
str(model_path),
providers,
_cpu_thread_count(providers, threads),
(stamp.st_mtime_ns, stamp.st_size),
)
def clear_session_cache() -> None:
"""Release cached ONNX sessions, useful before an in-place model swap."""
_cached_session.cache_clear()
@dataclass(frozen=True)
class LoadedTTS:
"""Reusable encoder-free TTS runtime loaded from one release directory."""
release: Path
model: str
text_encoder: ort.InferenceSession
duration_predictor: ort.InferenceSession
sampler: ort.InferenceSession
vocoder: ort.InferenceSession
indexer: UnicodeIndexer
accentizer: object | None
def load_model(
release: Path,
*,
model: str = "distilled",
provider: str = "CPUExecutionProvider",
threads: int | None = None,
russian_stress: bool = True,
ruaccent_model_size: str = "turbo3.1",
ruaccent_device: str = "CPU",
ruaccent_workdir: Path | None = None,
ruaccent_mode: str = "full",
) -> LoadedTTS:
"""Load reusable ONNX sessions; call once before generating many utterances.
``threads`` controls CPU intra-op parallelism. ``None`` chooses a
physical-core-scale default; CUDA ignores this value. Russian ````
spans receive automatic ``+`` stress markers when ``russian_stress`` is
enabled; manually supplied markers are preserved. ``ruaccent_mode`` is
``"full"`` (neural ONNX models plus dictionaries) or ``"dictionary"``
(dictionaries only, with no accentuation-model ONNX sessions).
"""
if model not in {"teacher", "distilled"}:
raise ValueError("model must be 'teacher' or 'distilled'")
release = release.resolve()
models = release / "models"
providers = [provider]
sampler_name = (
"sampler_teacher_8step.onnx"
if model == "teacher"
else "sampler_distilled_cfg3_8step.onnx"
)
return LoadedTTS(
release=release,
model=model,
text_encoder=session(models, "text_encoder.onnx", providers, threads=threads),
duration_predictor=session(
models, "duration_predictor.onnx", providers, threads=threads
),
sampler=session(models, sampler_name, providers, threads=threads),
vocoder=session(models, "vocoder.onnx", providers, threads=threads),
indexer=UnicodeIndexer(release / "unicode_indexer.json"),
accentizer=(
load_ruaccent(
model_size=ruaccent_model_size,
device=ruaccent_device,
workdir=ruaccent_workdir or release / "ruaccent",
mode=ruaccent_mode,
)
if russian_stress
else None
),
)
def iter_vocoder_audio(
vocoder: ort.InferenceSession,
latent: np.ndarray,
*,
chunk_frames: int = DEFAULT_STREAM_CHUNK_FRAMES,
maximum_samples: int | None = None,
) -> Iterator[np.ndarray]:
"""Decode a latent in causal overlap-save chunks.
Each yielded array is mono float32 audio at 44,100 Hz. A consumer can send
it directly to a playback or network sink. Concatenating the chunks
matches a full vocoder decode up to normal floating-point kernel variation.
"""
if latent.ndim != 3 or latent.shape[:2] != (1, 144):
raise ValueError("streaming expects latent shape [1, 144, frames]")
if chunk_frames < 1:
raise ValueError("chunk_frames must be positive")
total_frames = latent.shape[-1]
full_samples = total_frames * SAMPLES_PER_COMPRESSED_FRAME
if maximum_samples is None:
maximum_samples = full_samples
maximum_samples = max(0, min(int(maximum_samples), full_samples))
emitted = 0
for start in range(0, total_frames, chunk_frames):
end = min(start + chunk_frames, total_frames)
input_start = max(0, start - VOCODER_CONTEXT_FRAMES)
decoded = vocoder.run(None, {"latent": latent[..., input_start:end]})[0]
if decoded.ndim != 2 or decoded.shape[0] != 1:
raise ValueError("vocoder returned an unexpected waveform shape")
decoded = decoded[0]
discard = (start - input_start) * SAMPLES_PER_COMPRESSED_FRAME
new_samples = (end - start) * SAMPLES_PER_COMPRESSED_FRAME
chunk = decoded[discard : discard + new_samples]
if chunk.shape[0] != new_samples:
raise ValueError("vocoder returned fewer samples than its latent input requires")
remaining = maximum_samples - emitted
if remaining <= 0:
break
chunk = chunk[:remaining]
if chunk.size:
emitted += chunk.size
yield chunk
def _generate_latent(
loaded: LoadedTTS,
text: str,
voice: str,
duration_scale: float,
*,
guidance: float,
seed: int,
) -> tuple[ort.InferenceSession, np.ndarray, int]:
if not math.isfinite(guidance) or guidance < 0:
raise ValueError("guidance must be finite and non-negative")
if not math.isfinite(duration_scale) or duration_scale <= 0:
raise ValueError("duration_scale must be finite and positive")
voice_dir = loaded.release / "styles" / voice
if not voice_dir.is_dir():
choices = ", ".join(
path.name
for path in sorted((loaded.release / "styles").glob("*"))
if path.is_dir()
)
raise ValueError(f"unknown voice {voice!r}; choices: {choices or '(none)'}")
style_ttl = np.load(voice_dir / "style_ttl.npy").astype(np.float32, copy=False)
style_dp = np.load(voice_dir / "style_dp.npy").astype(np.float32, copy=False)
if style_ttl.shape != (1, 50, 256) or style_dp.shape != (1, 8, 16):
raise ValueError("style assets have unexpected shapes")
model_text = normalize_text(loaded, text)
duration_text = model_text.replace("+", "")
text_ids, text_mask = loaded.indexer.batch(model_text)
duration_ids, duration_mask = loaded.indexer.batch(duration_text)
text_emb = loaded.text_encoder.run(
None,
{
"text_ids": text_ids,
"style_ttl": style_ttl,
"text_mask": text_mask,
},
)[0]
raw_duration = loaded.duration_predictor.run(
None,
{
"text_ids": duration_ids,
"style_dp": style_dp,
"text_mask": duration_mask,
},
)[0]
duration_seconds = float(raw_duration[0]) * duration_scale / SPEED
if not math.isfinite(duration_seconds) or duration_seconds <= 0:
raise ValueError("duration predictor returned a non-positive duration")
latent_length = max(
1, math.ceil(duration_seconds * SAMPLE_RATE / SAMPLES_PER_COMPRESSED_FRAME)
)
latent_mask = np.ones((1, 1, latent_length), dtype=np.float32)
latent = (
np.random.default_rng(seed)
.standard_normal((1, 144, latent_length))
.astype(np.float32)
)
# The selected sampler graph owns its diffusion architecture and complete
# Euler schedule. Replacing it with another graph that keeps this input
# contract changes the diffusion model without changing host code.
latent = loaded.sampler.run(
None,
{
"initial_latent": latent,
"text_emb": text_emb,
"style_ttl": style_ttl,
"latent_mask": latent_mask,
"text_mask": text_mask,
"guidance": np.asarray([guidance], dtype=np.float32),
},
)[0]
maximum_samples = round(duration_seconds * SAMPLE_RATE)
return loaded.vocoder, latent, maximum_samples
def generate_speech_stream(
loaded: LoadedTTS,
text: str,
voice: str,
*,
duration_scale: float = 1.0,
guidance: float = 3.0,
seed: int = SEED,
chunk_frames: int = DEFAULT_STREAM_CHUNK_FRAMES,
) -> Iterator[np.ndarray]:
"""Generate the latent, then yield vocoder audio as it becomes available.
The flow-model sampling phase necessarily completes before this iterator
emits its first audio chunk. The default chunk has 16 compressed frames
(49,152 samples); use a smaller positive value to reduce playback latency.
``guidance`` is used only by the teacher model and is ignored by distilled.
"""
vocoder, latent, maximum_samples = _generate_latent(
loaded,
text,
voice,
duration_scale,
guidance=guidance,
seed=seed,
)
yield from iter_vocoder_audio(
vocoder,
latent,
chunk_frames=chunk_frames,
maximum_samples=maximum_samples,
)
def generate_speech(
loaded: LoadedTTS,
text: str,
voice: str,
*,
duration_scale: float = 1.0,
guidance: float = 3.0,
seed: int = SEED,
) -> np.ndarray:
"""Generate and fully decode one utterance, trimmed to audible duration."""
vocoder, latent, maximum_samples = _generate_latent(
loaded,
text,
voice,
duration_scale,
guidance=guidance,
seed=seed,
)
waveform = vocoder.run(None, {"latent": latent})[0]
if waveform.ndim != 2 or waveform.shape[0] != 1:
raise ValueError("vocoder returned an unexpected waveform shape")
return waveform[0, :maximum_samples]
def synthesize_stream(
release: Path,
text: str,
voice: str,
model: str,
duration_scale: float,
provider: str,
*,
guidance: float = 3.0,
seed: int = SEED,
chunk_frames: int = DEFAULT_STREAM_CHUNK_FRAMES,
threads: int | None = None,
) -> Iterator[np.ndarray]:
"""Compatibility wrapper around :func:`load_model` and streaming generation."""
loaded = load_model(release, model=model, provider=provider, threads=threads)
yield from generate_speech_stream(
loaded,
text,
voice,
duration_scale=duration_scale,
guidance=guidance,
seed=seed,
chunk_frames=chunk_frames,
)
def synthesize(
release: Path,
text: str,
voice: str,
model: str,
duration_scale: float,
provider: str,
*,
guidance: float = 3.0,
seed: int = SEED,
threads: int | None = None,
) -> np.ndarray:
"""Compatibility wrapper around :func:`load_model` and full generation."""
loaded = load_model(release, model=model, provider=provider, threads=threads)
return generate_speech(
loaded,
text,
voice,
duration_scale=duration_scale,
guidance=guidance,
seed=seed,
)