File size: 12,200 Bytes
68f10e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Text prep for the Prathosh voice fine-tune.
Two outputs per verse:
  - model_text : Kannada-routed (champion path), daṇḍa/number-stripped, NO phonetic conversion.
  - mfa_text   : phonetic Devanagari for MFA alignment — visarga sandhi (jihvāmūlīya/upadhmānīya/
                 sibilant-gemination, shloka-final visarga preserved) + anusvāra→homorganic nasal.
All processing is done in Devanagari (Kannada sources transliterated in first).
"""
import re
from indic_transliteration import sanscript

VIRAMA = "्"        # ्
VISARGA = "ः"       # ः
ANUSVARA = "ं"      # ं
JIHVA = "ᳵ"         # ᳵ  jihvāmūlīya
UPADH = "ᳶ"         # ᳶ  upadhmānīya

KA_V = set("कखगघङ"); CA_V = set("चछजझञ"); TTA_V = set("टठडढण")
TA_V = set("तथदधन");  PA_V = set("पफबभम")
STOP_NASAL = {**{c:"ङ" for c in KA_V}, **{c:"ञ" for c in CA_V}, **{c:"ण" for c in TTA_V},
              **{c:"न" for c in TA_V}, **{c:"म" for c in PA_V}}
K_UNVOICED = set("कख"); P_UNVOICED = set("पफ")
# visarga → sibilant+halant assimilation (classical, by following stop/sibilant):
#   → स्  before स/त/थ   |   → श्  before श/च/छ   |   → ष्  before ष/ट/ठ
VIS_SIB = {**{c:"स" for c in "सतथ"}, **{c:"श" for c in "शचछ"}, **{c:"ष" for c in "षटठ"}}
PUNCT_DROP = set("।॥|/\\—–\"'“”‘’„«»‹›*•·().,;!?‌‍")   # daṇḍas, pipe/slash, quotes, parens, ZWJ/ZWNJ
SKIP = set(" \t\n-") | PUNCT_DROP | set("0123456789०१२३४५६७८९")

# Unicode block -> sanscript scheme, so a shloka in ANY Brahmic script is accepted: it is detected
# here and transliterated to Devanagari in to_deva(), after which the whole pipeline (which works in
# Devanagari) is unchanged. First in-block char wins. Roman input (IAST/ITRANS/HK) is NOT auto-detected
# — pass it pre-transliterated. (Tamil lacks distinct Sanskrit varga letters, so Tamil-script Sanskrit
# is inherently lossy; Grantha is the faithful Tamil-region script for Sanskrit and IS supported.)
_SCRIPT_BLOCKS = [
    (0x0900, 0x097F, sanscript.DEVANAGARI),
    (0x0980, 0x09FF, sanscript.BENGALI),
    (0x0A00, 0x0A7F, sanscript.GURMUKHI),
    (0x0A80, 0x0AFF, sanscript.GUJARATI),
    (0x0B00, 0x0B7F, sanscript.ORIYA),
    (0x0B80, 0x0BFF, sanscript.TAMIL),
    (0x0C00, 0x0C7F, sanscript.TELUGU),
    (0x0C80, 0x0CFF, sanscript.KANNADA),
    (0x0D00, 0x0D7F, sanscript.MALAYALAM),
    (0x11300, 0x1137F, sanscript.GRANTHA),
]
def detect_script(t):
    for c in t:
        o = ord(c)
        for lo, hi, scheme in _SCRIPT_BLOCKS:
            if lo <= o <= hi:
                return scheme
    return sanscript.DEVANAGARI

def to_deva(t):
    src = detect_script(t)
    return t if src == sanscript.DEVANAGARI else sanscript.transliterate(t, src, sanscript.DEVANAGARI)

def fix_colon(deva):
    """Stray Latin colon used as visarga: 'गुरु:-' / 'गुरु:' → 'गुरुः'."""
    deva = deva.replace(":-", VISARGA)
    return deva.replace(":", VISARGA)

def strip_punct(deva):
    """Colon→visarga, remove daṇḍas/pipes/slashes/quotes/digits, hyphen→space; avagraha & ॐ kept."""
    deva = fix_colon(deva)
    out = []
    for c in deva:
        if c in PUNCT_DROP or c.isdigit() or ("०" <= c <= "९") or c in "-–—":
            continue                         # hyphen → JOIN (compounds must stay continuous; space breaks alignment)
        out.append(c)
    return re.sub(r"\s+", " ", "".join(out)).strip()

def _next_real(s, i):
    """Index of next non-skip char after position i, or None."""
    j = i + 1
    while j < len(s) and s[j] in SKIP:
        j += 1
    return j if j < len(s) else None

def phonetic_mfa(deva, kannada_safe=False):
    """Apply visarga + anusvāra conversions on a daṇḍa/number-stripped Devanagari string.
    kannada_safe=True keeps plain ः before k/p (skips jihvāmūlīya ᳵ / upadhmānīya ᳶ, which are
    out-of-vocab for the Kannada-routed IndicF5) — used for the A/B 'normalized' arm."""
    s = strip_punct(deva)
    # locate the shloka-final visarga (last visarga with no real char after it) -> preserve
    last_vis_final = None
    for i, c in enumerate(s):
        if c == VISARGA and _next_real(s, i) is None:
            last_vis_final = i
    out = []
    for i, c in enumerate(s):
        if c == VISARGA:
            if i == last_vis_final:           # shloka-final → keep ः
                out.append(VISARGA); continue
            j = _next_real(s, i)
            nxt = s[j] if j is not None else None
            if nxt in K_UNVOICED:   out.append(VISARGA if kannada_safe else JIHVA)
            elif nxt in P_UNVOICED: out.append(VISARGA if kannada_safe else UPADH)
            elif nxt in VIS_SIB:    out.append(VIS_SIB[nxt] + VIRAMA)   # s/ś/ṣ/c/ch/ṭ/ṭh/t/th
            else:                   out.append(VISARGA)     # voiced/vowel/semivowel/h → leave
        elif c == ANUSVARA:
            j = _next_real(s, i)
            nxt = s[j] if j is not None else None
            if nxt in STOP_NASAL:   out.append(STOP_NASAL[nxt] + VIRAMA)
            else:                   out.append(ANUSVARA)    # before sibilant/semivowel/h/end → keep
        else:
            out.append(c)
    return "".join(out)

def model_text(src_text):
    """PLAIN champion path (A/B Arm A): strip punct, transliterate Deva→Kannada, NO sandhi.
    This is exactly what the 4.6-MOS pilot_reciter/Prathosh champions trained on — visarga ः / anusvāra ं
    kept plain (both in IndicF5 vocab); the model learns jihvāmūlīya/upadhmānīya/homorganic acoustically."""
    slp = sanscript.transliterate(strip_punct(to_deva(src_text)),
                                  sanscript.DEVANAGARI, sanscript.SLP1)
    slp = slp.replace("F", "rU")   # long vocalic ṝ (ॄ/ॠ) → repha+ū: IndicF5 mispronounces Kannada ೄ (U+0CC4). Fix at SLP1 so tF→trU→ತ್ರೂ (2026-06-22)
    return sanscript.transliterate(slp, sanscript.SLP1, sanscript.KANNADA)

# ── word-boundary visarga sandhi (SLP1) ──────────────────────────────────────────────
_VS_VOICED = set("gGjJqQdDbBNYRnmyrlvh"); _VS_OTHERV = set("iIuUfFxXeEoO")
_VS_ALLV = set("aAiIuUfFxXeEoO"); _VS_LEN = {"a":"A","i":"I","u":"U","f":"F","A":"A","I":"I","U":"U"}
# satva (ḥ→ś/ṣ/s before c/ṭ/t & sibilants) and jihvāmūlīya/upadhmānīya (ḥ before k/kh/p/ph) are
# DELIBERATELY NOT applied — the training texts left these as PLAIN ः and the model learned them
# acoustically (A/B 2026-06-15: plain > resolved for satva). Only utva/rutva/lopa are applied.

def visarga_sandhi(slp):
    """Word-boundary visarga sandhi — utva/rutva/lopa ONLY (the sandhi that WAS resolved in the
    training texts). On a space-separated SLP1 string:
      1 utva : aH + a → o ' (avagraha) ; aH + voiced-cons → o
      2 rutva: (i/u/e/o…)H + vowel/voiced-cons → r
      3 lopa : āH + vowel/voiced → ā ; aH + (vowel≠a) → a ; saḥ/eṣaḥ + (≠a) → sa/eṣa ; H + r → drop + lengthen
    ḥ before any UNVOICED consonant or sibilant (satva / jihvāmūlīya / upadhmānīya contexts) → KEPT PLAIN.
    Segment-final visarga preserved (echo handled separately)."""
    ws = slp.split(" "); i = 0; out = []
    while i < len(ws):
        w = ws[i]
        if w.endswith("H") and i < len(ws) - 1 and len(w) >= 2:
            V = w[-2]; base = w[:-1]; nxt = ws[i + 1]; F = nxt[0] if nxt else ""
            if F == "r":                                       out.append(base[:-1] + _VS_LEN.get(V, V)); i += 1; continue   # H+r: drop+lengthen
            if w in ("saH", "ezaH") and F != "a":              out.append(base); i += 1; continue                            # saḥ/eṣaḥ
            if F not in _VS_ALLV and F not in _VS_VOICED:      out.append(w); i += 1; continue                              # satva/sibilant/k/p → KEEP plain
            if V == "a":
                if F == "a":                                   out.append(base[:-1] + "o"); ws[i + 1] = "'" + nxt[1:]; i += 1; continue  # utva aH+a
                if F in _VS_VOICED:                             out.append(base[:-1] + "o"); i += 1; continue               # utva aH+voiced
                out.append(base); i += 1; continue                                                                         # lopa aH+vowel
            if V == "A":                                       out.append(base); i += 1; continue                          # lopa āH
            if V in _VS_OTHERV:                                 out.append(base + "r"); i += 1; continue                    # rutva
            out.append(w); i += 1
        else:
            out.append(w); i += 1
    return " ".join(out)

_VS_VOWELS = "aAiIuUfFxXeEoO"
def visarga_echo_final(slp):
    """Chant echo-vowel for the segment-final visarga: ḥ → h + the preceding vowel.
    rāmaḥ→rāmaha, śrīpatiḥ→śrīpatihi, guruḥ→guruhu, …aiḥ(E)→…aihai. Only the LAST word's
    visarga (the chant pause) — internal/boundary visargas are handled by visarga_sandhi."""
    ws = slp.split(" ")
    if ws and ws[-1].endswith("H") and len(ws[-1]) >= 2 and ws[-1][-2] in _VS_VOWELS:
        ws[-1] = ws[-1][:-1] + "h" + ws[-1][-2]
    return " ".join(ws)

def model_text_sandhi(src_text, echo_final=True):
    """PRODUCTION normalizer: strip punct → Deva→SLP1 → visarga sandhi (utva/rutva/lopa; satva &
    jihvāmūlīya/upadhmānīya left PLAIN — the model learned those acoustically) → echo-vowel on the
    segment-final visarga (ḥ→ha/hi/hu/hai…) → SLP1→Kannada. Normalizes utva/rutva for inputs that
    lack them (matching the training texts) + fixes the clip-final visarga garble. Per render-unit."""
    slp = sanscript.transliterate(strip_punct(to_deva(src_text)), sanscript.DEVANAGARI, sanscript.SLP1)
    slp = visarga_sandhi(slp)
    if echo_final:
        slp = visarga_echo_final(slp)
    slp = slp.replace("F", "rU")   # long vocalic ṝ (ॄ/ॠ) → repha+ū: IndicF5 mispronounces Kannada ೄ (U+0CC4). Fix at SLP1 so tF→trU→ತ್ರೂ (2026-06-22) (incl. sandhi-generated F)
    return sanscript.transliterate(slp, sanscript.SLP1, sanscript.KANNADA)

def model_text_norm(src_text):
    """Kannada-safe NORMALIZED path (A/B Arm B): E48 sandhi minus jihvāmūlīya/upadhmānīya
    (plain ः kept before k/p, since ೱ/ೲ are OOV). Applies anusvāra→homorganic nasal +
    visarga→sibilant gemination, shloka-final ः preserved. All output chars are in the Kannada vocab."""
    return sanscript.transliterate(phonetic_mfa(to_deva(src_text), kannada_safe=True),
                                   sanscript.DEVANAGARI, sanscript.KANNADA)

def mfa_text(src_text):
    """Phonetic Devanagari (visarga/anusvāra conversions) — annotation for a future phonetic model."""
    return phonetic_mfa(to_deva(src_text))

def align_slp1(src_text):
    """Plain SLP1 for MFA forced-alignment (model-native convention: visarga=H, anusvāra=M, no
    phonetic conversion). Avagraha dropped (not a phone). Words space-separated."""
    slp = sanscript.transliterate(strip_punct(to_deva(src_text)),
                                  sanscript.DEVANAGARI, sanscript.SLP1)
    slp = slp.replace("'", "").replace("’", "")          # avagraha → drop
    slp = slp.replace("L", "l").replace("|", "")          # ḻ (retroflex l) → l for the model's phone set
    slp = slp.replace("F", "rU")          # long vocalic ṝ (ॄ/ॠ) → repha+ū: IndicF5 mispronounces Kannada ೄ (U+0CC4). Fix at SLP1 so tF→trU→ತ್ರೂ (2026-06-22) — keep MFA text == audio
    return re.sub(r"\s+", " ", slp).strip()

# phones MFA/the acoustic model knows (SLP1 inventory); every align_slp1 char must be one of these
PHONES = set("aAiIuUfFxXeEoO kKgGN cCjJY wWqQR tTdDn pPbBm yrlv Szs h M H ~".split()) | set(
    "aAiIuUfFxXeEoOkKgGNcCjJYwWqQRtTdDnpPbBmyrlvSzshMH~")

def word_phones(word):
    """SLP1 word → space-joined phone list (SLP1 is phonemic: 1 char = 1 phone)."""
    return " ".join(ch for ch in word if ch in PHONES)