Text-to-Speech
Transformers
ONNX
teratts_onnx
feature-extraction
onnxruntime
russian
english
custom-code
custom_code
Instructions to use TeraSpace/TeraTTSv2 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use TeraSpace/TeraTTSv2 with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-to-speech", model="TeraSpace/TeraTTSv2", trust_remote_code=True)# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("TeraSpace/TeraTTSv2", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Add dictionary stress mode and tagged number expansion
Browse files- README.md +34 -11
- modeling_teratts.py +2 -0
- requirements.txt +1 -0
- teratts.py +46 -3
- teratts_ruaccent.py +38 -5
README.md
CHANGED
|
@@ -11,9 +11,9 @@ tags:
|
|
| 11 |
|
| 12 |
# TeraTTSv2 ONNX
|
| 13 |
|
| 14 |
-
TeraTTSv2
|
| 15 |
-
diffusion samplers,
|
| 16 |
-
audio
|
| 17 |
|
| 18 |
## Installation
|
| 19 |
|
|
@@ -47,10 +47,14 @@ tts.save_wav("teratts.wav", waveform)
|
|
| 47 |
`waveform` is a mono `float32` NumPy array at 44,100 Hz. `save_wav` writes
|
| 48 |
standard signed-16-bit PCM WAV without an extra audio package.
|
| 49 |
|
| 50 |
-
##
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
The default `diffusion_model="distilled"` is the fast eight-step sampler. To
|
| 56 |
use the teacher sampler, choose it while loading:
|
|
@@ -68,19 +72,38 @@ teacher_tts = AutoModel.from_pretrained(
|
|
| 68 |
`guidance` can be adjusted when generating with the teacher sampler. The
|
| 69 |
distilled sampler has CFG 3 baked into its graph.
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
|
| 76 |
```python
|
| 77 |
waveform = tts.generate_speech(
|
| 78 |
-
"<ru>
|
| 79 |
voice="ru_female",
|
| 80 |
duration_scale=1,
|
| 81 |
)
|
| 82 |
```
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
## Stream audio
|
| 85 |
|
| 86 |
```python
|
|
|
|
| 11 |
|
| 12 |
# TeraTTSv2 ONNX
|
| 13 |
|
| 14 |
+
TeraTTSv2 is a self-contained ONNX Runtime text-to-speech release with
|
| 15 |
+
selectable diffusion samplers, six voice styles, Russian stress marking, and
|
| 16 |
+
streamed audio output.
|
| 17 |
|
| 18 |
## Installation
|
| 19 |
|
|
|
|
| 47 |
`waveform` is a mono `float32` NumPy array at 44,100 Hz. `save_wav` writes
|
| 48 |
standard signed-16-bit PCM WAV without an extra audio package.
|
| 49 |
|
| 50 |
+
## Controls
|
| 51 |
|
| 52 |
+
| Control | Values | Effect |
|
| 53 |
+
| --- | --- | --- |
|
| 54 |
+
| `voice` | `en_female`, `en_male`, `ru_female`, `ru_male`, `mita`, `masha_ls` | Selects a bundled precomputed voice style. |
|
| 55 |
+
| `duration_scale` | Positive float, default `1` | Higher values produce slower, longer speech. |
|
| 56 |
+
| `diffusion_model` | `distilled` (default), `teacher` | Distilled is faster; teacher supports adjustable CFG. |
|
| 57 |
+
| `ruaccent_mode` | `full` (default), `dictionary` | Full uses RUAccent neural ONNX graphs plus dictionaries; dictionary mode loads dictionaries only. |
|
| 58 |
|
| 59 |
The default `diffusion_model="distilled"` is the fast eight-step sampler. To
|
| 60 |
use the teacher sampler, choose it while loading:
|
|
|
|
| 72 |
`guidance` can be adjusted when generating with the teacher sampler. The
|
| 73 |
distilled sampler has CFG 3 baked into its graph.
|
| 74 |
|
| 75 |
+
## Language tags, numbers, and Russian stress
|
| 76 |
+
|
| 77 |
+
Wrap text in `<en>…</en>` or `<ru>…</ru>`. Numbers inside those tags are
|
| 78 |
+
expanded to words in the matching language before synthesis:
|
| 79 |
|
| 80 |
```python
|
| 81 |
waveform = tts.generate_speech(
|
| 82 |
+
"<ru>У меня 21 яблоко.</ru> <en>I have 42 apples.</en>",
|
| 83 |
voice="ru_female",
|
| 84 |
duration_scale=1,
|
| 85 |
)
|
| 86 |
```
|
| 87 |
|
| 88 |
+
Russian text is automatically stress-marked by the bundled RUAccent-derived
|
| 89 |
+
runtime. Manual `+` markers remain authoritative. For a lower-memory,
|
| 90 |
+
deterministic dictionary-only path, choose the mode while loading:
|
| 91 |
+
|
| 92 |
+
```python
|
| 93 |
+
dictionary_tts = AutoModel.from_pretrained(
|
| 94 |
+
"TeraSpace/TeraTTSv2",
|
| 95 |
+
trust_remote_code=True,
|
| 96 |
+
provider="CPUExecutionProvider",
|
| 97 |
+
threads=6,
|
| 98 |
+
ruaccent_mode="dictionary",
|
| 99 |
+
)
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
Dictionary mode does not load RUAccent neural ONNX graphs. It marks known
|
| 103 |
+
words and applies deterministic `ё` replacements, while unknown words and
|
| 104 |
+
ambiguous homographs are left unchanged. Set `russian_stress=False` to disable
|
| 105 |
+
automatic Russian stress processing entirely.
|
| 106 |
+
|
| 107 |
## Stream audio
|
| 108 |
|
| 109 |
```python
|
modeling_teratts.py
CHANGED
|
@@ -43,6 +43,7 @@ class TeraTTSModel(PreTrainedModel):
|
|
| 43 |
russian_stress: bool = True,
|
| 44 |
ruaccent_model_size: str = "turbo3.1",
|
| 45 |
ruaccent_device: str = "CPU",
|
|
|
|
| 46 |
**kwargs: object,
|
| 47 |
) -> "TeraTTSModel":
|
| 48 |
"""Download/load the release and initialize reusable ONNX sessions.
|
|
@@ -94,6 +95,7 @@ class TeraTTSModel(PreTrainedModel):
|
|
| 94 |
russian_stress=russian_stress,
|
| 95 |
ruaccent_model_size=ruaccent_model_size,
|
| 96 |
ruaccent_device=ruaccent_device,
|
|
|
|
| 97 |
)
|
| 98 |
return instance
|
| 99 |
|
|
|
|
| 43 |
russian_stress: bool = True,
|
| 44 |
ruaccent_model_size: str = "turbo3.1",
|
| 45 |
ruaccent_device: str = "CPU",
|
| 46 |
+
ruaccent_mode: str = "full",
|
| 47 |
**kwargs: object,
|
| 48 |
) -> "TeraTTSModel":
|
| 49 |
"""Download/load the release and initialize reusable ONNX sessions.
|
|
|
|
| 95 |
russian_stress=russian_stress,
|
| 96 |
ruaccent_model_size=ruaccent_model_size,
|
| 97 |
ruaccent_device=ruaccent_device,
|
| 98 |
+
ruaccent_mode=ruaccent_mode,
|
| 99 |
)
|
| 100 |
return instance
|
| 101 |
|
requirements.txt
CHANGED
|
@@ -3,4 +3,5 @@ onnxruntime>=1.16
|
|
| 3 |
torch>=2.1
|
| 4 |
transformers>=4.40
|
| 5 |
huggingface_hub>=0.23
|
|
|
|
| 6 |
sounddevice>=0.5
|
|
|
|
| 3 |
torch>=2.1
|
| 4 |
transformers>=4.40
|
| 5 |
huggingface_hub>=0.23
|
| 6 |
+
num2words>=0.5.13
|
| 7 |
sounddevice>=0.5
|
teratts.py
CHANGED
|
@@ -29,6 +29,8 @@ DEFAULT_STREAM_CHUNK_FRAMES = 16
|
|
| 29 |
SPEED = 1.05
|
| 30 |
SEED = 1234
|
| 31 |
RUSSIAN_TAG = re.compile(r"<ru>(.*?)</ru>", flags=re.DOTALL)
|
|
|
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
def prepare_raw_text(raw_text: str) -> tuple[str, str]:
|
|
@@ -41,11 +43,12 @@ def load_ruaccent(
|
|
| 41 |
model_size: str = "turbo3.1",
|
| 42 |
device: str = "CPU",
|
| 43 |
workdir: Path | None = None,
|
|
|
|
| 44 |
) -> object:
|
| 45 |
"""Load the bundled RUAccent-derived ONNX models without downloading."""
|
| 46 |
if workdir is None:
|
| 47 |
raise ValueError("load_ruaccent requires the release's ruaccent asset directory")
|
| 48 |
-
return RUAccent(workdir, model_size=model_size, device=device)
|
| 49 |
|
| 50 |
|
| 51 |
def add_russian_stress(text: str, accentizer: object | None) -> str:
|
|
@@ -65,6 +68,39 @@ def add_russian_stress(text: str, accentizer: object | None) -> str:
|
|
| 65 |
return RUSSIAN_TAG.sub(accent, text)
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
class UnicodeIndexer:
|
| 69 |
def __init__(self, indexer_path: Path):
|
| 70 |
self.table = json.loads(indexer_path.read_text())
|
|
@@ -207,13 +243,16 @@ def load_model(
|
|
| 207 |
ruaccent_model_size: str = "turbo3.1",
|
| 208 |
ruaccent_device: str = "CPU",
|
| 209 |
ruaccent_workdir: Path | None = None,
|
|
|
|
| 210 |
) -> LoadedTTS:
|
| 211 |
"""Load reusable ONNX sessions; call once before generating many utterances.
|
| 212 |
|
| 213 |
``threads`` controls CPU intra-op parallelism. ``None`` chooses a
|
| 214 |
physical-core-scale default; CUDA ignores this value. Russian ``<ru>``
|
| 215 |
spans receive automatic ``+`` stress markers when ``russian_stress`` is
|
| 216 |
-
enabled; manually supplied markers are preserved.
|
|
|
|
|
|
|
| 217 |
"""
|
| 218 |
if model not in {"teacher", "distilled"}:
|
| 219 |
raise ValueError("model must be 'teacher' or 'distilled'")
|
|
@@ -240,6 +279,7 @@ def load_model(
|
|
| 240 |
model_size=ruaccent_model_size,
|
| 241 |
device=ruaccent_device,
|
| 242 |
workdir=ruaccent_workdir or release / "ruaccent",
|
|
|
|
| 243 |
)
|
| 244 |
if russian_stress
|
| 245 |
else None
|
|
@@ -320,7 +360,10 @@ def _generate_latent(
|
|
| 320 |
if style_ttl.shape != (1, 50, 256) or style_dp.shape != (1, 8, 16):
|
| 321 |
raise ValueError("style assets have unexpected shapes")
|
| 322 |
|
| 323 |
-
|
|
|
|
|
|
|
|
|
|
| 324 |
text_ids, text_mask = loaded.indexer.batch(model_text)
|
| 325 |
duration_ids, duration_mask = loaded.indexer.batch(duration_text)
|
| 326 |
text_emb = loaded.text_encoder.run(
|
|
|
|
| 29 |
SPEED = 1.05
|
| 30 |
SEED = 1234
|
| 31 |
RUSSIAN_TAG = re.compile(r"<ru>(.*?)</ru>", flags=re.DOTALL)
|
| 32 |
+
LANGUAGE_TAG = re.compile(r"<(ru|en)>(.*?)</\1>", flags=re.DOTALL)
|
| 33 |
+
TAGGED_NUMBER = re.compile(r"(?<![\w.])[-−]?\d+(?:[.,]\d+)?(?![\w.])")
|
| 34 |
|
| 35 |
|
| 36 |
def prepare_raw_text(raw_text: str) -> tuple[str, str]:
|
|
|
|
| 43 |
model_size: str = "turbo3.1",
|
| 44 |
device: str = "CPU",
|
| 45 |
workdir: Path | None = None,
|
| 46 |
+
mode: str = "full",
|
| 47 |
) -> object:
|
| 48 |
"""Load the bundled RUAccent-derived ONNX models without downloading."""
|
| 49 |
if workdir is None:
|
| 50 |
raise ValueError("load_ruaccent requires the release's ruaccent asset directory")
|
| 51 |
+
return RUAccent(workdir, model_size=model_size, device=device, mode=mode)
|
| 52 |
|
| 53 |
|
| 54 |
def add_russian_stress(text: str, accentizer: object | None) -> str:
|
|
|
|
| 68 |
return RUSSIAN_TAG.sub(accent, text)
|
| 69 |
|
| 70 |
|
| 71 |
+
def expand_tagged_numbers(text: str) -> str:
|
| 72 |
+
"""Spell out numeric literals inside ``<ru>`` and ``<en>`` text spans.
|
| 73 |
+
|
| 74 |
+
Language tags are intentionally required: this avoids guessing a language
|
| 75 |
+
for bare text or for identifiers such as versions and file names.
|
| 76 |
+
"""
|
| 77 |
+
spans = list(LANGUAGE_TAG.finditer(text))
|
| 78 |
+
if not any(TAGGED_NUMBER.search(match.group(2)) for match in spans):
|
| 79 |
+
return text
|
| 80 |
+
try:
|
| 81 |
+
from num2words import num2words
|
| 82 |
+
except ImportError as error:
|
| 83 |
+
raise RuntimeError(
|
| 84 |
+
"number expansion requires num2words; install the model requirements"
|
| 85 |
+
) from error
|
| 86 |
+
|
| 87 |
+
def expand_span(match: re.Match[str]) -> str:
|
| 88 |
+
language, content = match.groups()
|
| 89 |
+
|
| 90 |
+
def expand_number(number: re.Match[str]) -> str:
|
| 91 |
+
literal = number.group(0).replace("−", "-")
|
| 92 |
+
value: int | float
|
| 93 |
+
if "." in literal or "," in literal:
|
| 94 |
+
value = float(literal.replace(",", "."))
|
| 95 |
+
else:
|
| 96 |
+
value = int(literal)
|
| 97 |
+
return str(num2words(value, lang=language))
|
| 98 |
+
|
| 99 |
+
return f"<{language}>{TAGGED_NUMBER.sub(expand_number, content)}</{language}>"
|
| 100 |
+
|
| 101 |
+
return LANGUAGE_TAG.sub(expand_span, text)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
class UnicodeIndexer:
|
| 105 |
def __init__(self, indexer_path: Path):
|
| 106 |
self.table = json.loads(indexer_path.read_text())
|
|
|
|
| 243 |
ruaccent_model_size: str = "turbo3.1",
|
| 244 |
ruaccent_device: str = "CPU",
|
| 245 |
ruaccent_workdir: Path | None = None,
|
| 246 |
+
ruaccent_mode: str = "full",
|
| 247 |
) -> LoadedTTS:
|
| 248 |
"""Load reusable ONNX sessions; call once before generating many utterances.
|
| 249 |
|
| 250 |
``threads`` controls CPU intra-op parallelism. ``None`` chooses a
|
| 251 |
physical-core-scale default; CUDA ignores this value. Russian ``<ru>``
|
| 252 |
spans receive automatic ``+`` stress markers when ``russian_stress`` is
|
| 253 |
+
enabled; manually supplied markers are preserved. ``ruaccent_mode`` is
|
| 254 |
+
``"full"`` (neural ONNX models plus dictionaries) or ``"dictionary"``
|
| 255 |
+
(dictionaries only, with no accentuation-model ONNX sessions).
|
| 256 |
"""
|
| 257 |
if model not in {"teacher", "distilled"}:
|
| 258 |
raise ValueError("model must be 'teacher' or 'distilled'")
|
|
|
|
| 279 |
model_size=ruaccent_model_size,
|
| 280 |
device=ruaccent_device,
|
| 281 |
workdir=ruaccent_workdir or release / "ruaccent",
|
| 282 |
+
mode=ruaccent_mode,
|
| 283 |
)
|
| 284 |
if russian_stress
|
| 285 |
else None
|
|
|
|
| 360 |
if style_ttl.shape != (1, 50, 256) or style_dp.shape != (1, 8, 16):
|
| 361 |
raise ValueError("style assets have unexpected shapes")
|
| 362 |
|
| 363 |
+
expanded_text = expand_tagged_numbers(text)
|
| 364 |
+
model_text, duration_text = prepare_raw_text(
|
| 365 |
+
add_russian_stress(expanded_text, loaded.accentizer)
|
| 366 |
+
)
|
| 367 |
text_ids, text_mask = loaded.indexer.batch(model_text)
|
| 368 |
duration_ids, duration_mask = loaded.indexer.batch(duration_text)
|
| 369 |
text_emb = loaded.text_encoder.run(
|
teratts_ruaccent.py
CHANGED
|
@@ -168,10 +168,20 @@ class RUAccent:
|
|
| 168 |
_tokens = re.compile(r"\w*(?:\+\w+)*|[^\w\s]+")
|
| 169 |
_sentence = re.compile(r"[^.!?…]+[.!?…]*[\"»“]*")
|
| 170 |
|
| 171 |
-
def __init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
root = root.resolve()
|
| 173 |
if model_size != "turbo3.1":
|
| 174 |
raise ValueError("the bundled RUAccent model is turbo3.1")
|
|
|
|
|
|
|
|
|
|
| 175 |
dictionary = root / "dictionary"
|
| 176 |
self.accents = json.load(gzip.open(dictionary / "accents.json.gz"))
|
| 177 |
self.omographs = json.load(gzip.open(dictionary / "omographs.json.gz"))
|
|
@@ -179,10 +189,11 @@ class RUAccent:
|
|
| 179 |
self.yo_words = json.load(gzip.open(dictionary / "yo_words.json.gz"))
|
| 180 |
self.yo_homographs = json.load(gzip.open(dictionary / "yo_homographs.json.gz"))
|
| 181 |
self.accents.update({"о": "+о", "О": "+О"})
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
|
|
|
| 186 |
|
| 187 |
@staticmethod
|
| 188 |
def _remaining(sentence: str, matches: list[re.Match[str]]) -> tuple[list[str], list[str]]:
|
|
@@ -253,8 +264,30 @@ class RUAccent:
|
|
| 253 |
words[index] = "".join(target)
|
| 254 |
return words
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
def process_all(self, text: str) -> str:
|
| 257 |
text = self._normalize.sub("", text)
|
|
|
|
|
|
|
| 258 |
output: list[str] = []
|
| 259 |
# Keep sentence delimiters attached to the sentence, matching the
|
| 260 |
# original RUAccent intent without its optional razdel dependency.
|
|
|
|
| 168 |
_tokens = re.compile(r"\w*(?:\+\w+)*|[^\w\s]+")
|
| 169 |
_sentence = re.compile(r"[^.!?…]+[.!?…]*[\"»“]*")
|
| 170 |
|
| 171 |
+
def __init__(
|
| 172 |
+
self,
|
| 173 |
+
root: Path,
|
| 174 |
+
*,
|
| 175 |
+
model_size: str = "turbo3.1",
|
| 176 |
+
device: str = "CPU",
|
| 177 |
+
mode: str = "full",
|
| 178 |
+
) -> None:
|
| 179 |
root = root.resolve()
|
| 180 |
if model_size != "turbo3.1":
|
| 181 |
raise ValueError("the bundled RUAccent model is turbo3.1")
|
| 182 |
+
if mode not in {"full", "dictionary"}:
|
| 183 |
+
raise ValueError("RUAccent mode must be 'full' or 'dictionary'")
|
| 184 |
+
self.mode = mode
|
| 185 |
dictionary = root / "dictionary"
|
| 186 |
self.accents = json.load(gzip.open(dictionary / "accents.json.gz"))
|
| 187 |
self.omographs = json.load(gzip.open(dictionary / "omographs.json.gz"))
|
|
|
|
| 189 |
self.yo_words = json.load(gzip.open(dictionary / "yo_words.json.gz"))
|
| 190 |
self.yo_homographs = json.load(gzip.open(dictionary / "yo_homographs.json.gz"))
|
| 191 |
self.accents.update({"о": "+о", "О": "+О"})
|
| 192 |
+
if mode == "full":
|
| 193 |
+
self.accent_model = _CharAccentModel(root / "nn" / "nn_accent", device)
|
| 194 |
+
self.omograph_model = _OmographModel(root / "nn" / "nn_omograph" / model_size, device)
|
| 195 |
+
self.stress_usage = _TokenClassifier(root / "nn" / "nn_stress_usage_predictor", device)
|
| 196 |
+
self.yo_classifier = _TokenClassifier(root / "nn" / "nn_yo_homograph_resolver", device)
|
| 197 |
|
| 198 |
@staticmethod
|
| 199 |
def _remaining(sentence: str, matches: list[re.Match[str]]) -> tuple[list[str], list[str]]:
|
|
|
|
| 264 |
words[index] = "".join(target)
|
| 265 |
return words
|
| 266 |
|
| 267 |
+
def _dictionary_word(self, match: re.Match[str]) -> str:
|
| 268 |
+
"""Apply deterministic ``ё`` and accent dictionary entries only."""
|
| 269 |
+
word = match.group(0)
|
| 270 |
+
normalized = _fix_capital(word, self.yo_words.get(word.lower(), word))
|
| 271 |
+
accented = self.accents.get(normalized.lower(), normalized.lower())
|
| 272 |
+
if accented == normalized.lower():
|
| 273 |
+
return normalized
|
| 274 |
+
target = list(normalized)
|
| 275 |
+
inserted = 0
|
| 276 |
+
for marker in re.finditer(r"\+", accented):
|
| 277 |
+
target.insert(marker.start() + inserted, "+")
|
| 278 |
+
inserted += 1
|
| 279 |
+
return "".join(target)
|
| 280 |
+
|
| 281 |
+
def _process_dictionary(self, text: str) -> str:
|
| 282 |
+
# Dictionary mode deliberately avoids loading or calling every neural
|
| 283 |
+
# RUAccent ONNX graph. Unknown words and unresolved homographs remain
|
| 284 |
+
# untouched rather than receiving a neural prediction.
|
| 285 |
+
return re.sub(r"[A-Za-zА-Яа-яЁё]+", self._dictionary_word, text)
|
| 286 |
+
|
| 287 |
def process_all(self, text: str) -> str:
|
| 288 |
text = self._normalize.sub("", text)
|
| 289 |
+
if self.mode == "dictionary":
|
| 290 |
+
return self._process_dictionary(text)
|
| 291 |
output: list[str] = []
|
| 292 |
# Keep sentence delimiters attached to the sentence, matching the
|
| 293 |
# original RUAccent intent without its optional razdel dependency.
|