TeraSpace commited on
Commit
01cab82
·
verified ·
1 Parent(s): d2cb1db

Add dictionary stress mode and tagged number expansion

Browse files
Files changed (5) hide show
  1. README.md +34 -11
  2. modeling_teratts.py +2 -0
  3. requirements.txt +1 -0
  4. teratts.py +46 -3
  5. teratts_ruaccent.py +38 -5
README.md CHANGED
@@ -11,9 +11,9 @@ tags:
11
 
12
  # TeraTTSv2 ONNX
13
 
14
- TeraTTSv2 text-to-speech with ONNX Runtime, selectable teacher/distilled
15
- diffusion samplers, precomputed voice styles, CPU/CUDA execution, and streamed
16
- audio chunks.
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
- ## Voices and samplers
51
 
52
- Available `voice` values are `en_female`, `en_male`, `ru_female`, `ru_male`,
53
- `mita`, and `masha_ls`.
 
 
 
 
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
- Russian `<ru>…</ru>` text is automatically stress-marked by the bundled
72
- RUAccent-derived ONNX runtime before inference. It makes no secondary model
73
- download and does not require the `ruaccent` package. Manual `+` markers
74
- remain unchanged:
75
 
76
  ```python
77
  waveform = tts.generate_speech(
78
- "<ru>На двери висит замок.</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
- model_text, duration_text = prepare_raw_text(add_russian_stress(text, loaded.accentizer))
 
 
 
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__(self, root: Path, *, model_size: str = "turbo3.1", device: str = "CPU") -> None:
 
 
 
 
 
 
 
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
- self.accent_model = _CharAccentModel(root / "nn" / "nn_accent", device)
183
- self.omograph_model = _OmographModel(root / "nn" / "nn_omograph" / model_size, device)
184
- self.stress_usage = _TokenClassifier(root / "nn" / "nn_stress_usage_predictor", device)
185
- self.yo_classifier = _TokenClassifier(root / "nn" / "nn_yo_homograph_resolver", device)
 
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.