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 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']