rita-py 4.0.9__py3-none-any.whl
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.
- rita/__init__.py +36 -0
- rita/analyzer.py +344 -0
- rita/backoff.py +533 -0
- rita/concorder.py +116 -0
- rita/conjugator.py +1516 -0
- rita/data/rita_dict.json +1 -0
- rita/dictdata.py +43 -0
- rita/inflector.py +391 -0
- rita/lexicon.py +814 -0
- rita/markov.py +202 -0
- rita/model.py +203 -0
- rita/randgen.py +295 -0
- rita/riscript.py +1704 -0
- rita/rita.py +585 -0
- rita/rita_lts.py +3471 -0
- rita/stemmer.py +708 -0
- rita/suffixes.py +585 -0
- rita/tagger.py +619 -0
- rita/tokenizer.py +506 -0
- rita/util.py +256 -0
- rita_py-4.0.9.dist-info/METADATA +83 -0
- rita_py-4.0.9.dist-info/RECORD +24 -0
- rita_py-4.0.9.dist-info/WHEEL +4 -0
- rita_py-4.0.9.dist-info/licenses/LICENSE +674 -0
rita/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rita — Python port of RiTa (https://rednoise.org/rita)
|
|
3
|
+
|
|
4
|
+
Public API re-exports. Additional modules (Tagger, Lexicon, etc.) will be
|
|
5
|
+
added as each phase of the port is completed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from rita.util import Util, RE
|
|
9
|
+
from rita.randgen import RandGen
|
|
10
|
+
from rita.rita_lts import LetterToSound
|
|
11
|
+
from rita.stemmer import Stemmer
|
|
12
|
+
from rita.tagger import Tagger
|
|
13
|
+
from rita.analyzer import Analyzer
|
|
14
|
+
from rita.lexicon import Lexicon
|
|
15
|
+
from rita.markov import RiMarkov
|
|
16
|
+
from rita.concorder import Concorder
|
|
17
|
+
from rita.rita import RiTa
|
|
18
|
+
|
|
19
|
+
# RiScript / RiGrammar (already fully ported)
|
|
20
|
+
from .riscript import RiScript, RiGrammar
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"RiScript",
|
|
24
|
+
"RiGrammar",
|
|
25
|
+
"RandGen",
|
|
26
|
+
"Util",
|
|
27
|
+
"RE",
|
|
28
|
+
"LetterToSound",
|
|
29
|
+
"Stemmer",
|
|
30
|
+
"Tagger",
|
|
31
|
+
"Analyzer",
|
|
32
|
+
"Lexicon",
|
|
33
|
+
"RiMarkov",
|
|
34
|
+
"Concorder",
|
|
35
|
+
"RiTa",
|
|
36
|
+
]
|
rita/analyzer.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rita/analyzer.py — Phonetic analyzer
|
|
3
|
+
Python port of ritajs/src/analyzer.js
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from rita.util import Util
|
|
9
|
+
|
|
10
|
+
_SP = ' '
|
|
11
|
+
_E = ''
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Features(dict):
|
|
15
|
+
"""Feature map returned by analyze()/analyze_word().
|
|
16
|
+
|
|
17
|
+
A dict subclass so it compares equal to a plain dict (eql) while also
|
|
18
|
+
supporting JS-style attribute access (data.phones).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __getattr__(self, name):
|
|
22
|
+
try:
|
|
23
|
+
return self[name]
|
|
24
|
+
except KeyError:
|
|
25
|
+
raise AttributeError(name)
|
|
26
|
+
|
|
27
|
+
def __setattr__(self, name, value):
|
|
28
|
+
self[name] = value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Analyzer:
|
|
32
|
+
|
|
33
|
+
def __init__(self):
|
|
34
|
+
self._cache = {}
|
|
35
|
+
self._cacheRef = None # identity of lexicon.data the cache was built from
|
|
36
|
+
self._lts = None # lazy LetterToSound
|
|
37
|
+
self._tokenizer = None # lazy
|
|
38
|
+
self._tagger = None # lazy
|
|
39
|
+
self.rita = None
|
|
40
|
+
self._dict_cache = None
|
|
41
|
+
|
|
42
|
+
def _silent_lts(self, opts=None):
|
|
43
|
+
return bool(
|
|
44
|
+
(self.rita and self.rita.SILENCE_LTS)
|
|
45
|
+
or (opts and opts.get('silent'))
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# ── lazy deps ────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def _get_lts(self):
|
|
51
|
+
if self._lts is None:
|
|
52
|
+
from rita.rita_lts import LetterToSound
|
|
53
|
+
self._lts = LetterToSound(self.rita)
|
|
54
|
+
return self._lts
|
|
55
|
+
|
|
56
|
+
def _get_tokenizer(self):
|
|
57
|
+
if self._tokenizer is None:
|
|
58
|
+
from rita.tokenizer import Tokenizer
|
|
59
|
+
self._tokenizer = Tokenizer()
|
|
60
|
+
return self._tokenizer
|
|
61
|
+
|
|
62
|
+
def _get_tagger(self):
|
|
63
|
+
if self._tagger is None:
|
|
64
|
+
from rita.tagger import Tagger
|
|
65
|
+
self._tagger = Tagger()
|
|
66
|
+
return self._tagger
|
|
67
|
+
|
|
68
|
+
def _get_dict(self):
|
|
69
|
+
# JS reads through the lexicon (lex.rawPhones), so a swapped
|
|
70
|
+
# RiTa.lexicon.data must be visible here too.
|
|
71
|
+
from rita.rita import RiTa
|
|
72
|
+
return RiTa.lexicon.data
|
|
73
|
+
|
|
74
|
+
# ── dict helpers ─────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def _lookup_raw(self, word):
|
|
77
|
+
if not word:
|
|
78
|
+
return None
|
|
79
|
+
d = self._get_dict()
|
|
80
|
+
return d.get(word.lower())
|
|
81
|
+
|
|
82
|
+
def raw_phones(self, word, opts=None):
|
|
83
|
+
"""Return raw phone string (with syllable breaks as spaces) from dict,
|
|
84
|
+
or compute via LTS if noLts is not set."""
|
|
85
|
+
no_lts = opts.get('noLts', False) if opts else False
|
|
86
|
+
rdata = self._lookup_raw(word)
|
|
87
|
+
if rdata and len(rdata) > 0:
|
|
88
|
+
return rdata[0] # phones string from dict
|
|
89
|
+
if not no_lts:
|
|
90
|
+
phones = self.compute_phones(word, opts)
|
|
91
|
+
return Util.syllables_from_phones(phones) if phones else None
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def dict_size(self):
|
|
95
|
+
return len(self._get_dict())
|
|
96
|
+
|
|
97
|
+
# ── public API ───────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
_DIGIT_WORDS = ['zero','one','two','three','four','five','six','seven','eight','nine']
|
|
100
|
+
|
|
101
|
+
def compute_phones(self, word=None, opts=None):
|
|
102
|
+
"""Run the LTS engine; returns list of phone strings or None."""
|
|
103
|
+
if word is None:
|
|
104
|
+
return None
|
|
105
|
+
if word and len(word) == 1 and word.isdigit():
|
|
106
|
+
word = self._DIGIT_WORDS[int(word)]
|
|
107
|
+
# use dict phones for digit-words (LTS gives wrong results)
|
|
108
|
+
rdata = self._lookup_raw(word)
|
|
109
|
+
if rdata and len(rdata) > 0:
|
|
110
|
+
# dict format: 'w-ah1-n' or 'w-ah1 n' — split on space/dash
|
|
111
|
+
raw = rdata[0].replace(' ', '-')
|
|
112
|
+
return [p.rstrip('12') if p[-1:].isdigit() else p
|
|
113
|
+
for p in raw.split('-') if p]
|
|
114
|
+
result = self._get_lts().build_phones(word, opts)
|
|
115
|
+
return result # list of phoneme strings, or None
|
|
116
|
+
|
|
117
|
+
def phones_to_stress(self, phones=None):
|
|
118
|
+
"""Convert a syllabified phone string to a stress pattern string."""
|
|
119
|
+
if not phones:
|
|
120
|
+
return None
|
|
121
|
+
stress = _E
|
|
122
|
+
syls = phones.split(_SP)
|
|
123
|
+
for j, syl in enumerate(syls):
|
|
124
|
+
if not syl:
|
|
125
|
+
continue
|
|
126
|
+
stress += '1' if '1' in syl else '0'
|
|
127
|
+
if j < len(syls) - 1:
|
|
128
|
+
stress += '/'
|
|
129
|
+
return stress
|
|
130
|
+
|
|
131
|
+
def analyze_word(self, word, opts=None):
|
|
132
|
+
"""Return dict with phones, stresses, syllables for a single word."""
|
|
133
|
+
if opts is None:
|
|
134
|
+
opts = {}
|
|
135
|
+
|
|
136
|
+
# convert single digit to its word equivalent
|
|
137
|
+
if word and len(word) == 1 and word.isdigit():
|
|
138
|
+
word = self._DIGIT_WORDS[int(word)]
|
|
139
|
+
|
|
140
|
+
# invalidate the cache if the lexicon data was swapped out
|
|
141
|
+
from rita.rita import RiTa
|
|
142
|
+
data = RiTa.lexicon.data
|
|
143
|
+
if self._cacheRef is not data:
|
|
144
|
+
self._cache = {}
|
|
145
|
+
self._cacheRef = data
|
|
146
|
+
|
|
147
|
+
caching = getattr(RiTa, 'CACHING', True)
|
|
148
|
+
result = self._cache.get(word) if caching else None
|
|
149
|
+
if result is not None:
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
slash = '/'
|
|
153
|
+
delim = '-'
|
|
154
|
+
phones = word
|
|
155
|
+
syllables = word
|
|
156
|
+
stresses = word
|
|
157
|
+
|
|
158
|
+
raw_phones = self._raw_phones_word(word, opts) if not word.startswith('-') else None
|
|
159
|
+
raw_phones = raw_phones or self._compute_raw_phones(word, opts)
|
|
160
|
+
|
|
161
|
+
if raw_phones:
|
|
162
|
+
if isinstance(raw_phones, str):
|
|
163
|
+
sp = raw_phones.replace('1', _E).replace(_SP, delim) + _SP
|
|
164
|
+
phones = 'dh-ah ' if sp == 'dh ' else sp
|
|
165
|
+
ss = raw_phones.replace(_SP, slash).replace('1', _E) + _SP
|
|
166
|
+
syllables = 'dh-ah ' if ss == 'dh ' else ss
|
|
167
|
+
stresses = self.phones_to_stress(raw_phones)
|
|
168
|
+
else:
|
|
169
|
+
# hyphenated word — list of raw phone strings
|
|
170
|
+
ps, syls, strs = [], [], []
|
|
171
|
+
for p in raw_phones:
|
|
172
|
+
sp = p.replace('1', _E).replace(_SP, delim)
|
|
173
|
+
ps.append('dh-ah ' if sp == 'dh ' else sp)
|
|
174
|
+
ss = p.replace(_SP, slash).replace('1', _E)
|
|
175
|
+
syls.append('dh-ah ' if ss == 'dh ' else ss)
|
|
176
|
+
strs.append(self.phones_to_stress(p))
|
|
177
|
+
phones = '-'.join(ps)
|
|
178
|
+
syllables = '/'.join(syls)
|
|
179
|
+
stresses = '-'.join(strs)
|
|
180
|
+
|
|
181
|
+
result = Features({
|
|
182
|
+
'phones': phones.strip(),
|
|
183
|
+
'stresses': stresses.strip() if stresses else stresses,
|
|
184
|
+
'syllables': syllables.strip(),
|
|
185
|
+
})
|
|
186
|
+
if caching:
|
|
187
|
+
self._cache[word] = result
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
def analyze(self, text, opts=None):
|
|
191
|
+
"""Analyze a full text string; returns feature dict."""
|
|
192
|
+
if opts is None:
|
|
193
|
+
opts = {}
|
|
194
|
+
if not text or not text.strip():
|
|
195
|
+
return Features({'tokens': '', 'pos': '', 'stresses': '', 'phones': '', 'syllables': ''})
|
|
196
|
+
|
|
197
|
+
words = self._get_tokenizer().tokenize(text)
|
|
198
|
+
tagger = self._get_tagger()
|
|
199
|
+
tags = tagger.tag(words, opts)
|
|
200
|
+
|
|
201
|
+
features = Features({
|
|
202
|
+
'phones': _E,
|
|
203
|
+
'stresses': _E,
|
|
204
|
+
'syllables': _E,
|
|
205
|
+
'pos': ' '.join(tags),
|
|
206
|
+
'tokens': ' '.join(words),
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
for word in words:
|
|
210
|
+
r = self.analyze_word(word, opts)
|
|
211
|
+
features['phones'] += _SP + r['phones']
|
|
212
|
+
features['stresses'] += _SP + r['stresses']
|
|
213
|
+
features['syllables'] += _SP + r['syllables']
|
|
214
|
+
|
|
215
|
+
for k in features:
|
|
216
|
+
features[k] = features[k].strip()
|
|
217
|
+
|
|
218
|
+
if opts.get('ipaPhones'):
|
|
219
|
+
features['phones'] = self.syllables_to_ipa(
|
|
220
|
+
features['syllables'], features['stresses']
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return features
|
|
224
|
+
|
|
225
|
+
_ARPABET_TO_IPA = {
|
|
226
|
+
'aa': 'ɑ', 'ae': 'æ', 'ah': 'ʌ', 'ao': 'ɔ', 'aw': 'aʊ',
|
|
227
|
+
'ax': 'ə', 'ay': 'aɪ', 'eh': 'ɛ', 'er': 'ɜr', 'ey': 'eɪ',
|
|
228
|
+
'ih': 'ɪ', 'ix': 'ɨ', 'iy': 'i', 'ow': 'oʊ', 'oy': 'ɔɪ',
|
|
229
|
+
'uh': 'ʊ', 'uw': 'u', 'b': 'b', 'ch': 'tʃ', 'd': 'd',
|
|
230
|
+
'dh': 'ð', 'dx': 'ɾ', 'el': 'l̩', 'em': 'm̩', 'en': 'n̩',
|
|
231
|
+
'f': 'f', 'g': 'ɡ', 'hh': 'h', 'jh': 'dʒ', 'k': 'k',
|
|
232
|
+
'l': 'l', 'm': 'm', 'n': 'n', 'ng': 'ŋ', 'p': 'p',
|
|
233
|
+
'q': 'ʔ', 'r': 'ɹ', 's': 's', 'sh': 'ʃ', 't': 't',
|
|
234
|
+
'th': 'θ', 'v': 'v', 'w': 'w', 'wh': 'ʍ', 'y': 'j',
|
|
235
|
+
'z': 'z', 'zh': 'ʒ',
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def syllables_to_ipa(cls, syllables, stresses, keep_boundaries=False):
|
|
240
|
+
syllable_words = syllables.split(' ')
|
|
241
|
+
stress_words = stresses.split(' ')
|
|
242
|
+
ipa_words = []
|
|
243
|
+
stress_index = 0
|
|
244
|
+
for word in syllable_words:
|
|
245
|
+
if len(word) == 1 and not (word.isalpha() or word in '-/'):
|
|
246
|
+
stress_index += 1
|
|
247
|
+
continue
|
|
248
|
+
syllable_list = word.split('/')
|
|
249
|
+
stress_list = (stress_words[stress_index] if stress_index < len(stress_words) else '0').split('/')
|
|
250
|
+
stress_index += 1
|
|
251
|
+
monosyllable = len(syllable_list) == 1
|
|
252
|
+
ipa_syllables = []
|
|
253
|
+
for index, syllable in enumerate(syllable_list):
|
|
254
|
+
stressed = index < len(stress_list) and stress_list[index] == '1'
|
|
255
|
+
parts = []
|
|
256
|
+
for phone in syllable.split('-'):
|
|
257
|
+
if phone == 'ah':
|
|
258
|
+
parts.append('ʌ' if stressed else 'ə')
|
|
259
|
+
elif phone == 'er':
|
|
260
|
+
parts.append('ɜr' if stressed else 'ər')
|
|
261
|
+
else:
|
|
262
|
+
ipa = cls._ARPABET_TO_IPA.get(phone, phone)
|
|
263
|
+
if stressed and phone in ('aa', 'iy', 'ao', 'uw'):
|
|
264
|
+
ipa += 'ː'
|
|
265
|
+
parts.append(ipa)
|
|
266
|
+
value = ''.join(parts)
|
|
267
|
+
if stressed and not monosyllable:
|
|
268
|
+
value = 'ˈ' + value
|
|
269
|
+
ipa_syllables.append(value)
|
|
270
|
+
ipa_words.append('/'.join(ipa_syllables) if keep_boundaries else ''.join(ipa_syllables))
|
|
271
|
+
return ' '.join(ipa_words)
|
|
272
|
+
|
|
273
|
+
# ── phone helpers ─────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
def _compute_raw_phones(self, word, opts=None):
|
|
276
|
+
if '-' in word:
|
|
277
|
+
return self._compute_phones_hyph(word, opts)
|
|
278
|
+
return self._compute_phones_word(word, opts)
|
|
279
|
+
|
|
280
|
+
def _compute_phones_hyph(self, word, opts=None):
|
|
281
|
+
raw_phones = []
|
|
282
|
+
for part in word.split('-'):
|
|
283
|
+
p = self._compute_phones_word(part, opts, is_part=True)
|
|
284
|
+
if p and len(p) > 0:
|
|
285
|
+
raw_phones.append(p)
|
|
286
|
+
return raw_phones if raw_phones else None
|
|
287
|
+
|
|
288
|
+
def _compute_phones_word(self, word, opts=None, is_part=False):
|
|
289
|
+
raw_phones = None
|
|
290
|
+
if is_part:
|
|
291
|
+
raw_phones = self._raw_phones_word(word, {'noLts': True})
|
|
292
|
+
|
|
293
|
+
# singular plural: if ends in 's', try singular + '-z'
|
|
294
|
+
if not raw_phones and word.endswith('s'):
|
|
295
|
+
from rita.inflector import Inflector
|
|
296
|
+
sing = Inflector().singularize(word)
|
|
297
|
+
raw_phones = self._raw_phones_word(sing, {'noLts': True})
|
|
298
|
+
if raw_phones:
|
|
299
|
+
raw_phones += '-z'
|
|
300
|
+
|
|
301
|
+
silent = self._silent_lts(opts)
|
|
302
|
+
|
|
303
|
+
if not raw_phones:
|
|
304
|
+
lts_phones = self.compute_phones(word, opts)
|
|
305
|
+
if lts_phones and len(lts_phones) > 0:
|
|
306
|
+
if not silent and self.dict_size() > 0:
|
|
307
|
+
print(f"[RiTa] Used LTS-rules for '{word}'")
|
|
308
|
+
# lts returns a list; join with '-' for syllabification
|
|
309
|
+
raw_phones = Util.syllables_from_phones('-'.join(lts_phones))
|
|
310
|
+
|
|
311
|
+
return raw_phones
|
|
312
|
+
|
|
313
|
+
def _raw_phones_word(self, word, opts=None):
|
|
314
|
+
"""Look up phones in dict only (no LTS fallback)."""
|
|
315
|
+
rdata = self._lookup_raw(word)
|
|
316
|
+
if rdata and len(rdata) > 0:
|
|
317
|
+
return rdata[0]
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
# ── convenience methods (mirror RiTa top-level API) ─────────────────────
|
|
321
|
+
|
|
322
|
+
def phones(self, text, opts=None):
|
|
323
|
+
"""Return space-separated phone strings for each token."""
|
|
324
|
+
if not text or not text.strip():
|
|
325
|
+
return ''
|
|
326
|
+
features = self.analyze(text, opts)
|
|
327
|
+
return features['phones']
|
|
328
|
+
|
|
329
|
+
def syllables(self, text, opts=None):
|
|
330
|
+
"""Return space-separated syllable strings for each token."""
|
|
331
|
+
if not text or not text.strip():
|
|
332
|
+
return ''
|
|
333
|
+
features = self.analyze(text, opts)
|
|
334
|
+
if opts and opts.get('ipaPhones'):
|
|
335
|
+
return self.syllables_to_ipa(
|
|
336
|
+
features['syllables'], features['stresses'], keep_boundaries=True
|
|
337
|
+
)
|
|
338
|
+
return features['syllables']
|
|
339
|
+
|
|
340
|
+
def stresses(self, text, opts=None):
|
|
341
|
+
"""Return space-separated stress patterns for each token."""
|
|
342
|
+
if not text or not text.strip():
|
|
343
|
+
return ''
|
|
344
|
+
return self.analyze(text, opts)['stresses']
|