notmax123 commited on
Commit
4156701
·
verified ·
1 Parent(s): 049f9fe

Add Russian G2P front end (RUAccent + RUPhon -> vocab IPA)

Browse files
README.md CHANGED
@@ -94,6 +94,45 @@ latents needs the PyTorch AE encoder: `export_onnx.py` exports the decoder only.
94
  Verified against the PyTorch path from identical initial noise: waveform cos-similarity
95
  ≥ 0.9995 in all 7 languages, duration agreeing to ~1e-6 s.
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  ## Known issues
98
 
99
  * **Output can exceed ±1.0** (up to 1.5 measured on loud references) — anything writing
 
94
  Verified against the PyTorch path from identical initial noise: waveform cos-similarity
95
  ≥ 0.9995 in all 7 languages, duration agreeing to ~1e-6 s.
96
 
97
+ ## Russian front end (`g2p/russian_g2p.py`)
98
+
99
+ Bundled here because it is the front end this checkpoint was trained with — feed it
100
+ anything else and the stress/reduction pattern will not match what the model saw.
101
+
102
+ ```
103
+ Cyrillic --RUAccent--> '+'-accented --RUPhon--> IPA --remap--> vocab.json symbols
104
+ ```
105
+
106
+ 1. **RUAccent** resolves lexical stress from sentence context and restores omitted ё
107
+ (~34% of `russian_librispeech` rows need it; ё is always stressed).
108
+ 2. **RUPhon** applies stress-conditioned vowel reduction — the thing that makes Russian
109
+ sound Russian: `зам+ок → zɐmˈok` vs `з+амок → zˈamək`.
110
+ 3. **`remap_ruphon_ipa`** folds RUPhon's tilde tie-bars (`t~s`, `t~ɕ`, …) onto the single
111
+ ligatures in `vocab.json` (`ʦ`, `ʧ`, `ʣ`, `ʤ`) and converts the ASCII stress mark `'`
112
+ to IPA `ˈ` (U+02C8). Without this last step stress silently trains into the
113
+ *apostrophe* embedding — in-vocab, so it never raises an OOV.
114
+
115
+ ```python
116
+ from g2p.russian_g2p import phonemize_russian, remap_ruphon_ipa
117
+
118
+ phonemize_russian("на горе стоит замок") # raw Cyrillic -> vocab-ready IPA
119
+ remap_ruphon_ipa("zɐm'ok t~sar") # -> "zɐmˈok ʦar" (stage 3 alone)
120
+ ```
121
+
122
+ `phonemize_russian` / `accent_russian` need `pip install ruaccent ruphon 'transformers<5'`.
123
+ That pin is why the two stages are kept out of the training env — phonemize offline into
124
+ an `ipa` column. `remap_ruphon_ipa`, `mark_yo_stress` and `apply_word_overrides` are pure
125
+ string work and safe to import anywhere.
126
+
127
+ Two deliberate quirks: `ч /tɕ/` and `тш /tʂ/` both map to `ʧ`, sharing an embedding with
128
+ the English/Yiddish affricate rather than getting a symbol of their own; and `всё` carries
129
+ a hard IPA override, because RUPhon reads it as `fsʲe` — which is the *correct* reading of
130
+ `все` ("all"), so no respelling can fix it and the substitution has to know the source word.
131
+
132
+ **Not espeak-ng:** its `ru` voice is context-invariant (`висит замок` and `стоит замок`
133
+ phonemize identically), cannot restore written-out ё, and emits `ы` as `/y/`, colliding
134
+ with the German ü already in this vocab.
135
+
136
  ## Known issues
137
 
138
  * **Output can exceed ±1.0** (up to 1.5 measured on loud references) — anything writing
g2p/__pycache__/russian_g2p.cpython-314.pyc ADDED
Binary file (8.85 kB). View file
 
