misheard 0.1.0__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.
- misheard/__init__.py +16 -0
- misheard/data/confusions.csv +22 -0
- misheard/distance.py +376 -0
- misheard/explain.py +165 -0
- misheard/match.py +111 -0
- misheard/py.typed +0 -0
- misheard/translit.py +254 -0
- misheard-0.1.0.dist-info/METADATA +140 -0
- misheard-0.1.0.dist-info/RECORD +11 -0
- misheard-0.1.0.dist-info/WHEEL +4 -0
- misheard-0.1.0.dist-info/licenses/LICENSE +21 -0
misheard/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
misheard: match Indian names and places by how they sound.
|
|
3
|
+
|
|
4
|
+
>>> from misheard import distance, best_match, to_roman
|
|
5
|
+
>>> to_roman("बाड़मेर")
|
|
6
|
+
'baaṛmer'
|
|
7
|
+
>>> distance("Barmer", "बाड़मेर") < distance("Mohan", "Rohan")
|
|
8
|
+
True
|
|
9
|
+
"""
|
|
10
|
+
from .distance import distance, sound_length, sound_range, tokenize
|
|
11
|
+
from .explain import Explanation, Step, explain
|
|
12
|
+
from .match import Match, Matcher, best_match
|
|
13
|
+
from .translit import UnsupportedScriptError, plain, script_of, to_roman
|
|
14
|
+
|
|
15
|
+
__all__ = ["distance", "explain", "Explanation", "Step", "best_match", "Match", "Matcher", "to_roman", "plain", "script_of", "tokenize", "sound_length", "sound_range", "UnsupportedScriptError"]
|
|
16
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
a,b,cost,why
|
|
2
|
+
r,ṛ,0.15,"retroflex flap ड़ is heard and written as r (Baadmer/Barmer)"
|
|
3
|
+
ḍ,ṛ,0.20,"the flap is a nukta form of ड; STT and spellings mix them"
|
|
4
|
+
d,ṛ,0.35,"Roman spelling of the flap as d"
|
|
5
|
+
r,d,0.35,"follows from the two rows above"
|
|
6
|
+
t,ṭ,0.20,"Roman script has no dental/retroflex distinction"
|
|
7
|
+
th,ṭh,0.20,"same"
|
|
8
|
+
d,ḍ,0.20,"same"
|
|
9
|
+
dh,ḍh,0.20,"same"
|
|
10
|
+
n,ṇ,0.15,"same"
|
|
11
|
+
l,ḷ,0.15,"same"
|
|
12
|
+
zh,ḷ,0.25,"Tamil ழ and ள are close and both romanise as l"
|
|
13
|
+
zh,l,0.30,"Tamil ழ is often written l (Ilango)"
|
|
14
|
+
v,w,0.15,"no phonemic v/w contrast in most Indian languages"
|
|
15
|
+
v,b,0.45,"b for Sanskrit v in Bihar, Bengal, Odisha and Assam (Binod/Vinod, Bishnoi/Vishnoi); also a real spelling, so keep it moderate"
|
|
16
|
+
sh,s,0.30,"sh/s merge in many dialects and in Tamil"
|
|
17
|
+
j,z,0.35,"z is often pronounced j"
|
|
18
|
+
ph,f,0.15,"फ is often pronounced f"
|
|
19
|
+
k,g,0.25,"Tamil uses one letter for both"
|
|
20
|
+
p,b,0.25,"Tamil uses one letter for both"
|
|
21
|
+
t,ḍ,0.30,"Tamil ட is voiced between vowels but romanised t (Natarajan, Venkatesh)"
|
|
22
|
+
ch,s,0.35,"Tamil ச is s between vowels"
|
misheard/distance.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Phoneme-weighted distance for Indian names and places.
|
|
3
|
+
|
|
4
|
+
Plain edit distance treats every letter swap as equally bad. For speech that
|
|
5
|
+
is wrong in both directions:
|
|
6
|
+
|
|
7
|
+
- "Barmer" vs "Baadmer" are the same place: a vowel-length change plus the
|
|
8
|
+
retroflex flap (ड़) written two different ways. Should be cheap.
|
|
9
|
+
- "Mohan" vs "Rohan" are different people: m and r sound nothing alike,
|
|
10
|
+
even though only one letter differs. Should be expensive.
|
|
11
|
+
|
|
12
|
+
So each sound carries articulatory features (where and how it is made,
|
|
13
|
+
voicing, aspiration; vowel height, backness, length), and substituting one
|
|
14
|
+
sound for another costs the weighted difference of those features. A small
|
|
15
|
+
table of confusions specific to Indian languages (data/confusions.csv)
|
|
16
|
+
overrides the feature cost where linguistics says two sounds are routinely
|
|
17
|
+
mixed up.
|
|
18
|
+
"""
|
|
19
|
+
import csv
|
|
20
|
+
import io
|
|
21
|
+
import re
|
|
22
|
+
from functools import lru_cache
|
|
23
|
+
from itertools import permutations, product
|
|
24
|
+
from typing import NamedTuple
|
|
25
|
+
from importlib.resources import files
|
|
26
|
+
|
|
27
|
+
from .translit import plain, to_roman
|
|
28
|
+
|
|
29
|
+
# ---- sound inventory -------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
# consonants: (place, manner, voiced, aspirated)
|
|
32
|
+
# place: 0 bilabial, 1 labiodental, 2 dental/alveolar, 3 retroflex,
|
|
33
|
+
# 4 palatal, 5 velar, 6 glottal
|
|
34
|
+
# manner: 0 stop, 1 nasal, 2 fricative, 3 approximant, 4 trill/flap, 5 affricate
|
|
35
|
+
CONSONANTS = {
|
|
36
|
+
"p": (0, 0, 0, 0), "ph": (0, 0, 0, 1), "b": (0, 0, 1, 0), "bh": (0, 0, 1, 1), "m": (0, 1, 1, 0),
|
|
37
|
+
"f": (1, 2, 0, 0), "v": (1, 3, 1, 0), "w": (0, 3, 1, 0),
|
|
38
|
+
"t": (2, 0, 0, 0), "th": (2, 0, 0, 1), "d": (2, 0, 1, 0), "dh": (2, 0, 1, 1),
|
|
39
|
+
"n": (2, 1, 1, 0), "s": (2, 2, 0, 0), "z": (2, 2, 1, 0), "l": (2, 3, 1, 0), "r": (2, 4, 1, 0),
|
|
40
|
+
"ṭ": (3, 0, 0, 0), "ṭh": (3, 0, 0, 1), "ḍ": (3, 0, 1, 0), "ḍh": (3, 0, 1, 1),
|
|
41
|
+
"ṇ": (3, 1, 1, 0), "ḷ": (3, 3, 1, 0), "ṛ": (3, 4, 1, 0), "ṛh": (3, 4, 1, 1), "zh": (3, 3, 1, 0),
|
|
42
|
+
"ch": (4, 5, 0, 0), "chh": (4, 5, 0, 1), "j": (4, 5, 1, 0), "jh": (4, 5, 1, 1),
|
|
43
|
+
"sh": (4, 2, 0, 0), "y": (4, 3, 1, 0),
|
|
44
|
+
"k": (5, 0, 0, 0), "kh": (5, 0, 0, 1), "g": (5, 0, 1, 0), "gh": (5, 0, 1, 1),
|
|
45
|
+
"h": (6, 2, 0, 0),
|
|
46
|
+
}
|
|
47
|
+
# vowels: (height 0 low..2 high, backness 0 front..2 back, length 0 short/1 long)
|
|
48
|
+
VOWELS = {
|
|
49
|
+
"a": (0, 1, 0), "aa": (0, 1, 1),
|
|
50
|
+
"i": (2, 0, 0), "ee": (2, 0, 1),
|
|
51
|
+
"u": (2, 2, 0), "oo": (2, 2, 1),
|
|
52
|
+
"e": (1, 0, 0), "ai": (1, 0, 1),
|
|
53
|
+
"o": (1, 2, 0), "au": (1, 2, 1),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
W_PLACE, W_MANNER, W_VOICE, W_ASP = 0.45, 0.45, 0.25, 0.15
|
|
57
|
+
W_HEIGHT, W_BACK, W_LEN = 0.35, 0.35, 0.25
|
|
58
|
+
CROSS_CLASS = 1.8 # a vowel replaced by a consonant
|
|
59
|
+
INDEL = 0.85 # a consonant missing or added
|
|
60
|
+
INDEL_CHEAP = {"a": 0.4, "h": 0.4, "y": 0.5} # silent schwa, aspiration spellings
|
|
61
|
+
INDEL_VOWEL = 0.6
|
|
62
|
+
|
|
63
|
+
_TOKENS = sorted(set(CONSONANTS) | set(VOWELS), key=len, reverse=True)
|
|
64
|
+
|
|
65
|
+
def _load_confusions() -> list[dict]:
|
|
66
|
+
text = files(__package__).joinpath("data", "confusions.csv").read_text(encoding="utf-8")
|
|
67
|
+
return list(csv.DictReader(io.StringIO(text)))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_CONFUSION_ROWS = _load_confusions()
|
|
71
|
+
CONFUSIONS = {frozenset((r["a"], r["b"])): float(r["cost"]) for r in _CONFUSION_ROWS}
|
|
72
|
+
CONFUSION_NOTES = {frozenset((r["a"], r["b"])): r["why"] for r in _CONFUSION_ROWS}
|
|
73
|
+
|
|
74
|
+
# ---- tokenizer -------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
# English-style spellings of Indian names, folded before tokenizing.
|
|
77
|
+
_SPELLING = [
|
|
78
|
+
("ck", "k"), ("x", "ks"), ("q", "k"), ("ii", "ee"), ("uu", "oo"),
|
|
79
|
+
("ou", "au"),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _fold(word: str) -> str:
|
|
84
|
+
w = word.lower()
|
|
85
|
+
for a, b in _SPELLING:
|
|
86
|
+
w = w.replace(a, b)
|
|
87
|
+
if w.endswith("ow"): # Lucknow
|
|
88
|
+
w = w[:-2] + "au"
|
|
89
|
+
if w.endswith("ey"): # Pandey, Dubey
|
|
90
|
+
w = w[:-2] + "e"
|
|
91
|
+
w = re.sub(r"ay(?=[^aeiou]|$)", "ai", w) # Sanjay/संजय, Jaipur/जयपुर
|
|
92
|
+
# c without h is k (Vincent, Deepcika); keep ch
|
|
93
|
+
w = "".join("k" if c == "c" and not w[i + 1:i + 2] == "h" else c for i, c in enumerate(w))
|
|
94
|
+
# doubled consonants are not contrastive in romanised names (Pillai/Pilai)
|
|
95
|
+
out = []
|
|
96
|
+
for c in w:
|
|
97
|
+
if out and c == out[-1] and c not in "aeo":
|
|
98
|
+
continue
|
|
99
|
+
out.append(c)
|
|
100
|
+
return "".join(out)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@lru_cache(maxsize=50_000)
|
|
104
|
+
def tokenize(word: str) -> tuple[str, ...]:
|
|
105
|
+
"""One word in any script -> tuple of sounds, greedy longest match."""
|
|
106
|
+
s = _fold(to_roman(word))
|
|
107
|
+
i, out = 0, []
|
|
108
|
+
while i < len(s):
|
|
109
|
+
for tok in _TOKENS:
|
|
110
|
+
if s.startswith(tok, i):
|
|
111
|
+
out.append(tok)
|
|
112
|
+
i += len(tok)
|
|
113
|
+
break
|
|
114
|
+
else:
|
|
115
|
+
i += 1 # digits, punctuation, unknown marks
|
|
116
|
+
return tuple(out)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---- costs -----------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def sub_cost(a: str, b: str) -> float:
|
|
122
|
+
if a == b:
|
|
123
|
+
return 0.0
|
|
124
|
+
override = CONFUSIONS.get(frozenset((a, b)))
|
|
125
|
+
if override is not None:
|
|
126
|
+
return override
|
|
127
|
+
a_vowel, b_vowel = a in VOWELS, b in VOWELS
|
|
128
|
+
if a_vowel != b_vowel:
|
|
129
|
+
return CROSS_CLASS
|
|
130
|
+
if a_vowel:
|
|
131
|
+
h1, k1, l1 = VOWELS[a]
|
|
132
|
+
h2, k2, l2 = VOWELS[b]
|
|
133
|
+
return W_HEIGHT * abs(h1 - h2) + W_BACK * abs(k1 - k2) + W_LEN * abs(l1 - l2)
|
|
134
|
+
p1, m1, v1, s1 = CONSONANTS[a]
|
|
135
|
+
p2, m2, v2, s2 = CONSONANTS[b]
|
|
136
|
+
return W_PLACE * abs(p1 - p2) + W_MANNER * min(abs(m1 - m2), 2) + W_VOICE * abs(v1 - v2) + W_ASP * abs(s1 - s2)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def indel_cost(tok: str) -> float:
|
|
140
|
+
if tok in INDEL_CHEAP:
|
|
141
|
+
return INDEL_CHEAP[tok]
|
|
142
|
+
return INDEL_VOWEL if tok in VOWELS else INDEL
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# The DP runs on integer ids with the costs precomputed, so the inner loop is
|
|
146
|
+
# list indexing only. This is also the boundary a compiled kernel would
|
|
147
|
+
# replace (PLAN.md 3.4).
|
|
148
|
+
_ID = {tok: i for i, tok in enumerate(_TOKENS)}
|
|
149
|
+
_SUB = [[sub_cost(a, b) for b in _TOKENS] for a in _TOKENS]
|
|
150
|
+
_INDEL = [indel_cost(t) for t in _TOKENS]
|
|
151
|
+
INF = float("inf")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _ids(sounds: tuple[str, ...]) -> tuple[int, ...]:
|
|
155
|
+
return tuple(_ID[t] for t in sounds)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _seq(ia: tuple[int, ...], ib: tuple[int, ...], cutoff: float = INF) -> float:
|
|
159
|
+
"""
|
|
160
|
+
Weighted edit distance over token ids.
|
|
161
|
+
|
|
162
|
+
Stops early once every cell of a row exceeds `cutoff`: the result is then
|
|
163
|
+
some value above `cutoff`, not the exact distance.
|
|
164
|
+
"""
|
|
165
|
+
sub, indel = _SUB, _INDEL
|
|
166
|
+
prev = [0.0]
|
|
167
|
+
acc = 0.0
|
|
168
|
+
for y in ib:
|
|
169
|
+
acc += indel[y]
|
|
170
|
+
prev.append(acc)
|
|
171
|
+
for x in ia:
|
|
172
|
+
row, dx = sub[x], indel[x]
|
|
173
|
+
left = prev[0] + dx
|
|
174
|
+
cur = [left]
|
|
175
|
+
row_min = left
|
|
176
|
+
for j, y in enumerate(ib):
|
|
177
|
+
d = prev[j] + row[y]
|
|
178
|
+
t = prev[j + 1] + dx
|
|
179
|
+
if t < d:
|
|
180
|
+
d = t
|
|
181
|
+
t = left + indel[y]
|
|
182
|
+
if t < d:
|
|
183
|
+
d = t
|
|
184
|
+
cur.append(d)
|
|
185
|
+
left = d
|
|
186
|
+
if d < row_min:
|
|
187
|
+
row_min = d
|
|
188
|
+
if row_min > cutoff:
|
|
189
|
+
return row_min
|
|
190
|
+
prev = cur
|
|
191
|
+
return prev[-1]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def sequence_distance(pa: tuple[str, ...], pb: tuple[str, ...]) -> float:
|
|
195
|
+
"""Weighted edit distance between two sound sequences."""
|
|
196
|
+
return _seq(_ids(pa), _ids(pb))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---- words -----------------------------------------------------------------
|
|
200
|
+
#
|
|
201
|
+
# A spoken name is more than its sounds: people add titles ("Shri Mohan Lal"),
|
|
202
|
+
# honorifics ("Mohan ji", "Rameshbhai"), use initials ("R. Kumar") and change
|
|
203
|
+
# word order ("Kumar Ramesh Singh"). Each name is expanded into a few
|
|
204
|
+
# variants, each with a small penalty, and the cheapest pairing wins.
|
|
205
|
+
|
|
206
|
+
# Dropped whenever another word remains. Listed in any script; they are
|
|
207
|
+
# compared after romanising, so श्री, Shri and Sri all match.
|
|
208
|
+
TITLES = [
|
|
209
|
+
"shri", "shree", "sri", "sree", "sh", "shrimati", "srimati", "smt", "dr", "doctor",
|
|
210
|
+
"mr", "mrs", "ms", "miss", "thiru", "tiru", "thirumathi", "tirumathi",
|
|
211
|
+
"श्री", "श्रीमती", "डॉ", "डॉक्टर", "திரு", "திருமதி", "ஸ்ரீ",
|
|
212
|
+
]
|
|
213
|
+
# Honorific words, said after (or before) a name. Also dropped.
|
|
214
|
+
HONORIFICS = [
|
|
215
|
+
"ji", "jee", "sahab", "saheb", "sahib", "sir", "madam", "bhai", "bhaiya", "ben", "behen", "didi",
|
|
216
|
+
"जी", "साहब", "भाई", "भैया", "बहन", "बेन", "दीदी",
|
|
217
|
+
]
|
|
218
|
+
# Honorifics written joined to the name (Gujarati Rameshbhai, Hansaben).
|
|
219
|
+
# Optional, because the joined form can be the whole name. "ji" is not here:
|
|
220
|
+
# Balaji, Shivaji.
|
|
221
|
+
SUFFIXES = ["bhai", "ben", "भाई", "बेन"]
|
|
222
|
+
SUFFIX_PENALTY = 0.1
|
|
223
|
+
# Often part of a legal name and often left out when spoken.
|
|
224
|
+
OPTIONAL_WORDS = ["devi", "kumar", "kumari", "देवी", "कुमार", "कुमारी", "தேவி", "குமார்", "குமாரி"]
|
|
225
|
+
OPTIONAL_PENALTY = 0.5
|
|
226
|
+
|
|
227
|
+
INITIAL_COST = 0.3 # "R." standing for a word starting with r
|
|
228
|
+
INITIAL_MISMATCH = 1.5 # an initial against a word starting with another letter
|
|
229
|
+
SWAP_PENALTY = 0.2 # words in a different order
|
|
230
|
+
MAX_PERMUTED_WORDS = 4 # up to 4! = 24 orders
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _key(word: str) -> str:
|
|
234
|
+
"""Romanised, folded, unmarked letters only: for comparing with the word lists above."""
|
|
235
|
+
return re.sub(r"[^a-z]", "", plain(_fold(to_roman(word))))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
_TITLES = {_key(w) for w in TITLES} | {_key(w) for w in HONORIFICS}
|
|
239
|
+
_SUFFIXES = sorted({_key(w) for w in SUFFIXES}, key=len, reverse=True)
|
|
240
|
+
_OPTIONAL = {_key(w) for w in OPTIONAL_WORDS}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class _Word(NamedTuple):
|
|
244
|
+
sounds: tuple[str, ...]
|
|
245
|
+
ids: tuple[int, ...]
|
|
246
|
+
initial: str | None # the letter, when the word is an initial like "R."
|
|
247
|
+
first: str # first letter of the romanised word
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _word(text: str) -> _Word | None:
|
|
251
|
+
sounds = tokenize(text)
|
|
252
|
+
if not sounds:
|
|
253
|
+
return None
|
|
254
|
+
is_initial = len(text) == 1 and text.isascii() and text.isalpha()
|
|
255
|
+
key = _key(text)
|
|
256
|
+
return _Word(sounds, _ids(sounds), text.lower() if is_initial else None, key[:1])
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# A note says what a variant changed: (word as written, kind, cost), kind
|
|
260
|
+
# being "title", "optional" (Devi, Kumar) or "suffix" (Rameshbhai).
|
|
261
|
+
Note = tuple[str, str, float]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@lru_cache(maxsize=50_000)
|
|
265
|
+
def _variants_detailed(text: str) -> tuple[tuple[float, tuple["_Word", ...], tuple[Note, ...]], ...]:
|
|
266
|
+
"""The ways a name may be said, each with its penalty and what was changed."""
|
|
267
|
+
raw = text.replace(".", " ").split() # "R.K. Narayan" -> R K Narayan
|
|
268
|
+
raw = [w for w in raw if _word(w)]
|
|
269
|
+
core = [w for w in raw if _key(w) not in _TITLES] or raw
|
|
270
|
+
dropped = tuple((w, "title", 0.0) for w in raw if w not in core)
|
|
271
|
+
options: list[list[tuple[float, _Word | None, Note | None]]] = []
|
|
272
|
+
for w in core:
|
|
273
|
+
word = _word(w)
|
|
274
|
+
choices: list[tuple[float, _Word | None, Note | None]] = [(0.0, word, None)]
|
|
275
|
+
key = _key(w)
|
|
276
|
+
if key in _OPTIONAL and len(core) > 1:
|
|
277
|
+
choices.append((OPTIONAL_PENALTY, None, (w, "optional", OPTIONAL_PENALTY)))
|
|
278
|
+
elif word.initial is None:
|
|
279
|
+
roman = _fold(to_roman(w))
|
|
280
|
+
for suf in _SUFFIXES:
|
|
281
|
+
if key.endswith(suf) and len(key) - len(suf) >= 3 and plain(roman).endswith(suf):
|
|
282
|
+
stem = _word(roman[: len(roman) - len(suf)])
|
|
283
|
+
if stem:
|
|
284
|
+
choices.append((SUFFIX_PENALTY, stem, (w, "suffix", SUFFIX_PENALTY)))
|
|
285
|
+
break
|
|
286
|
+
options.append(choices)
|
|
287
|
+
out: dict = {}
|
|
288
|
+
for combo in product(*options):
|
|
289
|
+
words = tuple(w for _, w, _ in combo if w is not None)
|
|
290
|
+
if words:
|
|
291
|
+
pen = round(sum(p for p, _, _ in combo), 6)
|
|
292
|
+
if words not in out or out[words][0] > pen:
|
|
293
|
+
out[words] = (pen, dropped + tuple(n for _, _, n in combo if n))
|
|
294
|
+
return tuple((p, w, notes) for w, (p, notes) in out.items())
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@lru_cache(maxsize=50_000)
|
|
298
|
+
def _variants(text: str) -> "Variants":
|
|
299
|
+
"""The ways a name may be said, each with its penalty."""
|
|
300
|
+
return tuple((p, w) for p, w, _ in _variants_detailed(text))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _word_cost(x: _Word, y: _Word, cutoff: float = INF) -> float:
|
|
304
|
+
if x.initial or y.initial:
|
|
305
|
+
if x.initial and y.initial:
|
|
306
|
+
return 0.0 if x.initial == y.initial else INITIAL_MISMATCH
|
|
307
|
+
letter, other = (x.initial, y) if x.initial else (y.initial, x)
|
|
308
|
+
return INITIAL_COST if other.first == letter else INITIAL_MISMATCH
|
|
309
|
+
return _seq(x.ids, y.ids, cutoff)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _words_distance(wa: tuple[_Word, ...], wb: tuple[_Word, ...], cutoff: float = INF) -> float:
|
|
313
|
+
n = len(wa)
|
|
314
|
+
if n == len(wb):
|
|
315
|
+
cost = [[_word_cost(x, y, cutoff) for y in wb] for x in wa]
|
|
316
|
+
best = sum(cost[i][i] for i in range(n))
|
|
317
|
+
if 1 < n <= MAX_PERMUTED_WORDS:
|
|
318
|
+
for perm in permutations(range(n)):
|
|
319
|
+
best = min(best, sum(cost[i][p] for i, p in enumerate(perm)) + SWAP_PENALTY)
|
|
320
|
+
return best
|
|
321
|
+
# A different number of words (a dropped surname, "Meenakshisundaram" as
|
|
322
|
+
# one word): compare as one joined sequence, so every missing sound costs.
|
|
323
|
+
return _seq(tuple(t for w in wa for t in w.ids), tuple(t for w in wb for t in w.ids), cutoff)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
Variants = tuple[tuple[float, tuple[_Word, ...]], ...]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _variants_distance(va: Variants, vb: Variants, cutoff: float = INF) -> float:
|
|
330
|
+
"""Cheapest pairing of two names' variants. Above `cutoff`, only a lower bound."""
|
|
331
|
+
best = INF
|
|
332
|
+
for pa, wa in va:
|
|
333
|
+
for pb, wb in vb:
|
|
334
|
+
limit = min(best, cutoff) - pa - pb
|
|
335
|
+
d = pa + pb + _words_distance(wa, wb, limit)
|
|
336
|
+
if d < best:
|
|
337
|
+
best = d
|
|
338
|
+
return best
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def distance(a: str, b: str) -> float:
|
|
342
|
+
"""
|
|
343
|
+
Distance between two names of one or more words, in any mix of scripts.
|
|
344
|
+
|
|
345
|
+
Titles and honorifics are ignored (Shri, Smt, Dr, ji, sahab, bhai), joined
|
|
346
|
+
honorifics (Rameshbhai) and words like Devi or Kumar may be left out for a
|
|
347
|
+
small penalty, initials match words that start with the same letter, and
|
|
348
|
+
words may come in any order for a small penalty.
|
|
349
|
+
"""
|
|
350
|
+
va, vb = _variants(a), _variants(b)
|
|
351
|
+
if not va or not vb:
|
|
352
|
+
return INF
|
|
353
|
+
return round(_variants_distance(va, vb), 6) # 1.8, not 1.7999999999999998
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@lru_cache(maxsize=50_000)
|
|
357
|
+
def sound_length(text: str) -> int:
|
|
358
|
+
"""Number of sounds in a name or place, all words together."""
|
|
359
|
+
return sum(len(tokenize(w)) for w in text.split())
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@lru_cache(maxsize=50_000)
|
|
363
|
+
def sound_range(text: str) -> tuple[int, float]:
|
|
364
|
+
"""
|
|
365
|
+
Fewest and most sounds any variant of `text` can stand for.
|
|
366
|
+
|
|
367
|
+
An initial stands for a word of any length, so a name with one has no
|
|
368
|
+
upper bound. `best_match` uses this to skip references that cannot be
|
|
369
|
+
close.
|
|
370
|
+
"""
|
|
371
|
+
lo, hi = float("inf"), 0.0
|
|
372
|
+
for _, words in _variants(text):
|
|
373
|
+
n = sum(len(w.sounds) for w in words)
|
|
374
|
+
lo = min(lo, n)
|
|
375
|
+
hi = max(hi, float("inf") if any(w.initial for w in words) else n)
|
|
376
|
+
return (int(lo), hi) if lo != float("inf") else (0, 0.0)
|
misheard/explain.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Why two names are as close as they are.
|
|
3
|
+
|
|
4
|
+
>>> print(explain("Barmer", "बाड़मेर"))
|
|
5
|
+
Barmer ↔ बाड़मेर (baaṛmer) distance 0.4
|
|
6
|
+
b = b
|
|
7
|
+
a → aa 0.25 vowel length
|
|
8
|
+
r → ṛ 0.15 retroflex flap ड़ is heard and written as r (Baadmer/Barmer)
|
|
9
|
+
m = m
|
|
10
|
+
e = e
|
|
11
|
+
r = r
|
|
12
|
+
|
|
13
|
+
`explain` returns the same distance as `distance`, broken into steps: each
|
|
14
|
+
sound kept, swapped, added or dropped, plus word-level steps (a title
|
|
15
|
+
ignored, Devi left out, an initial, a change of word order). The step costs
|
|
16
|
+
add up to the distance.
|
|
17
|
+
"""
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from itertools import permutations
|
|
20
|
+
|
|
21
|
+
from .distance import (
|
|
22
|
+
CONFUSION_NOTES, CONSONANTS, INDEL_CHEAP, INF, INITIAL_COST, MAX_PERMUTED_WORDS,
|
|
23
|
+
SWAP_PENALTY, VOWELS, _variants_detailed, _Word, _word_cost, indel_cost, sub_cost,
|
|
24
|
+
)
|
|
25
|
+
from .translit import to_roman
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Step:
|
|
30
|
+
kind: str # same, swap, add, drop, initial, reorder, title, optional, suffix
|
|
31
|
+
a: str # sound or word on the first side ("" if none)
|
|
32
|
+
b: str # sound or word on the second side ("" if none)
|
|
33
|
+
cost: float
|
|
34
|
+
why: str = "" # human-readable reason, when there is one
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Explanation:
|
|
39
|
+
a: str
|
|
40
|
+
b: str
|
|
41
|
+
distance: float
|
|
42
|
+
steps: tuple[Step, ...]
|
|
43
|
+
|
|
44
|
+
def __str__(self) -> str:
|
|
45
|
+
def label(text: str) -> str:
|
|
46
|
+
roman = to_roman(text)
|
|
47
|
+
return text if roman == text.lower() else f"{text} ({roman})"
|
|
48
|
+
|
|
49
|
+
lines = [f"{label(self.a)} ↔ {label(self.b)} distance {self.distance:g}"]
|
|
50
|
+
sym = {"same": "=", "swap": "→", "add": "+", "drop": "-", "initial": "≈"}
|
|
51
|
+
width = max([len(s.a) for s in self.steps if s.kind in sym] + [1])
|
|
52
|
+
for s in self.steps:
|
|
53
|
+
if s.kind in sym:
|
|
54
|
+
cost = f"{s.cost:g}" if s.cost else ""
|
|
55
|
+
lines.append(f" {s.a or '·':<{width}} {sym[s.kind]} {s.b or '·':<{width}} {cost:>5} {s.why}".rstrip())
|
|
56
|
+
else:
|
|
57
|
+
lines.append(f" [{s.kind}] {s.why} {s.cost:g}".rstrip())
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _sub_why(a: str, b: str) -> str:
|
|
62
|
+
note = CONFUSION_NOTES.get(frozenset((a, b)))
|
|
63
|
+
if note:
|
|
64
|
+
return note
|
|
65
|
+
if (a in VOWELS) != (b in VOWELS):
|
|
66
|
+
return "vowel for consonant"
|
|
67
|
+
if a in VOWELS:
|
|
68
|
+
names = ("height", "front/back", "length")
|
|
69
|
+
diff = [n for n, x, y in zip(names, VOWELS[a], VOWELS[b]) if x != y]
|
|
70
|
+
return "vowel " + ", ".join(diff)
|
|
71
|
+
names = ("place", "manner", "voicing", "aspiration")
|
|
72
|
+
return ", ".join(n for n, x, y in zip(names, CONSONANTS[a], CONSONANTS[b]) if x != y)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _indel_why(tok: str) -> str:
|
|
76
|
+
if tok == "a":
|
|
77
|
+
return "short a (often silent)"
|
|
78
|
+
if tok in INDEL_CHEAP:
|
|
79
|
+
return "often silent or only in spelling"
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _align(pa: tuple[str, ...], pb: tuple[str, ...]) -> list[Step]:
|
|
84
|
+
"""Backtrace of the weighted edit distance, same arithmetic as the matcher."""
|
|
85
|
+
n, m = len(pa), len(pb)
|
|
86
|
+
d = [[0.0] * (m + 1) for _ in range(n + 1)]
|
|
87
|
+
for j in range(1, m + 1):
|
|
88
|
+
d[0][j] = d[0][j - 1] + indel_cost(pb[j - 1])
|
|
89
|
+
for i in range(1, n + 1):
|
|
90
|
+
d[i][0] = d[i - 1][0] + indel_cost(pa[i - 1])
|
|
91
|
+
for j in range(1, m + 1):
|
|
92
|
+
d[i][j] = min(
|
|
93
|
+
d[i - 1][j - 1] + sub_cost(pa[i - 1], pb[j - 1]),
|
|
94
|
+
d[i - 1][j] + indel_cost(pa[i - 1]),
|
|
95
|
+
d[i][j - 1] + indel_cost(pb[j - 1]),
|
|
96
|
+
)
|
|
97
|
+
steps: list[Step] = []
|
|
98
|
+
i, j = n, m
|
|
99
|
+
while i or j:
|
|
100
|
+
if i and j and d[i][j] == d[i - 1][j - 1] + sub_cost(pa[i - 1], pb[j - 1]):
|
|
101
|
+
x, y = pa[i - 1], pb[j - 1]
|
|
102
|
+
c = sub_cost(x, y)
|
|
103
|
+
steps.append(Step("same", x, y, 0.0) if x == y else Step("swap", x, y, c, _sub_why(x, y)))
|
|
104
|
+
i, j = i - 1, j - 1
|
|
105
|
+
elif i and d[i][j] == d[i - 1][j] + indel_cost(pa[i - 1]):
|
|
106
|
+
steps.append(Step("drop", pa[i - 1], "", indel_cost(pa[i - 1]), _indel_why(pa[i - 1])))
|
|
107
|
+
i -= 1
|
|
108
|
+
else:
|
|
109
|
+
steps.append(Step("add", "", pb[j - 1], indel_cost(pb[j - 1]), _indel_why(pb[j - 1])))
|
|
110
|
+
j -= 1
|
|
111
|
+
return steps[::-1]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _word_steps(x: _Word, y: _Word) -> list[Step]:
|
|
115
|
+
if x.initial or y.initial:
|
|
116
|
+
c = _word_cost(x, y)
|
|
117
|
+
a, b = "".join(x.sounds), "".join(y.sounds)
|
|
118
|
+
why = "initial" if c in (0.0, INITIAL_COST) else "initial with a different letter"
|
|
119
|
+
return [Step("initial", a, b, c, why)]
|
|
120
|
+
return _align(x.sounds, y.sounds)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _words_steps(wa: tuple[_Word, ...], wb: tuple[_Word, ...]) -> tuple[float, list[Step]]:
|
|
124
|
+
n = len(wa)
|
|
125
|
+
if n != len(wb):
|
|
126
|
+
joined = _align(tuple(t for w in wa for t in w.sounds), tuple(t for w in wb for t in w.sounds))
|
|
127
|
+
return sum(s.cost for s in joined), joined
|
|
128
|
+
cost = [[_word_cost(x, y) for y in wb] for x in wa]
|
|
129
|
+
best, best_perm = sum(cost[i][i] for i in range(n)), tuple(range(n))
|
|
130
|
+
if 1 < n <= MAX_PERMUTED_WORDS:
|
|
131
|
+
for perm in permutations(range(n)):
|
|
132
|
+
c = sum(cost[i][p] for i, p in enumerate(perm)) + SWAP_PENALTY
|
|
133
|
+
if c < best:
|
|
134
|
+
best, best_perm = c, perm
|
|
135
|
+
steps: list[Step] = []
|
|
136
|
+
if best_perm != tuple(range(n)):
|
|
137
|
+
order = " ".join("".join(wb[p].sounds) for p in best_perm)
|
|
138
|
+
steps.append(Step("reorder", "", "", SWAP_PENALTY, f"words in a different order: {order}"))
|
|
139
|
+
for i, p in enumerate(best_perm):
|
|
140
|
+
steps.extend(_word_steps(wa[i], wb[p]))
|
|
141
|
+
return best, steps
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _note_steps(notes, side: str) -> list[Step]:
|
|
145
|
+
what = {"title": "title or honorific ignored", "optional": "left out", "suffix": "honorific suffix split off"}
|
|
146
|
+
return [Step(kind, w if side == "a" else "", w if side == "b" else "", cost, f"{w}: {what[kind]}")
|
|
147
|
+
for w, kind, cost in notes]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def explain(a: str, b: str) -> Explanation:
|
|
151
|
+
"""The steps that make up `distance(a, b)`, cheapest alignment first."""
|
|
152
|
+
va, vb = _variants_detailed(a), _variants_detailed(b)
|
|
153
|
+
if not va or not vb:
|
|
154
|
+
return Explanation(a, b, INF, ())
|
|
155
|
+
best, best_steps = INF, []
|
|
156
|
+
for pa, wa, na in va:
|
|
157
|
+
for pb, wb, nb in vb:
|
|
158
|
+
d, steps = _words_steps(wa, wb)
|
|
159
|
+
if pa + pb + d < best:
|
|
160
|
+
best = pa + pb + d
|
|
161
|
+
best_steps = _note_steps(na, "a") + _note_steps(nb, "b") + steps
|
|
162
|
+
return Explanation(a, b, round(best, 6), tuple(best_steps))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
__all__ = ["explain", "Explanation", "Step"]
|
misheard/match.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Find the closest references to something heard.
|
|
3
|
+
|
|
4
|
+
m = Matcher(districts) # once
|
|
5
|
+
m.best_match("Baadmer") # per utterance
|
|
6
|
+
|
|
7
|
+
`Matcher` holds a list of references (names, places, anything spoken),
|
|
8
|
+
prepared once, and ranks them by phonetic distance to the heard text,
|
|
9
|
+
returning the closest few. `best_match` does the same for a one-off list. It does not
|
|
10
|
+
decide whether the best one is "good enough": that depends on the
|
|
11
|
+
application, so the distances and the gap between the first and second
|
|
12
|
+
match are returned for the caller to judge.
|
|
13
|
+
|
|
14
|
+
Every sound missing from one side costs at least the cheapest deletion, so a
|
|
15
|
+
reference much longer or shorter than what was heard cannot beat matches
|
|
16
|
+
already found. Lengths are ranges (a dropped "Devi", an initial standing for
|
|
17
|
+
a whole word), and the gap between the two ranges gives the lower bound.
|
|
18
|
+
References are visited in order of that gap and the search stops once the
|
|
19
|
+
bound exceeds the current `top`-th best. Each distance computation also
|
|
20
|
+
stops early once it can't beat that. The result is exactly what scoring
|
|
21
|
+
every reference would give, with far fewer computations.
|
|
22
|
+
"""
|
|
23
|
+
from bisect import insort
|
|
24
|
+
from collections.abc import Hashable, Iterable
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
from .distance import INDEL_CHEAP, INF, _variants, _variants_distance, sound_range
|
|
28
|
+
|
|
29
|
+
_FLOOR = min(INDEL_CHEAP.values()) # cheapest cost of one missing sound
|
|
30
|
+
_EPS = 1e-6 # distances are rounded to 6 places
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Match:
|
|
35
|
+
key: Hashable # the key given with the reference, or the text itself
|
|
36
|
+
text: str # the reference text
|
|
37
|
+
distance: float # phonetic distance from what was heard; 0 is identical
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
Reference = str | tuple[Hashable, str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Matcher:
|
|
44
|
+
"""
|
|
45
|
+
A list of references, prepared once for many lookups.
|
|
46
|
+
|
|
47
|
+
references: plain strings, or (key, text) pairs when several texts lead
|
|
48
|
+
to one thing (an id for a person, aliases for one city). With pairs,
|
|
49
|
+
each key appears at most once in a result, at its best distance.
|
|
50
|
+
|
|
51
|
+
Raises UnsupportedScriptError if a reference is in a script the library
|
|
52
|
+
can't read, so bad data shows up when the list is loaded, not mid-call.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, references: Iterable[Reference]):
|
|
56
|
+
self._refs = []
|
|
57
|
+
order: dict[Hashable, int] = {}
|
|
58
|
+
for r in references:
|
|
59
|
+
key, text = (r, r) if isinstance(r, str) else (r[0], r[1])
|
|
60
|
+
variants = _variants(text)
|
|
61
|
+
if variants:
|
|
62
|
+
rank = order.setdefault(key, len(order)) # equal distances keep list order
|
|
63
|
+
self._refs.append((key, text, variants, sound_range(text), rank))
|
|
64
|
+
|
|
65
|
+
def __len__(self) -> int:
|
|
66
|
+
return len(self._refs)
|
|
67
|
+
|
|
68
|
+
def best_match(self, heard: str, top: int = 3) -> list[Match]:
|
|
69
|
+
"""The `top` references closest to `heard`, nearest first; ties in list order."""
|
|
70
|
+
if top < 1:
|
|
71
|
+
raise ValueError("top must be at least 1")
|
|
72
|
+
heard_variants = _variants(heard)
|
|
73
|
+
if not heard_variants or not self._refs:
|
|
74
|
+
return []
|
|
75
|
+
lo, hi = sound_range(heard)
|
|
76
|
+
|
|
77
|
+
def gap(ref) -> float:
|
|
78
|
+
r_lo, r_hi = ref[3]
|
|
79
|
+
return max(0, r_lo - hi, lo - r_hi)
|
|
80
|
+
|
|
81
|
+
best: dict[Hashable, Match] = {}
|
|
82
|
+
ranked: list[tuple[float, int, Hashable]] = []
|
|
83
|
+
for ref in sorted(self._refs, key=gap):
|
|
84
|
+
key, text, variants, _, rank = ref
|
|
85
|
+
full = len(ranked) >= top
|
|
86
|
+
cutoff = ranked[-1][0] if full else INF
|
|
87
|
+
if full and _FLOOR * gap(ref) > cutoff + _EPS:
|
|
88
|
+
break
|
|
89
|
+
d = _variants_distance(heard_variants, variants, cutoff + _EPS)
|
|
90
|
+
if d > cutoff + _EPS:
|
|
91
|
+
continue
|
|
92
|
+
d = round(d, 6)
|
|
93
|
+
if key in best and best[key].distance <= d:
|
|
94
|
+
continue
|
|
95
|
+
best[key] = Match(key, text, d)
|
|
96
|
+
ranked = [e for e in ranked if e[2] != key]
|
|
97
|
+
insort(ranked, (d, rank, key))
|
|
98
|
+
del ranked[top:]
|
|
99
|
+
return [best[k] for _, _, k in ranked]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def best_match(heard: str, references: Iterable[Reference], top: int = 3) -> list[Match]:
|
|
103
|
+
"""
|
|
104
|
+
The `top` references closest to `heard`, nearest first.
|
|
105
|
+
|
|
106
|
+
A shortcut for `Matcher(references).best_match(heard, top)`. When matching
|
|
107
|
+
many utterances against one list, build the Matcher once.
|
|
108
|
+
"""
|
|
109
|
+
if top < 1:
|
|
110
|
+
raise ValueError("top must be at least 1")
|
|
111
|
+
return Matcher(references).best_match(heard, top)
|
misheard/py.typed
ADDED
|
File without changes
|
misheard/translit.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Native script -> phonetic Roman.
|
|
3
|
+
|
|
4
|
+
Speech-to-text returns Hindi and Marathi in Devanagari and Tamil in Tamil
|
|
5
|
+
script, but customer records are stored in Roman letters. Everything is
|
|
6
|
+
compared in one phonetic Roman space, so this module converts native script
|
|
7
|
+
into it.
|
|
8
|
+
|
|
9
|
+
The output is plain lowercase Roman with long vowels doubled (aa, ee, oo)
|
|
10
|
+
and retroflex consonants marked with a dot below (ṭ ḍ ṇ ḷ ṛ). The phonetic
|
|
11
|
+
matcher reads those marks; `plain()` strips them for anything that wants
|
|
12
|
+
ordinary letters.
|
|
13
|
+
|
|
14
|
+
Hindi needs schwa deletion: the inherent "a" written in every consonant is
|
|
15
|
+
often silent (मोहन is "mohan", not "mohana"; बाड़मेर is "baaṛmer"). Tamil has
|
|
16
|
+
no silent schwa but voices stops between vowels and after nasals
|
|
17
|
+
(முருகன் is "murugan", not "murukan").
|
|
18
|
+
"""
|
|
19
|
+
import re
|
|
20
|
+
import unicodedata
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------- Devanagari
|
|
23
|
+
|
|
24
|
+
_DEV_CONS = {
|
|
25
|
+
"क": "k", "ख": "kh", "ग": "g", "घ": "gh", "ङ": "n",
|
|
26
|
+
"च": "ch", "छ": "chh", "ज": "j", "झ": "jh", "ञ": "n",
|
|
27
|
+
"ट": "ṭ", "ठ": "ṭh", "ड": "ḍ", "ढ": "ḍh", "ण": "ṇ",
|
|
28
|
+
"त": "t", "थ": "th", "द": "d", "ध": "dh", "न": "n",
|
|
29
|
+
"प": "p", "फ": "ph", "ब": "b", "भ": "bh", "म": "m",
|
|
30
|
+
"य": "y", "र": "r", "ल": "l", "ळ": "ḷ", "व": "v",
|
|
31
|
+
"श": "sh", "ष": "sh", "स": "s", "ह": "h",
|
|
32
|
+
}
|
|
33
|
+
# consonant + nukta, as precomposed characters or base + U+093C
|
|
34
|
+
_DEV_NUKTA = {"क": "q", "ख": "kh", "ग": "g", "ज": "z", "ड": "ṛ", "ढ": "ṛh", "फ": "f", "य": "y", "र": "r"}
|
|
35
|
+
_DEV_VOWELS = {
|
|
36
|
+
"अ": "a", "आ": "aa", "इ": "i", "ई": "ee", "उ": "u", "ऊ": "oo", "ऋ": "ri",
|
|
37
|
+
"ए": "e", "ऐ": "ai", "ओ": "o", "औ": "au", "ऑ": "o", "ॲ": "e",
|
|
38
|
+
}
|
|
39
|
+
_DEV_MATRAS = {
|
|
40
|
+
"ा": "aa", "ि": "i", "ी": "ee", "ु": "u", "ू": "oo", "ृ": "ri",
|
|
41
|
+
"े": "e", "ै": "ai", "ो": "o", "ौ": "au", "ॉ": "o", "ॅ": "e",
|
|
42
|
+
}
|
|
43
|
+
_VIRAMA, _NUKTA, _ANUSVARA, _CHANDRABINDU, _VISARGA = "्", "़", "ं", "ँ", "ः"
|
|
44
|
+
_DEV_DIGITS = {chr(0x0966 + i): str(i) for i in range(10)}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _devanagari_word(word: str) -> str:
|
|
48
|
+
# Split the word into units: (consonant cluster, vowel, vowel_is_inherent).
|
|
49
|
+
word = unicodedata.normalize("NFD", word)
|
|
50
|
+
word = word.replace("ज्ञ", "ग्य") # ज्ञ is spoken gy in Hindi (ज्ञान = gyaan)
|
|
51
|
+
units: list[list] = []
|
|
52
|
+
cluster: list[str] = []
|
|
53
|
+
i = 0
|
|
54
|
+
while i < len(word):
|
|
55
|
+
ch = word[i]
|
|
56
|
+
nxt = word[i + 1] if i + 1 < len(word) else ""
|
|
57
|
+
if ch in _DEV_CONS:
|
|
58
|
+
sound = _DEV_NUKTA.get(ch, _DEV_CONS[ch]) if nxt == _NUKTA else _DEV_CONS[ch]
|
|
59
|
+
if nxt == _NUKTA:
|
|
60
|
+
i += 1
|
|
61
|
+
nxt = word[i + 1] if i + 1 < len(word) else ""
|
|
62
|
+
cluster.append(sound)
|
|
63
|
+
if nxt == _VIRAMA: # half consonant: joins the next one
|
|
64
|
+
i += 2
|
|
65
|
+
continue
|
|
66
|
+
if nxt in _DEV_MATRAS:
|
|
67
|
+
units.append([cluster, _DEV_MATRAS[nxt], False])
|
|
68
|
+
i += 2
|
|
69
|
+
else:
|
|
70
|
+
units.append([cluster, "a", True])
|
|
71
|
+
i += 1
|
|
72
|
+
cluster = []
|
|
73
|
+
continue
|
|
74
|
+
if ch in _DEV_VOWELS:
|
|
75
|
+
units.append([[], _DEV_VOWELS[ch], False])
|
|
76
|
+
elif ch == _ANUSVARA:
|
|
77
|
+
if units:
|
|
78
|
+
units[-1][1] += _NASAL # resolved below by the next consonant
|
|
79
|
+
elif ch == _CHANDRABINDU:
|
|
80
|
+
if units:
|
|
81
|
+
units[-1][1] += "n" # nasalised vowel
|
|
82
|
+
elif ch in _DEV_DIGITS:
|
|
83
|
+
units.append([[], _DEV_DIGITS[ch], False])
|
|
84
|
+
i += 1
|
|
85
|
+
if cluster: # word ends in a half consonant
|
|
86
|
+
units.append([cluster, "", False])
|
|
87
|
+
|
|
88
|
+
# Schwa deletion, right to left (Ohala's rule, simplified):
|
|
89
|
+
# - a final inherent "a" is dropped unless it follows a conjunct (मिश्र)
|
|
90
|
+
# - a medial inherent "a" is dropped in V C _ C V, i.e. when it sits on a
|
|
91
|
+
# single consonant, between two syllables that keep their vowels.
|
|
92
|
+
keep = [True] * len(units)
|
|
93
|
+
for k in range(len(units) - 1, 0, -1):
|
|
94
|
+
cons, vowel, inherent = units[k]
|
|
95
|
+
if not inherent:
|
|
96
|
+
continue
|
|
97
|
+
if k == len(units) - 1:
|
|
98
|
+
if len(cons) == 1:
|
|
99
|
+
keep[k] = False
|
|
100
|
+
continue
|
|
101
|
+
nxt_cons, nxt_vowel, _ = units[k + 1]
|
|
102
|
+
if len(cons) == 1 and len(nxt_cons) == 1 and keep[k + 1] and keep[k - 1] and nxt_vowel:
|
|
103
|
+
keep[k] = False
|
|
104
|
+
|
|
105
|
+
out = []
|
|
106
|
+
for (cons, vowel, inherent), kept in zip(units, keep):
|
|
107
|
+
out.append("".join(cons))
|
|
108
|
+
if kept or not inherent:
|
|
109
|
+
out.append(vowel)
|
|
110
|
+
elif vowel.endswith(("n", _NASAL)) and vowel != "a":
|
|
111
|
+
out.append(vowel[-1])
|
|
112
|
+
return _resolve_anusvara("".join(out))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_NASAL = "\x00"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _resolve_anusvara(word: str) -> str:
|
|
119
|
+
# Anusvara takes the place of the consonant after it: m before labials
|
|
120
|
+
# (चंपा = champa), ng before h (सिंह = singh), n otherwise (संत = sant;
|
|
121
|
+
# before k and g that already spells ng: गंगा = ganga).
|
|
122
|
+
word = re.sub(_NASAL + r"(?=[pbm])", "m", word)
|
|
123
|
+
word = re.sub(_NASAL + r"(?=h)", "ng", word)
|
|
124
|
+
return word.replace(_NASAL, "n")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------- Tamil
|
|
128
|
+
|
|
129
|
+
_TA_CONS = {
|
|
130
|
+
"க": "k", "ங": "ng", "ச": "ch", "ஞ": "nj", "ட": "ṭ", "ண": "ṇ",
|
|
131
|
+
"த": "t", "ந": "n", "ப": "p", "ம": "m", "ய": "y", "ர": "r",
|
|
132
|
+
"ல": "l", "வ": "v", "ழ": "zh", "ள": "ḷ", "ற": "r", "ன": "n",
|
|
133
|
+
"ஜ": "j", "ஷ": "sh", "ஸ": "s", "ஹ": "h",
|
|
134
|
+
}
|
|
135
|
+
_TA_VOWELS = {
|
|
136
|
+
"அ": "a", "ஆ": "aa", "இ": "i", "ஈ": "ee", "உ": "u", "ஊ": "oo",
|
|
137
|
+
"எ": "e", "ஏ": "e", "ஐ": "ai", "ஒ": "o", "ஓ": "o", "ஔ": "au",
|
|
138
|
+
}
|
|
139
|
+
_TA_MATRAS = {
|
|
140
|
+
"ா": "aa", "ி": "i", "ீ": "ee", "ு": "u", "ூ": "oo",
|
|
141
|
+
"ெ": "e", "ே": "e", "ை": "ai", "ொ": "o", "ோ": "o", "ௌ": "au",
|
|
142
|
+
}
|
|
143
|
+
_TA_PULLI = "்"
|
|
144
|
+
_TA_VOICED = {"k": "g", "t": "d", "ṭ": "ḍ", "p": "b", "ch": "s"}
|
|
145
|
+
_NASALS = {"ng", "nj", "ṇ", "n", "m"}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _tamil_word(word: str) -> str:
|
|
149
|
+
# Tamil writes vowel signs (ொ ோ ௌ) as two code points in some input;
|
|
150
|
+
# NFC folds them back into one.
|
|
151
|
+
word = unicodedata.normalize("NFC", word)
|
|
152
|
+
word = word.replace("ட்ச", "க்ஷ") # Tamil spells क्ष as ட்ச (லட்சுமி = Lakshmi)
|
|
153
|
+
sounds: list[tuple[str, str]] = [] # (consonant or "", vowel or "")
|
|
154
|
+
i = 0
|
|
155
|
+
while i < len(word):
|
|
156
|
+
ch = word[i]
|
|
157
|
+
nxt = word[i + 1] if i + 1 < len(word) else ""
|
|
158
|
+
if ch in _TA_CONS:
|
|
159
|
+
if nxt == _TA_PULLI:
|
|
160
|
+
sounds.append((_TA_CONS[ch], ""))
|
|
161
|
+
i += 2
|
|
162
|
+
elif nxt in _TA_MATRAS:
|
|
163
|
+
sounds.append((_TA_CONS[ch], _TA_MATRAS[nxt]))
|
|
164
|
+
i += 2
|
|
165
|
+
else:
|
|
166
|
+
sounds.append((_TA_CONS[ch], "a"))
|
|
167
|
+
i += 1
|
|
168
|
+
continue
|
|
169
|
+
if ch in _TA_VOWELS:
|
|
170
|
+
sounds.append(("", _TA_VOWELS[ch]))
|
|
171
|
+
i += 1
|
|
172
|
+
|
|
173
|
+
out = []
|
|
174
|
+
for idx, (cons, vowel) in enumerate(sounds):
|
|
175
|
+
prev = sounds[idx - 1] if idx else ("", "")
|
|
176
|
+
nxt = sounds[idx + 1] if idx + 1 < len(sounds) else ("", "")
|
|
177
|
+
if cons in _TA_VOICED:
|
|
178
|
+
after_vowel = idx > 0 and prev[1] != ""
|
|
179
|
+
after_nasal = idx > 0 and prev[0] in _NASALS and prev[1] == ""
|
|
180
|
+
geminate = (prev[0] == cons and prev[1] == "") or (vowel == "" and nxt[0] == cons)
|
|
181
|
+
if idx == 0 and cons == "ch":
|
|
182
|
+
cons = "s" # சரவணன் -> saravanan
|
|
183
|
+
elif vowel and not geminate and (after_vowel or after_nasal):
|
|
184
|
+
# a single stop between vowels or after a nasal is voiced
|
|
185
|
+
cons = "j" if (cons == "ch" and after_nasal) else _TA_VOICED[cons]
|
|
186
|
+
if cons in ("ng", "nj") and not vowel and nxt[0]:
|
|
187
|
+
cons = "n" # ங்க = ng, ஞ்ச = nj: the next letter carries g/j
|
|
188
|
+
out.append(cons + vowel)
|
|
189
|
+
return "".join(out)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------- public API
|
|
193
|
+
|
|
194
|
+
_DEVANAGARI = re.compile(r"[ऀ-ॿ]")
|
|
195
|
+
_TAMIL = re.compile(r"[-]")
|
|
196
|
+
_SUPPORTED = {"LATIN", "DEVANAGARI", "TAMIL"}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class UnsupportedScriptError(ValueError):
|
|
200
|
+
"""Text contains letters from a script this library can't read yet."""
|
|
201
|
+
|
|
202
|
+
def __init__(self, script: str, text: str):
|
|
203
|
+
self.script = script
|
|
204
|
+
super().__init__(
|
|
205
|
+
f"{script.title()} script is not supported yet (in {text!r}). "
|
|
206
|
+
"Supported: Devanagari, Tamil, Latin."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _foreign_script(word: str) -> str | None:
|
|
211
|
+
for ch in word:
|
|
212
|
+
if unicodedata.category(ch).startswith(("L", "M")):
|
|
213
|
+
script = unicodedata.name(ch, "UNKNOWN").split()[0]
|
|
214
|
+
if script not in _SUPPORTED and script != "COMBINING":
|
|
215
|
+
return script
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def script_of(text: str) -> str:
|
|
220
|
+
""""devanagari", "tamil" or "latin"; for other scripts their lowercase name ("telugu")."""
|
|
221
|
+
if _DEVANAGARI.search(text):
|
|
222
|
+
return "devanagari"
|
|
223
|
+
if _TAMIL.search(text):
|
|
224
|
+
return "tamil"
|
|
225
|
+
foreign = _foreign_script(text)
|
|
226
|
+
return foreign.lower() if foreign else "latin"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def to_roman(text: str) -> str:
|
|
230
|
+
"""
|
|
231
|
+
Convert any mix of Devanagari, Tamil and Latin to phonetic Roman.
|
|
232
|
+
|
|
233
|
+
Raises UnsupportedScriptError for other scripts (Telugu, Bengali, …)
|
|
234
|
+
rather than silently dropping them.
|
|
235
|
+
"""
|
|
236
|
+
words = []
|
|
237
|
+
for word in text.split():
|
|
238
|
+
foreign = _foreign_script(word)
|
|
239
|
+
if foreign:
|
|
240
|
+
raise UnsupportedScriptError(foreign, word)
|
|
241
|
+
script = script_of(word)
|
|
242
|
+
if script == "devanagari":
|
|
243
|
+
words.append(_devanagari_word(word))
|
|
244
|
+
elif script == "tamil":
|
|
245
|
+
words.append(_tamil_word(word))
|
|
246
|
+
else:
|
|
247
|
+
words.append(word.lower())
|
|
248
|
+
return " ".join(w for w in words if w)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def plain(text: str) -> str:
|
|
252
|
+
"""Drop the retroflex marks: 'baaṛmer' -> 'baarmer'."""
|
|
253
|
+
decomposed = unicodedata.normalize("NFD", text)
|
|
254
|
+
return "".join(c for c in decomposed if not unicodedata.combining(c))
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: misheard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Match Indian names and places by how they sound, across Devanagari, Tamil and Roman script
|
|
5
|
+
Project-URL: Homepage, https://github.com/trivedi-aman/misheard
|
|
6
|
+
Project-URL: Changelog, https://github.com/trivedi-aman/misheard/blob/main/CHANGELOG.md
|
|
7
|
+
Project-URL: Roadmap, https://github.com/trivedi-aman/misheard/blob/main/PLAN.md
|
|
8
|
+
Project-URL: Issues, https://github.com/trivedi-aman/misheard/issues
|
|
9
|
+
Author-email: Aman Trivedi <amantrivedi2003@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: fuzzy matching,hindi,indic,names,phonetic,speech,tamil,transliteration
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Natural Language :: Hindi
|
|
16
|
+
Classifier: Natural Language :: Marathi
|
|
17
|
+
Classifier: Natural Language :: Tamil
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Provides-Extra: bench
|
|
27
|
+
Requires-Dist: jellyfish>=1; extra == 'bench'
|
|
28
|
+
Requires-Dist: rapidfuzz>=3; extra == 'bench'
|
|
29
|
+
Provides-Extra: demo
|
|
30
|
+
Requires-Dist: gradio>=6; extra == 'demo'
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# misheard
|
|
36
|
+
|
|
37
|
+
Match Indian names and places by how they **sound**, not how they are spelled, across Devanagari, Tamil and Roman script. Pure Python, no dependencies.
|
|
38
|
+
|
|
39
|
+
*Formerly published as [`indic-phonetic`](https://pypi.org/project/indic-phonetic/).*
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from misheard import distance, best_match, to_roman
|
|
43
|
+
|
|
44
|
+
to_roman("बाड़मेर") # 'baaṛmer'
|
|
45
|
+
to_roman("முருகன்") # 'murugan'
|
|
46
|
+
|
|
47
|
+
distance("Barmer", "बाड़मेर") # 0.4 same place
|
|
48
|
+
distance("Barmer", "Baadmer") # 0.6 same place, different spelling
|
|
49
|
+
distance("Mohan", "Rohan") # 1.7 different people
|
|
50
|
+
|
|
51
|
+
best_match("Rohun Varma", [("C1", "Rohan Verma"), ("C2", "Mohan Verma"), ("C3", "Sohan Verma")])
|
|
52
|
+
# [Match(key='C1', text='Rohan Verma', distance=1.7),
|
|
53
|
+
# Match(key='C3', text='Sohan Verma', distance=2.85),
|
|
54
|
+
# Match(key='C2', text='Mohan Verma', distance=3.4)]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install misheard
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
> **Status: alpha (0.1).** The API may still change before 1.0. Known gaps and the roadmap are in [PLAN.md](https://github.com/trivedi-aman/misheard/blob/main/PLAN.md).
|
|
62
|
+
|
|
63
|
+
## Try it
|
|
64
|
+
|
|
65
|
+
From a clone of the repository:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install -e '.[demo]'
|
|
69
|
+
python demo/app.py # opens http://127.0.0.1:7860
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Type a name or place as it was heard, in any supported script, and see the closest matches from Indian districts or a sample list of people, why the top one matched, and what a voice agent might do with it (accept, read back, ask again). A second tab compares two names step by step.
|
|
73
|
+
|
|
74
|
+
## Why
|
|
75
|
+
|
|
76
|
+
Speech-to-text and hand-typed records spell Indian names in many ways: *Barmer / Baadmer / बाड़मेर*, *Lakshmi / Laxmi / லட்சுமி*, *Vikram / Wikram*. Edit distance counts every letter change as equally bad, so it is wrong in both directions:
|
|
77
|
+
|
|
78
|
+
- **Barmer** and **Baadmer** are the same place: a long vowel plus the retroflex flap ड़ spelled two ways. That should be cheap.
|
|
79
|
+
- **Mohan** and **Rohan** are different people: *m* and *r* sound nothing alike, even though only one letter differs. That should be expensive.
|
|
80
|
+
|
|
81
|
+
English phonetic codes (Soundex, Metaphone) follow English spelling rules and don't know about retroflex consonants, aspiration or the silent *a* in Hindi.
|
|
82
|
+
|
|
83
|
+
## How it works
|
|
84
|
+
|
|
85
|
+
1. **Transliterate** Devanagari and Tamil into one phonetic Roman form. Hindi drops the silent *a* (schwa deletion: मोहन → *mohan*). Tamil voices stops between vowels (முருகன் → *murugan*). Retroflex sounds stay marked (ṭ ḍ ṇ ḷ ṛ).
|
|
86
|
+
2. **Tokenize** into sounds, folding English-style spellings first (*x→ks*, *-ey→e*, doubled letters).
|
|
87
|
+
3. **Score substitutions by articulatory features**: place and manner of articulation, voicing and aspiration for consonants; height, backness and length for vowels. A swap between similar sounds is cheap, a swap between unrelated sounds is expensive.
|
|
88
|
+
4. **Override with known confusions** ([`data/confusions.csv`](https://github.com/trivedi-aman/misheard/blob/main/src/misheard/data/confusions.csv)): v/w, t/ṭ, ph/f, Tamil k/g, and so on. Each row says why it's there.
|
|
89
|
+
5. **Weighted edit distance** over the sounds. Dropping a silent *a* or an *h* costs little.
|
|
90
|
+
6. **Words as people say them.** Titles and honorifics are ignored (*Shri*, *Smt*, *Dr*, *ji*, *sahab*). A joined honorific (*Rameshbhai*) costs 0.1 to drop, and *Devi* or *Kumar* cost 0.5. An initial matches a word with the same first letter (*R. Kumar* / *Ramesh Kumar* = 0.3). Words may come in any order (*Kumar Ramesh Singh*) for 0.2.
|
|
91
|
+
|
|
92
|
+
`best_match` skips references whose length alone rules them out. It gives the same result as scoring every reference, with far fewer comparisons.
|
|
93
|
+
|
|
94
|
+
## API
|
|
95
|
+
|
|
96
|
+
| | |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| `distance(a, b) -> float` | Phonetic distance between two names, in any mix of scripts. 0 means the same sounds. |
|
|
99
|
+
| `Matcher(references)` | Prepare a reference list once. References are strings or `(key, text)` pairs; keys group aliases (*Bangalore*, *Bengaluru*) under one result. |
|
|
100
|
+
| `Matcher.best_match(heard, top=3) -> list[Match]` | Closest references, nearest first; equal distances keep list order. |
|
|
101
|
+
| `best_match(heard, references, top=3)` | Shortcut for a one-off list. |
|
|
102
|
+
| `explain(a, b) -> Explanation` | The steps behind a distance: each sound kept, swapped, added or dropped, with the reason from the confusion table, plus titles ignored, words left out, initials and reordering. `print()` it for a readable table. |
|
|
103
|
+
| `to_roman(text) -> str` | Devanagari / Tamil / Latin → phonetic Roman. |
|
|
104
|
+
| `plain(text) -> str` | Remove the retroflex marks: *baaṛmer* → *baarmer*. |
|
|
105
|
+
| `script_of(text) -> str` | `"devanagari"`, `"tamil"` or `"latin"`. |
|
|
106
|
+
| `tokenize(word) -> tuple[str, ...]` | The sounds the distance is computed over. |
|
|
107
|
+
|
|
108
|
+
The library does not decide whether a match is good enough, because that threshold depends on your application. A voice agent might accept a match below about 0.8 when it is at least 1.0 ahead of the next candidate, read back anything below 1.8, and ask again otherwise. Those values come from the project this library was extracted from; tune them on your own data.
|
|
109
|
+
|
|
110
|
+
## Benchmark
|
|
111
|
+
|
|
112
|
+
782 items: 300 synthetic people, and 745 Indian districts plus aliases. Run it with `python benchmarks/run.py`. Full results are in [benchmarks/results/latest.md](https://github.com/trivedi-aman/misheard/blob/main/benchmarks/results/latest.md).
|
|
113
|
+
|
|
114
|
+
| Source | Kind | Items | Levenshtein top-1 | Metaphone top-1 | misheard top-1 |
|
|
115
|
+
| --- | --- | --- | --- | --- | --- |
|
|
116
|
+
| handwritten | name | 62 | 96.8% | 96.8% | 100.0% |
|
|
117
|
+
| handwritten | place | 50 | 92.0% | 74.0% | 100.0% |
|
|
118
|
+
| native script | name | 254 | 97.6% | 97.6% | 100.0% |
|
|
119
|
+
| rule-generated | name | 246 | 99.6% | 96.3% | 100.0% |
|
|
120
|
+
| rule-generated | place | 170 | 97.6% | 90.0% | 100.0% |
|
|
121
|
+
|
|
122
|
+
**Read these numbers with care.** None of the items are real speech-to-text output yet. The rule-generated variants use the same confusions the engine models, and the handwritten ones were written by the author. Building a benchmark from data the engine wasn't designed around is the top item in [PLAN.md](https://github.com/trivedi-aman/misheard/blob/main/PLAN.md). Speed is still behind: about 5 ms per lookup against 780 places with a `Matcher`, compared with 0.1 ms for Levenshtein. A compiled core is planned.
|
|
123
|
+
|
|
124
|
+
## Scope
|
|
125
|
+
|
|
126
|
+
- **Scripts:** Devanagari (Hindi, Marathi, …), Tamil, Latin. Other scripts are not supported yet and raise `UnsupportedScriptError`.
|
|
127
|
+
- **Made for:** names of people and places, short spoken phrases. It is not a general transliterator: use [indic-transliteration](https://pypi.org/project/indic-transliteration/) or [IndicXlit](https://github.com/AI4Bharat/IndicXlit) for running text.
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
python -m venv .venv && source .venv/bin/activate
|
|
133
|
+
pip install -e '.[dev,bench]'
|
|
134
|
+
pytest # tests marked xfail are known gaps from PLAN.md
|
|
135
|
+
python benchmarks/run.py
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT. The district list in `benchmarks/data` is derived from India Post's public pincode directory. All people in `benchmarks/data/people.csv` are synthetic.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
misheard/__init__.py,sha256=asqLUr0BtoJLTHx757niznzooeo2S4HW64yGQI8DL0w,728
|
|
2
|
+
misheard/distance.py,sha256=LITtM4MqsVvsvrFc7j43s3AR-gPqZasuDe4kdIeDB1A,14857
|
|
3
|
+
misheard/explain.py,sha256=xdx1ge7krh8RIJf1XEnCcAdZszfq0sDWBPU2cZJqA1s,6472
|
|
4
|
+
misheard/match.py,sha256=wpvcTsGWtVzqxQZf6Lqma2l03pDZA0svZoyaUHihVfE,4503
|
|
5
|
+
misheard/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
misheard/translit.py,sha256=RYNcShbOu7O-99f9n_2k1lNq7i7xGHkTEdr2L3VSXZE,10208
|
|
7
|
+
misheard/data/confusions.csv,sha256=nVffeSt4Q4X38qtL1rNAeGi417BOoi6bzspw1FUqIqQ,1062
|
|
8
|
+
misheard-0.1.0.dist-info/METADATA,sha256=MA-zuVmz8MuNgvjTIsfE6zqpmf9u-95LG3YnKZM72Qs,8578
|
|
9
|
+
misheard-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
misheard-0.1.0.dist-info/licenses/LICENSE,sha256=d9CMrNZPd8-E7_fJnCy2BnjqPf0pjpy7OHItMmfb_LE,1069
|
|
11
|
+
misheard-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aman Trivedi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|