renikud-plus 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: renikud-plus
3
+ Version: 0.2.0
4
+ Summary: ONNX Runtime inference for Hebrew grapheme-to-phoneme conversion (ReNikud Plus)
5
+ Keywords: hebrew,g2p,phonemizer,onnx,tts,niqqud
6
+ Author: Maxim Melichov, Yakov Kolani, Morris Alper
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Classifier: Topic :: Text Processing :: Linguistic
16
+ Requires-Dist: numpy
17
+ Requires-Dist: onnxruntime>=1.24.2
18
+ Requires-Python: >=3.12
19
+ Project-URL: Homepage, https://github.com/maxmelichov/RenikudPlus
20
+ Project-URL: Repository, https://github.com/maxmelichov/RenikudPlus
21
+ Project-URL: Issues, https://github.com/maxmelichov/RenikudPlus/issues
22
+ Project-URL: Model, https://huggingface.co/notmax123/RenikudPlus
23
+ Description-Content-Type: text/markdown
24
+
25
+ # ReNikud Plus — Hebrew Grapheme-to-Phoneme Inference
26
+
27
+ Convert unvocalized Hebrew text into IPA for TTS, speech technology, and
28
+ spoken-language research.
29
+
30
+ ## Benchmark
31
+
32
+ ![G2P benchmark comparison](assets/bar_plot_comparison.jpeg)
33
+
34
+ ## Install
35
+
36
+ This repository contains only the code required for ONNX inference. It has no
37
+ training pipeline, checkpoint files, or PyTorch dependency.
38
+
39
+ ```console
40
+ pip install renikud-plus
41
+ hf download notmax123/RenikudPlus model.onnx --local-dir .
42
+ ```
43
+
44
+ Or from a clone of this repo:
45
+
46
+ ```console
47
+ uv sync
48
+ hf download notmax123/RenikudPlus model.onnx --local-dir .
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ```python
54
+ from renikud_onnx import G2P
55
+
56
+ g2p = G2P("model.onnx")
57
+ print(g2p.phonemize("שלום עולם"))
58
+ # → ʃlˈom ʔolˈam
59
+ ```
60
+
61
+ For a gender-conditioned ONNX model, pass `speaker` and `target_speaker` as
62
+ `0` (unknown), `1` (male), or `2` (female):
63
+
64
+ ```python
65
+ g2p.phonemize("היא רצה", speaker=2, target_speaker=2)
66
+ ```
67
+
68
+ ### Niqqud output
69
+
70
+ `vocalize` renders the same predictions as pointed Hebrew (niqqud) instead of
71
+ IPA — for TTS engines that read niqqud natively but ignore phoneme markup. It
72
+ accepts the same `speaker` / `target_speaker` arguments.
73
+
74
+ ```python
75
+ print(g2p.vocalize("שלום עולם"))
76
+ # → שׁלוֹם עוֹלַם
77
+ ```
78
+
79
+ Niqqud has no stress mark, so predicted stress is not represented in this output
80
+ (it is in `phonemize`). Diacritization is phonetically faithful but not
81
+ publication-grade — e.g. shva in clusters is omitted.
82
+
83
+
84
+ ## Citation
85
+
86
+ ```bibtex
87
+ @misc{melichov2026renikud,
88
+ title={ReNikud: Audio-Supervised Hebrew Grapheme-to-Phoneme Conversion},
89
+ author={Maxim Melichov and Yakov Kolani and Morris Alper},
90
+ year={2026},
91
+ url={https://arxiv.org/pdf/2606.20179},
92
+ }
93
+ ```
@@ -0,0 +1,69 @@
1
+ # ReNikud Plus — Hebrew Grapheme-to-Phoneme Inference
2
+
3
+ Convert unvocalized Hebrew text into IPA for TTS, speech technology, and
4
+ spoken-language research.
5
+
6
+ ## Benchmark
7
+
8
+ ![G2P benchmark comparison](assets/bar_plot_comparison.jpeg)
9
+
10
+ ## Install
11
+
12
+ This repository contains only the code required for ONNX inference. It has no
13
+ training pipeline, checkpoint files, or PyTorch dependency.
14
+
15
+ ```console
16
+ pip install renikud-plus
17
+ hf download notmax123/RenikudPlus model.onnx --local-dir .
18
+ ```
19
+
20
+ Or from a clone of this repo:
21
+
22
+ ```console
23
+ uv sync
24
+ hf download notmax123/RenikudPlus model.onnx --local-dir .
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```python
30
+ from renikud_onnx import G2P
31
+
32
+ g2p = G2P("model.onnx")
33
+ print(g2p.phonemize("שלום עולם"))
34
+ # → ʃlˈom ʔolˈam
35
+ ```
36
+
37
+ For a gender-conditioned ONNX model, pass `speaker` and `target_speaker` as
38
+ `0` (unknown), `1` (male), or `2` (female):
39
+
40
+ ```python
41
+ g2p.phonemize("היא רצה", speaker=2, target_speaker=2)
42
+ ```
43
+
44
+ ### Niqqud output
45
+
46
+ `vocalize` renders the same predictions as pointed Hebrew (niqqud) instead of
47
+ IPA — for TTS engines that read niqqud natively but ignore phoneme markup. It
48
+ accepts the same `speaker` / `target_speaker` arguments.
49
+
50
+ ```python
51
+ print(g2p.vocalize("שלום עולם"))
52
+ # → שׁלוֹם עוֹלַם
53
+ ```
54
+
55
+ Niqqud has no stress mark, so predicted stress is not represented in this output
56
+ (it is in `phonemize`). Diacritization is phonetically faithful but not
57
+ publication-grade — e.g. shva in clusters is omitted.
58
+
59
+
60
+ ## Citation
61
+
62
+ ```bibtex
63
+ @misc{melichov2026renikud,
64
+ title={ReNikud: Audio-Supervised Hebrew Grapheme-to-Phoneme Conversion},
65
+ author={Maxim Melichov and Yakov Kolani and Morris Alper},
66
+ year={2026},
67
+ url={https://arxiv.org/pdf/2606.20179},
68
+ }
69
+ ```
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "renikud-plus"
3
+ version = "0.2.0"
4
+ description = "ONNX Runtime inference for Hebrew grapheme-to-phoneme conversion (ReNikud Plus)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ authors = [
9
+ { name = "Maxim Melichov" },
10
+ { name = "Yakov Kolani" },
11
+ { name = "Morris Alper" },
12
+ ]
13
+ keywords = ["hebrew", "g2p", "phonemizer", "onnx", "tts", "niqqud"]
14
+ dependencies = [
15
+ "numpy",
16
+ "onnxruntime>=1.24.2",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Intended Audience :: Developers",
21
+ "Intended Audience :: Science/Research",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
26
+ "Topic :: Text Processing :: Linguistic",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/maxmelichov/RenikudPlus"
31
+ Repository = "https://github.com/maxmelichov/RenikudPlus"
32
+ Issues = "https://github.com/maxmelichov/RenikudPlus/issues"
33
+ Model = "https://huggingface.co/notmax123/RenikudPlus"
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.9.13,<0.10.0"]
37
+ build-backend = "uv_build"
38
+
39
+ [tool.uv.build-backend]
40
+ module-name = "renikud_onnx"
@@ -0,0 +1,293 @@
1
+ """ReNikud Plus: Hebrew grapheme-to-phoneme inference via ONNX."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import unicodedata
8
+
9
+ import numpy as np
10
+ import onnxruntime as ort
11
+
12
+ ALEF_ORD = ord("א")
13
+ TAF_ORD = ord("ת")
14
+ STRESS_MARK = "ˈ"
15
+ ORTHOGRAPHIC_MARKERS = ("'", '"')
16
+
17
+ # Niqqud points named by their Unicode names -- the bare combining glyphs are
18
+ # invisible in source. `vocalize` renders each of the model's five predicted vowel
19
+ # qualities (a/e/i/o/u) as one representative sign; signs that share a sound
20
+ # (patah/qamats, tsere/segol) collapse to one, which is lossless for pronunciation.
21
+ _NIQQUD_VOWEL = {
22
+ "a": "\N{HEBREW POINT PATAH}",
23
+ "e": "\N{HEBREW POINT SEGOL}",
24
+ "i": "\N{HEBREW POINT HIRIQ}",
25
+ "o": "\N{HEBREW POINT HOLAM}",
26
+ "u": "\N{HEBREW POINT QUBUTS}",
27
+ }
28
+ _DAGESH = "\N{HEBREW POINT DAGESH OR MAPIQ}" # hard b/k/p: בּ כּ פּ
29
+ _SHIN_DOT = "\N{HEBREW POINT SHIN DOT}" # שׁ
30
+ _SIN_DOT = "\N{HEBREW POINT SIN DOT}" # שׂ
31
+
32
+ # A letter takes a consonant-conditioned point when the model's predicted consonant
33
+ # selects one: בכפ take a dagesh in their hard (stop) realization, ש takes the
34
+ # shin- or sin-dot. One table instead of branches scattered through `vocalize`.
35
+ _DAGESH_PAIRS = frozenset({("ב", "b"), ("כ", "k"), ("ך", "k"), ("פ", "p"), ("ף", "p")})
36
+
37
+
38
+ def _is_hebrew(char: str) -> bool:
39
+ return ALEF_ORD <= ord(char) <= TAF_ORD
40
+
41
+
42
+ def _consonant_point(letter: str, consonant: str) -> str:
43
+ """Dagesh or shin/sin dot implied by the consonant the model chose for `letter`."""
44
+ if letter == "ש":
45
+ return _SHIN_DOT if consonant == "ʃ" else _SIN_DOT
46
+ if (letter, consonant) in _DAGESH_PAIRS:
47
+ return _DAGESH
48
+ return ""
49
+
50
+
51
+ def normalize_graphemes(text: str) -> str:
52
+ text = re.sub(r"[׳'`´]", "'", text)
53
+ text = re.sub(r'[״""]', '"', text)
54
+ return text
55
+
56
+
57
+ class G2P:
58
+ def __init__(self, model_path: str, session_options: ort.SessionOptions | None = None) -> None:
59
+ """Load the ONNX model.
60
+
61
+ `session_options` is passed straight to onnxruntime. In a CPU-limited
62
+ container, set `intra_op_num_threads` to the CPU quota -- onnxruntime
63
+ otherwise sizes its thread pool from the host core count and
64
+ oversubscribes, which measurably slows inference on a small pod.
65
+ """
66
+ self._session = ort.InferenceSession(model_path, session_options)
67
+ self._input_names = {input_.name for input_ in self._session.get_inputs()}
68
+ meta = self._session.get_modelmeta().custom_metadata_map
69
+ self._vocab: dict[str, int] = json.loads(meta["vocab"])
70
+ self._consonant_vocab: dict[int, str] = {int(k): v for k, v in json.loads(meta["consonant_vocab"]).items()}
71
+ self._vowel_vocab: dict[int, str] = {int(k): v for k, v in json.loads(meta["vowel_vocab"]).items()}
72
+ self._cls_id = int(meta["cls_token_id"])
73
+ self._sep_id = int(meta["sep_token_id"])
74
+ self._letter_constraints: dict[str, list[int]] = {
75
+ k: v for k, v in json.loads(meta["letter_consonant_constraints"]).items()
76
+ }
77
+ self._geresh_map: dict[str, str] = json.loads(meta.get("geresh_map", "{}"))
78
+
79
+ def _tokenize(self, text: str) -> tuple[list[int], list[int], list[tuple[int, int]]]:
80
+ """Tokenize character by character, return ids, mask, and offset mapping."""
81
+ normalized = unicodedata.normalize("NFD", text)
82
+ unk_id = self._vocab.get("[UNK]", 0)
83
+ ids = [self._cls_id]
84
+ offsets = [(0, 0)]
85
+ for i, char in enumerate(normalized):
86
+ ids.append(self._vocab.get(char, unk_id))
87
+ offsets.append((i, i + 1))
88
+ ids.append(self._sep_id)
89
+ offsets.append((0, 0))
90
+ return ids, [1] * len(ids), offsets
91
+
92
+ def _best_stress_per_word(
93
+ self,
94
+ offsets: list[tuple[int, int]],
95
+ text: str,
96
+ stress_logits: np.ndarray,
97
+ vowel_predictions: np.ndarray,
98
+ ) -> set[int]:
99
+ word_spans = [(match.start(), match.end()) for match in re.finditer(r"\S+", text)]
100
+ words: dict[int, list[int]] = {i: [] for i in range(len(word_spans))}
101
+ for token_index, (start, end) in enumerate(offsets):
102
+ if end - start != 1:
103
+ continue
104
+ for word_index, (word_start, word_end) in enumerate(word_spans):
105
+ if word_start <= start < word_end:
106
+ words[word_index].append(token_index)
107
+ break
108
+ stressed: set[int] = set()
109
+ for token_indexes in words.values():
110
+ vowel_token_indexes = [
111
+ token_index
112
+ for token_index in token_indexes
113
+ if self._vowel_vocab.get(int(vowel_predictions[token_index]), "∅") != "∅"
114
+ ]
115
+ if vowel_token_indexes:
116
+ stressed.add(
117
+ max(vowel_token_indexes, key=lambda token_index: stress_logits[token_index, 1])
118
+ )
119
+ return stressed
120
+
121
+ def _predict(
122
+ self, text: str, speaker: int = 0, target_speaker: int = 0
123
+ ) -> tuple[str, list[tuple[int, int]], np.ndarray, np.ndarray, set[int]]:
124
+ """Run the model once and return the per-character predictions shared by
125
+ `phonemize` (IPA) and `vocalize` (niqqud).
126
+
127
+ Gender-conditioned models accept speaker IDs: 0 for unknown, 1 for male,
128
+ and 2 for female. Legacy models only support the default unknown values.
129
+
130
+ Returns ``(normalized_text, offsets, consonant_logits[seq, C],
131
+ vowel_predictions[seq], stressed_token_positions)``.
132
+ """
133
+ if speaker not in (0, 1, 2) or target_speaker not in (0, 1, 2):
134
+ raise ValueError("speaker and target_speaker must be 0 (unknown), 1 (male), or 2 (female)")
135
+ supports_gender = {"speaker", "target_speaker"} <= self._input_names
136
+ if not supports_gender and (speaker or target_speaker):
137
+ raise ValueError(
138
+ "This ONNX model is not gender-conditioned; export a model with speaker and target_speaker inputs."
139
+ )
140
+
141
+ text = normalize_graphemes(text)
142
+ normalized = unicodedata.normalize("NFD", text)
143
+ ids, mask, offsets = self._tokenize(text)
144
+ inputs: dict[str, np.ndarray] = {
145
+ "input_ids": np.array([ids], dtype=np.int64),
146
+ "attention_mask": np.array([mask], dtype=np.int64),
147
+ }
148
+ if supports_gender:
149
+ inputs["speaker"] = np.array([speaker], dtype=np.int64)
150
+ inputs["target_speaker"] = np.array([target_speaker], dtype=np.int64)
151
+ consonant_logits, vowel_logits, stress_logits = self._session.run(
152
+ ["consonant_logits", "vowel_logits", "stress_logits"],
153
+ inputs,
154
+ )
155
+ vowel_predictions = vowel_logits[0].argmax(axis=-1)
156
+ stressed_positions = self._best_stress_per_word(
157
+ offsets, normalized, stress_logits[0], vowel_predictions
158
+ )
159
+ return normalized, offsets, consonant_logits[0], vowel_predictions, stressed_positions
160
+
161
+ def phonemize(self, text: str, speaker: int = 0, target_speaker: int = 0) -> str:
162
+ """Convert text to IPA.
163
+
164
+ Gender-conditioned models accept speaker IDs: 0 for unknown, 1 for male,
165
+ and 2 for female. Legacy models only support the default unknown values.
166
+ """
167
+ normalized, offsets, consonant_logits, vowel_predictions, stressed_positions = self._predict(
168
+ text, speaker, target_speaker
169
+ )
170
+ consonant_predictions = consonant_logits.argmax(axis=-1)
171
+
172
+ result: list[str] = []
173
+ previous_end = 0
174
+ for token_index, (start, end) in enumerate(offsets):
175
+ if end - start != 1:
176
+ continue
177
+ if start > previous_end:
178
+ result.append(normalized[previous_end:start])
179
+
180
+ char = normalized[start:end]
181
+ previous_end = end
182
+ if not _is_hebrew(char):
183
+ if char not in ORTHOGRAPHIC_MARKERS:
184
+ result.append(char)
185
+ continue
186
+
187
+ consonant_id = int(consonant_predictions[token_index])
188
+ allowed = self._letter_constraints.get(char)
189
+ if allowed is not None and consonant_id not in allowed:
190
+ consonant_id = max(allowed, key=lambda value: consonant_logits[token_index, value])
191
+ consonant = self._consonant_vocab.get(consonant_id, "∅")
192
+ if char in self._geresh_map and end < len(normalized) and normalized[end] == "'":
193
+ consonant = self._geresh_map[char]
194
+
195
+ vowel = self._vowel_vocab.get(int(vowel_predictions[token_index]), "∅")
196
+ stress = token_index in stressed_positions
197
+ word_final = end >= len(normalized) or not normalized[end].isalpha()
198
+ if char == "ח" and word_final and vowel == "a":
199
+ result.append(f"{STRESS_MARK if stress else ''}aχ")
200
+ continue
201
+
202
+ chunk = consonant if consonant != "∅" else ""
203
+ if stress and vowel != "∅":
204
+ chunk += STRESS_MARK
205
+ if vowel != "∅":
206
+ chunk += vowel
207
+ result.append(chunk)
208
+
209
+ if previous_end < len(normalized):
210
+ result.append(normalized[previous_end:])
211
+ return "".join(result)
212
+
213
+ def vocalize(self, text: str, speaker: int = 0, target_speaker: int = 0) -> str:
214
+ """Add niqqud (vowel diacritics) to Hebrew ``text``; non-Hebrew is unchanged.
215
+
216
+ Renders the same per-letter (consonant, vowel) predictions as `phonemize`,
217
+ but as niqqud -- vowel signs plus dagesh for hard b/k/p and the shin/sin
218
+ dot -- instead of IPA. Useful for TTS engines that read pointed Hebrew
219
+ natively but ignore phoneme markup. Diacritization is phonetically faithful
220
+ but not publication-grade (e.g. shva in clusters is omitted), and niqqud has
221
+ no stress mark, so the stress the model predicts is not represented here.
222
+
223
+ Gender-conditioned models accept the same speaker IDs as `phonemize`.
224
+ """
225
+ normalized, offsets, consonant_logits, vowel_predictions, _stressed = self._predict(
226
+ text, speaker, target_speaker
227
+ )
228
+ consonant_predictions = consonant_logits.argmax(axis=-1)
229
+
230
+ out: list[str] = []
231
+ # One record per Hebrew letter: [char, consonant, vowel, out_index, start].
232
+ records: list[list] = []
233
+ previous_end = 0
234
+ for token_index, (start, end) in enumerate(offsets):
235
+ if end - start != 1:
236
+ continue
237
+ if start > previous_end:
238
+ out.append(normalized[previous_end:start])
239
+ char = normalized[start:end]
240
+ previous_end = end
241
+ if not _is_hebrew(char):
242
+ # Keep everything non-Hebrew, including geresh markers (ג׳ -> ג').
243
+ out.append(char)
244
+ continue
245
+
246
+ consonant_id = int(consonant_predictions[token_index])
247
+ allowed = self._letter_constraints.get(char)
248
+ if allowed is not None and consonant_id not in allowed:
249
+ consonant_id = max(allowed, key=lambda value: consonant_logits[token_index, value])
250
+ consonant = self._consonant_vocab.get(consonant_id, "∅")
251
+ vowel = self._vowel_vocab.get(int(vowel_predictions[token_index]), "∅")
252
+
253
+ records.append([char, consonant, vowel, len(out), start])
254
+ out.append("") # placeholder, filled after the mater-lectionis fixup
255
+
256
+ if previous_end < len(normalized):
257
+ out.append(normalized[previous_end:])
258
+
259
+ # Mater lectionis fixup — a vowel-letter (ו/י/ה/א) is silent but the vowel
260
+ # it represents belongs on a neighbouring letter. Both cases are guarded to
261
+ # adjacent letters in the same word.
262
+ # - Silent final ה/א (consonant ∅) took the preceding consonant's vowel;
263
+ # shift it back so the mark never lands on a silent letter (TTS would
264
+ # voice it).
265
+ # - A silent ו should itself carry an adjacent /o/ or /u/, so it renders as
266
+ # holam male (וֹ) or shuruk (וּ). Otherwise the vav is left bare and TTS
267
+ # may voice it as /v/ (שֻׁולחַן read as "shuvlchan"). י as a mater already
268
+ # renders correctly (hiriq male, ִי), so it needs no fixup.
269
+ for i, record in enumerate(records):
270
+ char, consonant, vowel, _idx, start = record
271
+ if i == 0:
272
+ continue
273
+ previous = records[i - 1]
274
+ adjacent = previous[4] + 1 == start
275
+ if char in ("ה", "א") and consonant == "∅" and vowel != "∅":
276
+ if previous[2] == "∅" and adjacent:
277
+ previous[2] = vowel
278
+ record[2] = "∅"
279
+ elif char == "ו" and consonant == "∅" and vowel == "∅":
280
+ if adjacent and previous[2] in ("o", "u"):
281
+ record[2] = previous[2]
282
+ previous[2] = "∅"
283
+
284
+ for char, consonant, vowel, idx, _start in records:
285
+ if char == "ו" and consonant == "∅" and vowel in ("o", "u"):
286
+ # Vav as a vowel letter: shuruk (וּ) for /u/, holam male (וֹ) for /o/.
287
+ point = _DAGESH if vowel == "u" else _NIQQUD_VOWEL["o"]
288
+ out[idx] = unicodedata.normalize("NFC", char + point)
289
+ else:
290
+ point = _consonant_point(char, consonant)
291
+ out[idx] = unicodedata.normalize("NFC", char + point + _NIQQUD_VOWEL.get(vowel, ""))
292
+
293
+ return "".join(out)