g2p/russian_g2p.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Russian G2P: Cyrillic -> narrow IPA for TTS, in the 256-token Piper vocab.
3
+
4
+ ARCHITECTURE: Two-Stage (accentuation -> phonemization -> vocab remap)
5
+ 1. RUAccent resolves lexical stress AND restores omitted ё, from sentence
6
+ context. Emits the '+'-before-stressed-vowel convention.
7
+ 2. RUPhon turns '+'-accented Cyrillic into narrow IPA, applying the
8
+ stress-conditioned vowel reduction that makes Russian sound Russian
9
+ (зам+ок -> zɐm'ok vs з+амок -> z'amək).
10
+ 3. remap folds RUPhon's tie-bar affricates and ASCII stress mark onto the
11
+ symbols this project's vocab already uses.
12
+
13
+ Stage 1+2 need the ruaccent/ruphon packages, which pin transformers<5 and are
14
+ therefore kept OUT of the training environment: ``scripts/phonemize_russian.py``
15
+ runs them once offline and writes an ``ipa`` column, exactly as the
16
+ yiddish24-wav corpus ships a precomputed one. Only ``remap_ruphon_ipa`` -- which
17
+ is pure string work -- is imported by the training/inference path.
18
+
19
+ WHY NOT ESPEAK: espeak-ng's ru voice cannot disambiguate homographs at all (it
20
+ is context-invariant, so на двери висит замок and на горе стоит замок phonemize
21
+ identically), cannot restore ё that the orthography omits (~34% of rows in
22
+ russian_librispeech need it, and ё is always stressed), and writes ы as /y/ --
23
+ which collides with the German ü already in this vocab.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+
30
+ # =====================================================================
31
+ # STAGE 3: VOCAB REMAP
32
+ #
33
+ # RUPhon marks affricates with a tilde tie-bar (t~s) and stress with an ASCII
34
+ # apostrophe. Both are technically in the 256-token table already, but as the
35
+ # WRONG tokens: '~' is this vocab's tilde and "'" is its apostrophe, so stress
36
+ # would silently train into a punctuation embedding without ever raising an OOV.
37
+ # =====================================================================
38
+
39
+ # Tie-bar affricates -> the single ligatures this vocab uses. ч is /tɕ/ and ʧ is
40
+ # /tʃ/, so the mapping is lossy by design: it shares an embedding with the
41
+ # Yiddish and English affricate rather than sitting alone.
42
+ _AFFRICATES: dict[str, str] = {
43
+ "t~s": "ʦ", # ʦ ц -- matches yiddish_g2p, which emits ʦ directly
44
+ "t~ɕ": "ʧ", # ʧ ч
45
+ "t~ʂ": "ʧ", # ʧ тш across a morpheme boundary (rare)
46
+ "d~z": "ʣ", # ʣ дз (loanwords)
47
+ "d~ʑ": "ʤ", # ʤ дж
48
+ "d~ʐ": "ʤ", # ʤ дж (retroflex realisation)
49
+ }
50
+
51
+ # RUPhon's stress mark (U+0027) -> the IPA primary stress every other language
52
+ # in this vocab uses (U+02C8).
53
+ _RUPHON_STRESS = "'"
54
+ _IPA_STRESS = "ˈ"
55
+
56
+ # Any tie-bar this table does not name: keep both segments, drop the bar.
57
+ _RESIDUAL_TIE = re.compile(r"(\S)~(\S)")
58
+
59
+
60
+ def remap_ruphon_ipa(text: str) -> str:
61
+ """Fold RUPhon output onto this project's phoneme inventory.
62
+
63
+ Pure string transformation with no model dependencies, so it is safe to
64
+ import from the training and inference paths. Must run BEFORE
65
+ ``text_vocab.normalize_text``, whose affricate pass does not recognise the
66
+ tilde tie-bar form.
67
+ """
68
+ for src, dst in _AFFRICATES.items():
69
+ text = text.replace(src, dst)
70
+ text = _RESIDUAL_TIE.sub(r"\1\2", text)
71
+ return text.replace(_RUPHON_STRESS, _IPA_STRESS)
72
+
73
+
74
+ def mark_yo_stress(accented: str) -> str:
75
+ """Ensure every ё-bearing word carries an explicit stress mark.
76
+
77
+ RUAccent only inserts '+' where stress is ambiguous, so it leaves ё alone when
78
+ the orthography already writes it -- but RUPhon needs the mark to realise ё as
79
+ /ɵ/ and renders the bare letter as /e/. ё is *always* stressed in Russian, so
80
+ marking it is unconditionally correct.
81
+
82
+ Without this, 445 of the 738 rows in russian_librispeech that spell ё out
83
+ (60.3%) produced no /ɵ/ at all. ё-restoration was unaffected -- RUAccent marks
84
+ the stress on ё it inserts itself -- so this only touches already-written ё,
85
+ which includes very common words (всё, ещё, её).
86
+ """
87
+ out = []
88
+ for word in accented.split(" "):
89
+ if "ё" in word and "+" not in word:
90
+ word = word.replace("ё", "+ё", 1)
91
+ out.append(word)
92
+ return " ".join(out)
93
+
94
+
95
+ # Words RUPhon mispronounces no matter how they are marked. всё renders /fsʲe/
96
+ # whether written всё, вс+ё or Вс+ё, and no respelling helps (фсё -> fsʲe,
97
+ # всьо -> fsʲjɵ). It cannot be patched at the IPA level either: /fsʲe/ is the
98
+ # CORRECT reading of все ("all"), and все/всё is precisely the ё distinction --
99
+ # so the substitution has to know which source word it came from.
100
+ #
101
+ # Scoped deliberately tight. In russian_librispeech, всё* is 453 of the 472
102
+ # ё-tokens that phonemize wrong (96%); the rest occur 1-3 times each.
103
+ _WORD_IPA_OVERRIDES: dict[str, str] = {
104
+ "всё": "fsʲˈɵ",
105
+ }
106
+
107
+
108
+ def apply_word_overrides(source: str, ipa: str) -> str:
109
+ """Replace IPA tokens for source words in ``_WORD_IPA_OVERRIDES``.
110
+
111
+ Token-aligned and fail-safe: if the word counts disagree, RUPhon did not emit
112
+ one token per input word and the alignment cannot be trusted, so the IPA is
113
+ returned untouched rather than corrupted.
114
+ """
115
+ src_tokens = source.split()
116
+ ipa_tokens = ipa.split()
117
+ if len(src_tokens) != len(ipa_tokens):
118
+ return ipa
119
+ changed = False
120
+ for i, raw in enumerate(src_tokens):
121
+ key = raw.strip(".,!?;:\"'()«»—").lower()
122
+ replacement = _WORD_IPA_OVERRIDES.get(key)
123
+ if replacement is None:
124
+ continue
125
+ # Carry over any trailing punctuation the phonemizer kept.
126
+ tail = ""
127
+ while ipa_tokens[i] and ipa_tokens[i][-1] in ".,!?;:":
128
+ tail = ipa_tokens[i][-1] + tail
129
+ ipa_tokens[i] = ipa_tokens[i][:-1]
130
+ ipa_tokens[i] = replacement + tail
131
+ changed = True
132
+ return " ".join(ipa_tokens) if changed else ipa
133
+
134
+
135
+ def to_vocab_ipa(text: str) -> str:
136
+ """RUPhon output -> normalized IPA ready for ``text_to_indices``."""
137
+ from data.text_vocab import normalize_text
138
+
139
+ return normalize_text(remap_ruphon_ipa(text), apply_hebrew_fixes=False)
140
+
141
+
142
+ # =====================================================================
143
+ # STAGES 1-2: the model-backed front end (offline / inference only)
144
+ # =====================================================================
145
+
146
+ _ACCENTOR = None
147
+ _PHONEMIZER = None
148
+
149
+
150
+ def _load(device: str = "CPU", workdir: str | None = None):
151
+ """Lazily build the RUAccent + RUPhon pair. Requires ruaccent and ruphon."""
152
+ global _ACCENTOR, _PHONEMIZER
153
+ if _ACCENTOR is None or _PHONEMIZER is None:
154
+ try:
155
+ from ruaccent import RUAccent
156
+ from ruphon import RUPhon
157
+ except ImportError as exc: # pragma: no cover
158
+ raise ImportError(
159
+ "Russian raw-text G2P needs ruaccent and ruphon:\n"
160
+ " pip install ruaccent ruphon 'transformers<5'\n"
161
+ "These pin transformers<5, so prefer running "
162
+ "scripts/phonemize_russian.py in a separate venv and consuming "
163
+ "its precomputed 'ipa' column."
164
+ ) from exc
165
+
166
+ accentor = RUAccent()
167
+ accentor.load(
168
+ omograph_model_size="turbo3.1",
169
+ use_dictionary=True,
170
+ tiny_mode=False,
171
+ )
172
+ phonemizer = RUPhon()
173
+ kwargs = {"device": device}
174
+ if workdir:
175
+ kwargs["workdir"] = workdir
176
+ _ACCENTOR, _PHONEMIZER = accentor, phonemizer.load("big", **kwargs)
177
+ return _ACCENTOR, _PHONEMIZER
178
+
179
+
180
+ def accent_russian(text: str, device: str = "CPU", workdir: str | None = None) -> str:
181
+ """Cyrillic -> '+'-accented Cyrillic with ё restored."""
182
+ accentor, _ = _load(device, workdir)
183
+ return mark_yo_stress(accentor.process_all(str(text).strip()))
184
+
185
+
186
+ def phonemize_russian(
187
+ text: str, device: str = "CPU", workdir: str | None = None
188
+ ) -> str:
189
+ """Raw Cyrillic -> IPA on this project's phoneme inventory (unnormalized)."""
190
+ accentor, phonemizer = _load(device, workdir)
191
+ source = str(text).strip()
192
+ accented = mark_yo_stress(accentor.process_all(source))
193
+ ipa = remap_ruphon_ipa(phonemizer.phonemize(accented)).strip()
194
+ return apply_word_overrides(source, ipa)