File size: 8,086 Bytes
e044cab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
"""Kabyle text to the phoneme ids Matoub-82M was trained on.

The rules are a copy of `agbalu.tts.g2p` and `agbalu.tts.kokoro`, not an import: nothing
under `hub/` may import `agbalu`, because the published repository ships without it.
`tests/unit/test_hub_matoub.py` asserts the two agree over the corpus, and it is the only
thing keeping them from drifting.

A symbol with no rule raises. It is never dropped: silent deletion is the defect this
front end exists to remove, and it has already cost three Kabyle consonants once —
Kokoro's own G2P is built with `unk=''` and StyleTTS2's cleaner skips what it cannot find.
"""

from __future__ import annotations

import json
import re
from typing import TYPE_CHECKING, Any, Final

from transformers import PreTrainedTokenizer

if TYPE_CHECKING:
    from collections.abc import Mapping
    from pathlib import Path

VOCAB_FILE: Final = "vocab.json"

VOWELS: Final = frozenset("aeiou")

BACKING_TRIGGERS: Final = frozenset("ḍṣṭẓṛqɣx")

SPIRANTS: Final[dict[str, tuple[str, str]]] = {
    "b": ("β", "b"),
    "d": ("ð", "d"),
    "g": ("ʝ", "ɡ"),
    "k": ("ç", "k"),
    "t": ("θ", "t"),
    "ḍ": ("ðˤ", "dˤ"),
}

PLAIN: Final[dict[str, str]] = {
    "a": "æ",
    "c": "ʃ",
    "e": "ə",
    "f": "f",
    "h": "h",
    "i": "i",
    "j": "ʒ",
    "l": "l",
    "m": "m",
    "n": "n",
    "o": "o",
    "p": "p",
    "q": "q",
    "r": "r",
    "s": "s",
    "u": "u",
    "v": "v",
    "w": "w",
    "x": "χ",
    "y": "j",
    "z": "z",
    "č": "t͡ʃ",
    "ǧ": "d͡ʒ",
    "ɛ": "ʕ",
    "ɣ": "ʁ",
    "ḥ": "ħ",
    "ṛ": "rˤ",
    "ṣ": "sˤ",
    "ṭ": "tˤ",
    "ẓ": "zˤ",
}

NASAL_ASSIMILATION: Final[dict[str, str]] = {"f": "m", "m": "m", "y": "ɲ", "q": "ŋ", "x": "ŋ"}

FOLD: Final[dict[str, str]] = {"t͡ʃ": "ʧ", "d͡ʒ": "ʤ"}
"""Tie-bar sequences the base model already carries as single symbols."""

LEGACY_TENSE_T: Final = "ţ"
LENGTH: Final = "ː"
BOUNDARY: Final = " "

_SPLIT: Final = re.compile(r"[\s\-]+")
_STRIP: Final = "«»\"'“”‘’.,;:!?()[]{}…"


class PhonemeError(ValueError):
    """A character with no rule."""


def _segment(word: str) -> list[tuple[str, bool]]:
    segments: list[tuple[str, bool]] = []
    index = 0
    while index < len(word):
        char = word[index]
        paired = index + 1 < len(word) and word[index + 1] == char and char not in VOWELS
        segments.append((char, paired))
        index += 2 if paired else 1
    return segments


def _backed(chars: list[str], position: int) -> bool:
    before = chars[position - 1] if position > 0 else ""
    after = chars[position + 1] if position + 1 < len(chars) else ""
    return before in BACKING_TRIGGERS or after in BACKING_TRIGGERS


def phonemize_word(word: str) -> str:
    """One orthographic word to IPA. Raises on any character without a rule."""
    segments = _segment(word.casefold())
    chars = [char for char, _ in segments]
    out: list[str] = []
    for position, (char, geminate) in enumerate(segments):
        if char == LEGACY_TENSE_T:
            out.append(SPIRANTS["t"][1] + LENGTH)
            continue
        if char in SPIRANTS:
            short, stop = SPIRANTS[char]
            out.append(stop + LENGTH if geminate else short)
            continue
        if char not in PLAIN:
            message = f"no rule for {char!r} (U+{ord(char):04X}) in {word!r}"
            raise PhonemeError(message)
        if char == "a" and _backed(chars, position):
            symbol = "ɑ"
        elif char == "n" and not geminate and position + 1 < len(chars):
            symbol = NASAL_ASSIMILATION.get(chars[position + 1], PLAIN["n"])
        else:
            symbol = PLAIN[char]
        out.append(symbol + LENGTH if geminate else symbol)
    return "".join(out)


