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
|
+

|
|
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
|
+

|
|
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)
|