def fold(ipa: str) -> str:
    """Rewrite tie-bar affricates onto the base model's own single symbols."""
    for sequence, symbol in FOLD.items():
        ipa = ipa.replace(sequence, symbol)
    return ipa


def phonemize(text: str) -> str:
    """A Kabyle sentence to the phoneme string the model was fitted on."""
    words = [token.strip(_STRIP) for token in _SPLIT.split(text) if token.strip(_STRIP)]
    return fold(BOUNDARY.join(phonemize_word(word) for word in words))


class MatoubTokenizer(PreTrainedTokenizer):
    """Kabyle Latin orthography in, phoneme ids out.

    Punctuation is dropped and the clitic hyphen is a word boundary, because that is what
    the training transcripts carried: the model has never been supervised on a comma.
    """

    vocab_files_names: dict[str, str] = {"vocab_file": VOCAB_FILE}
    model_input_names: list[str] = ["input_ids", "attention_mask"]

    def __init__(
        self,
        vocab_file: str,
        pad_token: str = "$",
        bos_token: str = "$",
        eos_token: str = "$",
        **kwargs: Any,
    ) -> None:
        with open(vocab_file, encoding="utf-8") as handle:  # noqa: PTH123
            self._vocab: dict[str, int] = json.load(handle)
        self._ids_to_symbols = {index: symbol for symbol, index in self._vocab.items()}
        super().__init__(pad_token=pad_token, bos_token=bos_token, eos_token=eos_token, **kwargs)

    @property
    def vocab_size(self) -> int:
        return len(self._vocab)

    def get_vocab(self) -> dict[str, int]:
        return dict(self._vocab)

    def phonemize(self, text: str) -> str:
        """The IPA string this tokenizer will encode, for inspection."""
        return phonemize(text)

    def _tokenize(self, text: str, **kwargs: Any) -> list[str]:
        return list(phonemize(text))

    def _convert_token_to_id(self, token: str) -> int:
        index = self._vocab.get(token)
        if index is None:
            message = (
                f"no embedding row for {token!r} (U+{ord(token):04X}); the model would be "
                f"given a phoneme it was never trained on"
            )
            raise KeyError(message)
        return index

    def _convert_id_to_token(self, index: int) -> str:
        symbol: str = self._ids_to_symbols.get(index, "")
        return symbol

    def convert_tokens_to_string(self, tokens: list[str]) -> str:
        return "".join(tokens)

    def build_inputs_with_special_tokens(
        self, token_ids_0: list[int], token_ids_1: list[int] | None = None
    ) -> list[int]:
        # `meldataset` wraps every training target in the pad symbol on both sides, so a
        # sequence without them is off the distribution the durations were fitted on.
        boundary = [self._vocab["$"]]
        merged = boundary + token_ids_0 + boundary
        return merged if token_ids_1 is None else merged + token_ids_1 + boundary

    def get_special_tokens_mask(
        self,
        token_ids_0: list[int],
        token_ids_1: list[int] | None = None,
        already_has_special_tokens: bool = False,
    ) -> list[int]:
        if already_has_special_tokens:
            return super().get_special_tokens_mask(
                token_ids_0, token_ids_1, already_has_special_tokens=True
            )
        mask = [1, *([0] * len(token_ids_0)), 1]
        return mask if token_ids_1 is None else mask + [0] * len(token_ids_1) + [1]

    def save_vocabulary(
        self, save_directory: str, filename_prefix: str | None = None
    ) -> tuple[str]:
        from pathlib import Path

        name = f"{filename_prefix}-{VOCAB_FILE}" if filename_prefix else VOCAB_FILE
        path = Path(save_directory) / name
        path.write_text(
            json.dumps(self._vocab, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
        )
        return (str(path),)

    @staticmethod
    def write_vocabulary(symbols: Mapping[str, int], directory: Path) -> Path:
        """Write the `vocab.json` a staged release is constructed from."""
        path = directory / VOCAB_FILE
        path.write_text(
            json.dumps(dict(symbols), ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
        )
        return path


__all__ = ["MatoubTokenizer", "PhonemeError", "fold", "phonemize", "phonemize_word"]