Spaces:
Running
Running
| """Gradio web UI for IPA Translator.""" | |
| from typing import Optional | |
| import gradio as gr | |
| from core import Phonemlyze, AnalysisResult | |
| # ββ Lazy singleton βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _engine: Optional[Phonemlyze] = None | |
| WHISPER_SIZES = ["tiny", "base", "small", "medium", "large"] | |
| LANGUAGES = { | |
| "Arabic": "ar", | |
| "Czech": "cs", | |
| "Dutch": "nl", | |
| "English (UK)": "en-gb", | |
| "English (US)": "en-us", | |
| "French (Canadian)": "fr-ca", | |
| "French (Parisian)": "fr", | |
| "German": "de", | |
| "Greek": "el", | |
| "Hindi": "hi", | |
| "Hungarian": "hu", | |
| "Italian": "it", | |
| "Japanese": "ja", | |
| "Mandarin (Simplified)": "zh", | |
| "Mandarin (Traditional)": "zh-tw", | |
| "Portuguese (Brazilian)": "pt-br", | |
| "Russian": "ru", | |
| "Spanish (Castilian)": "es-es", | |
| "Spanish (Latin American)": "es-419", | |
| "Thai": "th", | |
| "Turkish": "tr", | |
| "Ukrainian": "uk", | |
| } | |
| def get_engine(whisper_size: str) -> Phonemlyze: | |
| global _engine | |
| if _engine is None: | |
| _engine = Phonemlyze(whisper_model=whisper_size) | |
| return _engine | |
| # ββ Colour palette (dark-first) βββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Every custom HTML component uses these so the whole app is consistently dark. | |
| _C_BG = "#111827" # page / deepest background | |
| _C_CARD = "#1e2535" # card / IPA box background | |
| _C_RAISED = "#2d3748" # raised elements (keys, input bg, table header) | |
| _C_BORDER = "#4b5563" # borders / dividers | |
| _C_TEXT = "#f1f5f9" # primary text | |
| _C_TEXT2 = "#cbd5e1" # secondary text | |
| _C_MUTED = "#94a3b8" # muted / label text | |
| # Alignment status colours β muted dark backgrounds, bright foreground text | |
| STATUS_COLORS = { | |
| "match": ("#0a3320", "#6ee7b7"), # dark green / mint | |
| "replace": ("#4a2000", "#fbbf24"), # dark amber / gold | |
| "delete": ("#4a0e0e", "#fca5a5"), # dark red / rose | |
| "insert": ("#0e2744", "#93c5fd"), # dark blue / sky | |
| } | |
| # ββ HTML rendering βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| STATUS_LABELS = { | |
| "match": "β match", | |
| "replace": "β substitution", | |
| "delete": "β missing", | |
| "insert": "+ extra", | |
| } | |
| # IPA reference: symbol β {desc, ex (examples), word (spoken on click)} | |
| # "ex" uses plain-English spelling hints so non-linguists can map the sound. | |
| IPA_REFERENCE = { | |
| # ββ Stops ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| "p": {"desc": "voiceless stop", "ex": '"p" as in <b>p</b>at, ca<b>p</b>, ha<b>pp</b>y', "word": "pat"}, | |
| "b": {"desc": "voiced stop", "ex": '"b" as in <b>b</b>at, ca<b>b</b>, la<b>b</b>', "word": "bat"}, | |
| "t": {"desc": "voiceless stop", "ex": '"t" as in <b>t</b>ap, ca<b>t</b>, bu<b>tt</b>er', "word": "tap"}, | |
| "d": {"desc": "voiced stop", "ex": '"d" as in <b>d</b>og, be<b>d</b>, la<b>dd</b>er', "word": "dog"}, | |
| "k": {"desc": "voiceless stop", "ex": '"k/c" as in <b>c</b>at, ba<b>ck</b>, <b>k</b>ick', "word": "cat"}, | |
| "Ι‘": {"desc": "voiced stop", "ex": '"g" as in <b>g</b>o, ba<b>g</b>, ti<b>g</b>er', "word": "go"}, | |
| "g": {"desc": "voiced stop", "ex": '"g" as in <b>g</b>o, ba<b>g</b>, ti<b>g</b>er', "word": "go"}, | |
| "Κ": {"desc": "glottal stop", "ex": 'the catch in uh-<b>oh</b>, or "t" in Bu<b>tt</b>on (UK)',"word": "uh oh"}, | |
| # ββ Affricates βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| "tΚ": {"desc": "voiceless affricate", "ex": '"ch" as in <b>ch</b>ip, ca<b>tch</b>, na<b>t</b>ure', "word": "chip"}, | |
| "dΚ": {"desc": "voiced affricate", "ex": '"j/g" as in <b>j</b>am, a<b>g</b>e, ma<b>g</b>ic', "word": "jam"}, | |
| "ts": {"desc": "voiceless affricate", "ex": '"ts" as in ca<b>ts</b>, bi<b>ts</b>', "word": "cats"}, | |
| "dz": {"desc": "voiced affricate", "ex": '"ds/z" as in be<b>ds</b>, a<b>dz</b>e', "word": "beds"}, | |
| # ββ Fricatives βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| "f": {"desc": "voiceless fricative", "ex": '"f/ph" as in <b>f</b>at, lea<b>f</b>, <b>ph</b>one', "word": "fat"}, | |
| "v": {"desc": "voiced fricative", "ex": '"v" as in <b>v</b>at, lo<b>v</b>e, o<b>v</b>er', "word": "vat"}, | |
| "ΞΈ": {"desc": "voiceless fricative", "ex": '"th" as in <b>th</b>in, ba<b>th</b>', "word": "thin"}, | |
| "Γ°": {"desc": "voiced fricative", "ex": '"th" as in <b>th</b>e, <b>th</b>is, fa<b>th</b>er', "word": "this"}, | |
| "s": {"desc": "voiceless fricative", "ex": '"s" as in <b>s</b>it, bu<b>s</b>, hi<b>ss</b>', "word": "sit"}, | |
| "z": {"desc": "voiced fricative", "ex": '"z" as in <b>z</b>oo, bu<b>zz</b>, la<b>z</b>y', "word": "zoo"}, | |
| "Κ": {"desc": "voiceless fricative", "ex": '"sh" as in <b>sh</b>ip, ca<b>sh</b>, <b>s</b>ugar', "word": "ship"}, | |
| "Κ": {"desc": "voiced fricative", "ex": '"s/g" as in mea<b>s</b>ure, vi<b>s</b>ion, bei<b>g</b>e',"word": "measure"}, | |
| "h": {"desc": "voiceless fricative", "ex": '"h" as in <b>h</b>at, a<b>h</b>ead, <b>wh</b>o', "word": "hat"}, | |
| "x": {"desc": "voiceless fricative", "ex": '"ch" as in lo<b>ch</b> (Scottish), Ba<b>ch</b>', "word": "loch"}, | |
| "Γ§": {"desc": "voiceless fricative", "ex": 'soft "ch" as in i<b>ch</b> (German)', "word": "huge"}, | |
| # ββ Nasals βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| "m": {"desc": "nasal", "ex": '"m" as in <b>m</b>at, ha<b>m</b>, su<b>mm</b>er', "word": "mat"}, | |
| "n": {"desc": "nasal", "ex": '"n" as in <b>n</b>ap, ba<b>n</b>, su<b>nn</b>y', "word": "nap"}, | |
| "Ε": {"desc": "nasal", "ex": '"ng" as in si<b>ng</b>, ri<b>ng</b>, si<b>ng</b>er', "word": "singing"}, | |
| "Ι²": {"desc": "palatal nasal", "ex": '"ny/gn" as in ca<b>ny</b>on, lasag<b>n</b>e', "word": "canyon"}, | |
| # ββ Liquids & Approximants βββββββββββββββββββββββββββββββββββββββββββββββ | |
| "l": {"desc": "lateral", "ex": '"l" as in <b>l</b>ip, be<b>ll</b>, si<b>ll</b>y', "word": "lip"}, | |
| "r": {"desc": "trill/tap", "ex": 'rolled "r" as in Spanish <b>r</b>osa', "word": "red"}, | |
| "ΙΉ": {"desc": "approximant", "ex": '"r" as in <b>r</b>ed, ca<b>r</b>, ve<b>r</b>y (US/UK)', "word": "red"}, | |
| "ΙΎ": {"desc": "flap", "ex": 'quick "d/t" as in bu<b>tt</b>er, lad<b>d</b>er (US)', "word": "butter"}, | |
| "w": {"desc": "approximant", "ex": '"w" as in <b>w</b>et, t<b>w</b>in, a<b>w</b>ay', "word": "wet"}, | |
| "j": {"desc": "approximant", "ex": '"y" as in <b>y</b>es, <b>y</b>ou, be<b>y</b>ond', "word": "yes"}, | |
| "Κ": {"desc": "voiceless approximant","ex": 'breathy "wh" as in <b>wh</b>ich (Scottish/Irish)', "word": "which"}, | |
| # ββ Short / lax vowels βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| "Ιͺ": {"desc": "short vowel", "ex": '"i" as in s<b>i</b>t, sh<b>i</b>p, b<b>i</b>g', "word": "sit"}, | |
| "Κ": {"desc": "short vowel", "ex": '"oo" as in p<b>u</b>t, f<b>oo</b>t, c<b>ou</b>ld', "word": "foot"}, | |
| "Ι": {"desc": "short vowel", "ex": '"e" as in b<b>e</b>d, h<b>ea</b>d, s<b>ai</b>d', "word": "bed"}, | |
| "Γ¦": {"desc": "short vowel", "ex": '"a" as in c<b>a</b>t, b<b>a</b>d, h<b>a</b>nd', "word": "cat"}, | |
| "Ι": {"desc": "short vowel", "ex": '"o" as in p<b>o</b>t, d<b>o</b>g, n<b>o</b>t (UK)', "word": "pot"}, | |
| "Κ": {"desc": "short vowel", "ex": '"u" as in c<b>u</b>p, l<b>u</b>ck, bl<b>oo</b>d', "word": "cup"}, | |
| "Ι": {"desc": "schwa (unstressed)", "ex": 'weak vowel: <b>a</b>bout, sof<b>a</b>, ban<b>a</b>na', "word": "about"}, | |
| "Ι": {"desc": "open-mid central", "ex": 'short "a/u" as in c<b>u</b>t, <b>a</b>bout (some accents)',"word": "cut"}, | |
| # ββ Long vowels ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| "iΛ": {"desc": "long vowel", "ex": '"ee" as in s<b>ee</b>, f<b>ee</b>t, k<b>ey</b>', "word": "see"}, | |
| "i": {"desc": "close front vowel", "ex": '"ee" as in s<b>ee</b>, happ<b>y</b>, coff<b>ee</b>', "word": "see"}, | |
| "uΛ": {"desc": "long vowel", "ex": '"oo" as in t<b>oo</b>, bl<b>ue</b>, f<b>oo</b>d', "word": "too"}, | |
| "u": {"desc": "close back vowel", "ex": '"oo" as in t<b>oo</b>, bl<b>ue</b>, f<b>oo</b>d', "word": "too"}, | |
| "ΙΛ": {"desc": "long vowel", "ex": '"ah" as in c<b>ar</b>, f<b>a</b>ther, p<b>a</b>lm', "word": "father"}, | |
| "Ι": {"desc": "open back vowel", "ex": '"ah" as in f<b>a</b>ther, sp<b>a</b>', "word": "father"}, | |
| "ΙΛ": {"desc": "long vowel", "ex": '"aw" as in s<b>aw</b>, c<b>all</b>, th<b>ought</b>', "word": "saw"}, | |
| "Ι": {"desc": "open-mid back vowel", "ex": '"aw/o" as in s<b>aw</b>, l<b>aw</b>', "word": "saw"}, | |
| "ΙΛ": {"desc": "long vowel", "ex": '"ur" as in b<b>ir</b>d, w<b>or</b>d, t<b>ur</b>n', "word": "bird"}, | |
| "Ι": {"desc": "open-mid central", "ex": '"ur" as in b<b>ir</b>d, w<b>or</b>d', "word": "bird"}, | |
| "aΛ": {"desc": "long open vowel", "ex": '"ah" as in c<b>ar</b>, f<b>a</b>ther', "word": "car"}, | |
| "a": {"desc": "open front vowel", "ex": '"a" as in f<b>a</b>ther, sp<b>a</b>', "word": "father"}, | |
| "e": {"desc": "mid front vowel", "ex": '"ay/e" as in d<b>ay</b>, f<b>a</b>ce, th<b>ey</b>', "word": "day"}, | |
| "o": {"desc": "mid back vowel", "ex": '"oh" as in g<b>o</b>, h<b>o</b>me, n<b>o</b>te', "word": "go"}, | |
| "ΓΈ": {"desc": "front rounded vowel", "ex": '"eu" as in f<b>eu</b> (French), sch<b>ΓΆ</b>n (German)', "word": "fur"}, | |
| "y": {"desc": "close front rounded", "ex": '"ΓΌ" as in l<b>u</b>ne (French), <b>ΓΌ</b>ber (German)', "word": "few"}, | |
| "Κ": {"desc": "close central vowel", "ex": 'between "ee" and "oo", e.g. g<b>oo</b>se (Australian)', "word": "goose"}, | |
| # ββ R-coloured vowels ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| "Ι": {"desc": "r-coloured schwa", "ex": '"er" as in butt<b>er</b>, bett<b>er</b> (US)', "word": "butter"}, | |
| "Ι": {"desc": "r-coloured vowel", "ex": '"ur" as in b<b>ir</b>d, w<b>or</b>d (US)', "word": "bird"}, | |
| "Ι»": {"desc": "retroflex approximant","ex": '"r" sound made with tongue curled back (some US)', "word": "red"}, | |
| # ββ Diphthongs βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| "eΙͺ": {"desc": "diphthong", "ex": '"ay" as in d<b>ay</b>, f<b>a</b>ce, t<b>a</b>ke', "word": "day"}, | |
| "aΙͺ": {"desc": "diphthong", "ex": '"i" as in fl<b>y</b>, t<b>i</b>me, h<b>igh</b>', "word": "fly"}, | |
| "ΙΙͺ": {"desc": "diphthong", "ex": '"oy" as in b<b>oy</b>, t<b>oy</b>, c<b>oi</b>n', "word": "boy"}, | |
| "aΚ": {"desc": "diphthong", "ex": '"ow" as in n<b>ow</b>, <b>ou</b>t, m<b>ou</b>se', "word": "now"}, | |
| "oΚ": {"desc": "diphthong", "ex": '"oh" as in g<b>o</b>, h<b>o</b>me, kn<b>ow</b>', "word": "go"}, | |
| "ΙΚ": {"desc": "diphthong", "ex": '"oh" as in g<b>o</b>, h<b>o</b>me, kn<b>ow</b> (UK)', "word": "go"}, | |
| "ΙͺΙ": {"desc": "diphthong", "ex": '"ear" as in <b>ear</b>, h<b>ere</b>, n<b>ear</b>', "word": "ear"}, | |
| "ΚΙ": {"desc": "diphthong", "ex": '"oor" as in t<b>our</b>, p<b>oor</b> (UK)', "word": "tour"}, | |
| "eΙ": {"desc": "diphthong", "ex": '"air" as in h<b>air</b>, c<b>are</b>, th<b>ere</b> (UK)',"word": "hair"}, | |
| } | |
| # Pure-CSS tooltip + inline onclick (no <script> tag needed β those don't | |
| # execute when inserted via innerHTML, but inline handlers and CSS do). | |
| _IPA_CSS = f"""<style> | |
| .ipa-outer {{ | |
| background: {_C_CARD}; | |
| border: 1px solid {_C_BORDER}; | |
| border-radius: 8px; | |
| padding: 10px 16px 16px; | |
| width: 100% !important; | |
| max-width: none !important; | |
| box-sizing: border-box !important; | |
| color-scheme: dark; | |
| }} | |
| .ipa-label {{ | |
| font-size: 0.8em; | |
| color: {_C_MUTED}; | |
| margin-bottom: 6px; | |
| }} | |
| .ipa-token {{ | |
| position: relative; | |
| display: inline-block; | |
| cursor: pointer; | |
| color: {_C_TEXT}; | |
| border-bottom: 2px dotted {_C_MUTED}; | |
| padding: 0 2px; | |
| border-radius: 2px; | |
| }} | |
| .ipa-token:hover {{ background: rgba(255,255,255,0.10); }} | |
| .ipa-token:active {{ transform: scale(0.95); }} | |
| .ipa-copy-btn {{ | |
| font-size: 0.75em; | |
| padding: 2px 8px; | |
| border: 1px solid {_C_BORDER}; | |
| border-radius: 4px; | |
| background: {_C_RAISED}; | |
| color: {_C_MUTED}; | |
| cursor: pointer; | |
| transition: background 0.15s, color 0.15s; | |
| white-space: nowrap; | |
| flex-shrink: 0; | |
| }} | |
| .ipa-copy-btn:hover {{ background: {_C_BORDER}; color: {_C_TEXT}; }} | |
| .ipa-tip {{ | |
| visibility: hidden; | |
| opacity: 0; | |
| position: absolute; | |
| bottom: calc(100% + 6px); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| min-width: 220px; | |
| background: #1a1a2e; | |
| color: #fff !important; | |
| padding: 10px 14px; | |
| border-radius: 9px; | |
| font-family: sans-serif !important; | |
| font-size: 13px !important; | |
| font-weight: normal !important; | |
| line-height: 1.5 !important; | |
| z-index: 9999; | |
| pointer-events: none; | |
| box-shadow: 0 4px 18px rgba(0,0,0,0.6); | |
| transition: opacity 0.15s; | |
| white-space: normal; | |
| text-align: left; | |
| }} | |
| .ipa-token:hover .ipa-tip {{ visibility: visible; opacity: 1; }} | |
| .ipa-tip * {{ color: #fff !important; }} | |
| .ipa-tip b {{ color: #7dd3fc !important; font-weight: 600; }} | |
| /* Prevent ancestor table cells from clipping the tooltip */ | |
| table, tbody, tr, td, th {{ overflow: visible !important; }} | |
| /* Lift Gradio's .prose max-width:65ch */ | |
| .prose:has(.ipa-outer) {{ | |
| max-width: none !important; | |
| width: 100% !important; | |
| }} | |
| </style> | |
| """ | |
| def _ipa_span(phoneme: str) -> str: | |
| """Wrap a phoneme in an interactive span with a CSS tooltip and click-to-speak.""" | |
| if phoneme == "β " or phoneme not in IPA_REFERENCE: | |
| return phoneme | |
| d = IPA_REFERENCE[phoneme] | |
| # Inline onclick uses single-quoted JS strings β safe inside double-quoted HTML attr | |
| word_js = d["word"].replace("'", "\\'") | |
| onclick = ( | |
| f"speechSynthesis&&(" | |
| f"speechSynthesis.cancel()," | |
| f"speechSynthesis.speak(" | |
| f"Object.assign(new SpeechSynthesisUtterance('{word_js}')," | |
| f"{{lang:'en-US',rate:.75}})))" | |
| ) | |
| tip = ( | |
| f'<span class="ipa-tip">' | |
| f'<b style="font-family:monospace;font-size:1.3em">{phoneme}</b> ' | |
| f'<span style="opacity:0.65;font-size:0.88em">{d["desc"]}</span>' | |
| f'<hr style="border:0;border-top:1px solid rgba(255,255,255,.2);margin:6px 0">' | |
| f'{d["ex"]}' | |
| f'<div style="margin-top:6px;opacity:0.5;font-size:0.85em">π Click to hear an example</div>' | |
| f'</span>' | |
| ) | |
| return f'<span class="ipa-token" onclick="{onclick}">{phoneme}{tip}</span>' | |
| def _build_ipa_keyboard() -> str: | |
| """Return the static HTML for the clickable IPA keyboard.""" | |
| def key(sym: str) -> str: | |
| # Inline JS: capture cursor, insert symbol, restore cursor, fire input event | |
| js = ( | |
| f"var t=document.querySelector('#ipa-edit-box textarea');" | |
| f"if(t){{var s=t.selectionStart,e=t.selectionEnd;" | |
| f"t.value=t.value.slice(0,s)+'{sym}'+t.value.slice(e);" | |
| f"t.selectionStart=t.selectionEnd=s+{len(sym)};" | |
| f"t.dispatchEvent(new Event('input',{{bubbles:true}}));t.focus();}}" | |
| ) | |
| return f'<button class="ipa-k" onclick="{js}" title="{sym}">{sym}</button>' | |
| def bksp() -> str: | |
| js = ( | |
| "var t=document.querySelector('#ipa-edit-box textarea');" | |
| "if(t){var s=t.selectionStart,e=t.selectionEnd;" | |
| "if(s===e&&s>0){t.value=t.value.slice(0,s-1)+t.value.slice(s);t.selectionStart=t.selectionEnd=s-1;}" | |
| "else{t.value=t.value.slice(0,s)+t.value.slice(e);t.selectionStart=t.selectionEnd=s;}" | |
| "t.dispatchEvent(new Event('input',{bubbles:true}));t.focus();}" | |
| ) | |
| return f'<button class="ipa-k ipa-k-act" onclick="{js}" title="Backspace">β«</button>' | |
| def row(label: str, syms: list) -> str: | |
| keys = "".join(key(s) for s in syms) | |
| return (f'<div class="ipa-row">' | |
| f'<span class="ipa-row-lbl">{label}</span>' | |
| f'<span class="ipa-row-keys">{keys}</span>' | |
| f'</div>') | |
| css = f"""<style> | |
| .ipa-kb{{background:{_C_CARD};border:1px solid {_C_BORDER};border-radius:10px; | |
| padding:14px 16px;font-family:sans-serif; | |
| max-width:none!important;color-scheme:dark}} | |
| .ipa-kb-title{{font-size:.82em;color:{_C_MUTED};margin-bottom:10px;font-style:italic}} | |
| .ipa-row{{display:flex;align-items:center;flex-wrap:wrap;gap:3px;margin-bottom:6px}} | |
| .ipa-row-lbl{{font-size:.68em;color:{_C_MUTED};text-transform:uppercase; | |
| letter-spacing:.06em;min-width:90px;flex-shrink:0}} | |
| .ipa-row-keys{{display:flex;flex-wrap:wrap;gap:3px}} | |
| .ipa-k{{font-family:monospace;font-size:1.05em;min-width:30px;padding:4px 6px; | |
| border:1px solid {_C_BORDER};border-radius:5px;background:{_C_RAISED}; | |
| cursor:pointer;color:{_C_TEXT};transition:background .1s,transform .05s}} | |
| .ipa-k:hover{{background:#1e40af;border-color:#3b82f6;color:#fff}} | |
| .ipa-k:active{{background:#1d4ed8;transform:scale(.92)}} | |
| .ipa-k-act{{background:{_C_RAISED};min-width:38px;font-size:.95em}} | |
| .ipa-k-act:hover{{background:{_C_BORDER}}} | |
| .ipa-kb-div{{border-top:1px solid {_C_BORDER};margin:8px 0}} | |
| </style>""" | |
| rows = [ | |
| row("Stops", ["p","b","t","d","Κ","Ι","c","Ι","k","Ι‘","q","Ι’","Κ"]), | |
| row("Affricates", ["tΚ","dΚ","ts","dz","tΙ","dΚ"]), | |
| row("Nasals", ["m","Ι±","n","Ι³","Ι²","Ε","Ι΄"]), | |
| row("Fricatives", ["f","v","ΞΈ","Γ°","s","z","Κ","Κ","Κ","Κ","Γ§","Κ","x","Ι£","Ο","Κ","Δ§","Κ","h","Ι¦","ΙΈ","Ξ²"]), | |
| row("Approx.", ["Κ","ΙΉ","Ι»","j","Ι°","w"]), | |
| row("Laterals", ["l","Ι","Κ","Κ","r","Κ","Κ","ΙΎ","Ι½"]), | |
| '<div class="ipa-kb-div"></div>', | |
| row("Close V.", ["i","y","Ι¨","Κ","Ι―","u","Ιͺ","Κ","Κ"]), | |
| row("Mid V.", ["e","ΓΈ","Ι","Ι΅","Ι€","o","Ι","Ι","Ι"]), | |
| row("Open-mid V.", ["Ι","Ε","Ι","Ι","Κ","Ι"]), | |
| row("Open V.", ["Γ¦","a","Ι","Ι","Ι","ΙΆ"]), | |
| row("Diphthongs", ["eΙͺ","aΙͺ","ΙΙͺ","aΚ","oΚ","ΙΚ","ΙͺΙ","ΚΙ","eΙ"]), | |
| '<div class="ipa-kb-div"></div>', | |
| row("Supraseg.", ["Λ","Λ","Λ",".","|","β","βΏ"," "]), | |
| f'<div class="ipa-row"><span class="ipa-row-lbl">Edit</span>' | |
| f'<span class="ipa-row-keys">{bksp()}</span></div>', | |
| ] | |
| return ( | |
| css | |
| + '<div class="ipa-kb">' | |
| + '<div class="ipa-kb-title">Click a symbol to insert it at the cursor in the edit box above</div>' | |
| + "".join(rows) | |
| + '</div>' | |
| ) | |
| _IPA_KEYBOARD_HTML = _build_ipa_keyboard() | |
| def _ipa_box_html(label: str, ipa_text: str, word_token_groups=None) -> str: | |
| """Render a styled, centered IPA output box. | |
| When word_token_groups is supplied each phoneme becomes an interactive span.""" | |
| if word_token_groups: | |
| words_html = [] | |
| for tokens in word_token_groups: | |
| wh = "".join( | |
| _ipa_span(t) if t in IPA_REFERENCE | |
| else f'<span style="font-family:monospace">{t}</span>' | |
| for t in tokens | |
| ) | |
| words_html.append(f'<span style="white-space:nowrap;display:inline-block;margin:0 4px">{wh}</span>') | |
| body = " ".join(words_html) | |
| else: | |
| body = ipa_text | |
| # Escape IPA text for use in a double-quoted HTML attribute | |
| ipa_attr = ipa_text.replace("&", "&").replace('"', """) | |
| # Inline JS: try modern clipboard API, fall back to execCommand | |
| copy_js = ( | |
| "(function(b){" | |
| "var s=b.closest('.ipa-outer').getAttribute('data-ipa');" | |
| "function ok(){b.textContent='β Copied';setTimeout(function(){b.textContent='β Copy';},2000);}" | |
| "function fb(){var t=document.createElement('textarea');t.value=s;" | |
| "t.style.cssText='position:fixed;opacity:0';document.body.appendChild(t);" | |
| "t.select();document.execCommand('copy');document.body.removeChild(t);ok();}" | |
| "if(navigator.clipboard){navigator.clipboard.writeText(s).then(ok).catch(fb);}else{fb();}" | |
| "})(this)" | |
| ) | |
| return ( | |
| _IPA_CSS | |
| + f'<div class="ipa-outer" data-ipa="{ipa_attr}">' | |
| f'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">' | |
| f'<div class="ipa-label" style="margin-bottom:0">{label}</div>' | |
| f'<button class="ipa-copy-btn" onclick="{copy_js}">β Copy</button>' | |
| f'</div>' | |
| f'<table width="100%" style="border-collapse:collapse;overflow:visible">' | |
| f'<tr><td style="text-align:center;font-family:monospace;font-size:1.35em;' | |
| f'color:{_C_TEXT};line-height:3;padding:4px 0;overflow:visible;word-break:break-word">' | |
| f'{body}' | |
| f'</td></tr>' | |
| f'</table>' | |
| f'</div>' | |
| ) | |
| def _confidence_badge(conf: Optional[float]) -> str: | |
| if conf is None: | |
| return "" | |
| pct = conf * 100 | |
| color = "#6ee7b7" if pct >= 80 else "#fbbf24" if pct >= 50 else "#fca5a5" | |
| return ( | |
| f'<span style="font-size:0.75em;color:{color};margin-left:6px;' | |
| f'border:1px solid {color};border-radius:3px;padding:1px 4px">' | |
| f'{pct:.0f}%</span>' | |
| ) | |
| def _progress_bar(value: float, color: str, width: str = "100%") -> str: | |
| pct = value * 100 | |
| return ( | |
| f'<div style="background:{_C_RAISED};border-radius:4px;height:8px;width:{width}">' | |
| f'<div style="background:{color};width:{pct:.1f}%;height:8px;border-radius:4px"></div>' | |
| f'</div>' | |
| ) | |
| def render_alignment_html(result: AnalysisResult, word_token_groups=None) -> str: | |
| # Text-only mode: no audio was provided | |
| if not result.actual_ipa: | |
| tr_pct = result.transcription_confidence * 100 | |
| tr_color = "#4CAF50" if tr_pct >= 80 else "#FF9800" if tr_pct >= 50 else "#F44336" | |
| ipa_section = "" | |
| if word_token_groups: | |
| words_html = [] | |
| for tokens in word_token_groups: | |
| wh = "".join( | |
| _ipa_span(t) if t in IPA_REFERENCE | |
| else f'<span style="font-family:monospace">{t}</span>' | |
| for t in tokens | |
| ) | |
| words_html.append(f'<span style="white-space:nowrap;display:inline-block;margin:0 4px">{wh}</span>') | |
| ipa_display = " ".join(words_html) | |
| ipa_section = ( | |
| f'<div style="margin-top:20px;padding-top:16px;border-top:1px solid {_C_BORDER}">' | |
| f'<div style="font-size:0.85em;color:{_C_MUTED};margin-bottom:10px">' | |
| f'Target IPA — hover a phoneme for details, click to hear an example' | |
| f'</div>' | |
| f'<table width="100%" style="border-collapse:collapse;overflow:visible">' | |
| f'<tr><td style="text-align:center;font-family:monospace;font-size:1.7em;' | |
| f'color:{_C_TEXT};line-height:3.2;padding:4px 0;overflow:visible;word-break:break-word">' | |
| f'{ipa_display}' | |
| f'</td></tr>' | |
| f'</table>' | |
| f'</div>' | |
| ) | |
| return _IPA_CSS + ( | |
| f'<div style="font-family:sans-serif;color:{_C_TEXT}">' | |
| f'<div style="margin-bottom:12px">' | |
| f'<div style="font-size:0.8em;color:{_C_MUTED};margin-bottom:2px">Transcription confidence</div>' | |
| f'<strong style="font-size:1.2em;color:{_C_TEXT}">{tr_pct:.1f}%</strong>' | |
| f'{_progress_bar(result.transcription_confidence, tr_color)}' | |
| f'</div>' | |
| f'<p style="color:{_C_TEXT2}">Upload or record audio to see pronunciation accuracy.</p>' | |
| f'{ipa_section}' | |
| f'</div>' | |
| ) | |
| acc_pct = result.match_rate * 100 | |
| acc_color = "#4ade80" if acc_pct >= 80 else "#fb923c" if acc_pct >= 50 else "#f87171" | |
| tr_pct = result.transcription_confidence * 100 | |
| tr_color = "#4ade80" if tr_pct >= 80 else "#fb923c" if tr_pct >= 50 else "#f87171" | |
| mc_pct = result.model_confidence * 100 | |
| mc_color = "#4ade80" if mc_pct >= 80 else "#fb923c" if mc_pct >= 50 else "#f87171" | |
| rows = [] | |
| for pair in result.alignment: | |
| bg, fg = STATUS_COLORS[pair.status] | |
| label = STATUS_LABELS[pair.status] | |
| badge = _confidence_badge(pair.confidence) | |
| rows.append( | |
| f'<tr style="background:{bg}">' | |
| f'<td style="padding:6px 12px;font-family:monospace;font-size:1.1em;color:{fg}">{_ipa_span(pair.target)}</td>' | |
| f'<td style="padding:6px 12px;font-family:monospace;font-size:1.1em;color:{fg}">{_ipa_span(pair.actual)}{badge}</td>' | |
| f'<td style="padding:6px 12px;font-size:0.9em;color:{fg}">{label}</td>' | |
| f'</tr>' | |
| ) | |
| error_summary = "" | |
| if result.errors: | |
| substitutions = [p for p in result.errors if p.status == "replace"] | |
| deletions = [p for p in result.errors if p.status == "delete"] | |
| insertions = [p for p in result.errors if p.status == "insert"] | |
| parts = [] | |
| if substitutions: | |
| parts.append(f"{len(substitutions)} substitution{'s' if len(substitutions)>1 else ''}") | |
| if deletions: | |
| parts.append(f"{len(deletions)} omission{'s' if len(deletions)>1 else ''}") | |
| if insertions: | |
| parts.append(f"{len(insertions)} insertion{'s' if len(insertions)>1 else ''}") | |
| error_summary = f"<p style='color:#fca5a5;margin:4px 0'>Issues detected: {', '.join(parts)}</p>" | |
| return _IPA_CSS + f""" | |
| <div style="font-family:sans-serif;line-height:1.6;color:{_C_TEXT}"> | |
| <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:16px"> | |
| <div> | |
| <div style="font-size:0.8em;color:{_C_MUTED};margin-bottom:2px">Pronunciation accuracy</div> | |
| <strong style="font-size:1.2em;color:{_C_TEXT}">{acc_pct:.1f}%</strong> | |
| {_progress_bar(result.match_rate, acc_color)} | |
| </div> | |
| <div> | |
| <div style="font-size:0.8em;color:{_C_MUTED};margin-bottom:2px">Transcription confidence</div> | |
| <strong style="font-size:1.2em;color:{_C_TEXT}">{tr_pct:.1f}%</strong> | |
| {_progress_bar(result.transcription_confidence, tr_color)} | |
| </div> | |
| <div> | |
| <div style="font-size:0.8em;color:{_C_MUTED};margin-bottom:2px">Phoneme model confidence</div> | |
| <strong style="font-size:1.2em;color:{_C_TEXT}">{mc_pct:.1f}%</strong> | |
| {_progress_bar(result.model_confidence, mc_color)} | |
| </div> | |
| </div> | |
| {error_summary} | |
| <table style="border-collapse:collapse;width:100%;margin-top:8px;overflow:visible"> | |
| <thead> | |
| <tr style="background:{_C_RAISED}"> | |
| <th style="padding:6px 12px;text-align:left;border-bottom:2px solid {_C_BORDER};color:{_C_TEXT}"> | |
| Target phoneme<br><small style="font-weight:normal;color:{_C_MUTED}">(eSpeak-ng Β· expected)</small> | |
| </th> | |
| <th style="padding:6px 12px;text-align:left;border-bottom:2px solid {_C_BORDER};color:{_C_TEXT}"> | |
| Actual phoneme<br><small style="font-weight:normal;color:{_C_MUTED}">(Wav2Vec2 Β· heard Β· confidence)</small> | |
| </th> | |
| <th style="padding:6px 12px;text-align:left;border-bottom:2px solid {_C_BORDER};color:{_C_TEXT}">Status</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {"".join(rows)} | |
| </tbody> | |
| </table> | |
| <p style="margin-top:12px;font-size:0.8em;color:{_C_MUTED}"> | |
| Legend: | |
| <span style="color:#6ee7b7">β match</span> | |
| <span style="color:#fbbf24">β substitution</span> | |
| <span style="color:#fca5a5">β omission (expected but not said)</span> | |
| <span style="color:#93c5fd">β insertion (said but not expected)</span> | |
| </p> | |
| </div> | |
| """ | |
| # ββ Gradio callback ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def analyze(audio_path, text_input: str, whisper_size: str, language_label: str): | |
| has_audio = audio_path is not None | |
| has_text = bool(text_input and text_input.strip()) | |
| _hidden = gr.update(visible=False) | |
| # outputs order: text_out, target_ipa_out, ipa_out, ipa_audio_out, alignment_out, | |
| # ipa_text_state, ipa_mode_state, | |
| # modify_btn, ipa_edit_box, apply_btn, kb_btn, ipa_keyboard (12 total) | |
| if not has_audio and not has_text: | |
| return ("No input provided.", _hidden, gr.update(), | |
| gr.update(), "<p>Upload audio, record, or type text.</p>", | |
| "", "text", _hidden, _hidden, _hidden, _hidden, _hidden) | |
| lang_code = LANGUAGES[language_label] | |
| engine = get_engine(whisper_size) | |
| try: | |
| result = engine.analyze( | |
| audio_path=audio_path, | |
| lang=lang_code, | |
| text_override=text_input or None, | |
| ) | |
| except Exception as exc: | |
| import traceback | |
| print(traceback.format_exc()) | |
| return ("", _hidden, gr.update(), | |
| gr.update(), f"<p style='color:red'><strong>Error:</strong> {exc}</p>", | |
| "", "text", _hidden, _hidden, _hidden, _hidden, _hidden) | |
| # Shared: build target IPA display (used in both modes) | |
| t_words = result.target_ipa.split() if result.target_ipa else [] | |
| t_word_groups = [engine._segment_word(w) for w in t_words] | |
| if has_audio: | |
| # Show Target IPA (read-only) + Actual IPA (editable) | |
| target_html = _ipa_box_html("Target IPA (eSpeak-ng)", result.target_ipa or "", | |
| word_token_groups=t_word_groups if t_word_groups else None) | |
| actual_words = result.actual_ipa.split() if result.actual_ipa else [] | |
| actual_word_groups = [engine._segment_word(w) for w in actual_words] if actual_words else None | |
| actual_html = _ipa_box_html("Actual IPA (Wav2Vec2)", result.actual_ipa or "", | |
| word_token_groups=actual_word_groups) | |
| actual_audio = engine.synthesize_ipa(result.actual_ipa, lang_code) if result.actual_ipa else None | |
| html = render_alignment_html(result) | |
| return ( | |
| result.text, | |
| gr.update(value=target_html, visible=True), # target_ipa_out β show | |
| actual_html, # ipa_out | |
| gr.update(value=actual_audio, label="Listen to target pronunciation of actual IPA"), | |
| html, | |
| result.actual_ipa or "", # ipa_text_state | |
| "audio", # ipa_mode_state | |
| gr.update(visible=True), # modify_btn β show | |
| _hidden, # ipa_edit_box β reset | |
| _hidden, # apply_btn β reset | |
| _hidden, # kb_btn β reset | |
| _hidden, # ipa_keyboard β reset | |
| ) | |
| else: | |
| # Text mode: only Target IPA (target_ipa_out stays hidden) | |
| target_audio = engine.synthesize(result.text, lang_code) | |
| target_html = _ipa_box_html("Target IPA (eSpeak-ng)", result.target_ipa or "", | |
| word_token_groups=t_word_groups if t_word_groups else None) | |
| html = render_alignment_html(result, word_token_groups=t_word_groups if t_word_groups else None) | |
| return ( | |
| result.text, | |
| _hidden, # target_ipa_out β hide (text mode uses ipa_out) | |
| target_html, # ipa_out | |
| gr.update(value=target_audio, label="Listen to target pronunciation"), | |
| html, | |
| result.target_ipa or "", # ipa_text_state | |
| "text", # ipa_mode_state | |
| gr.update(visible=True), # modify_btn β show | |
| _hidden, # ipa_edit_box β reset | |
| _hidden, # apply_btn β reset | |
| _hidden, # kb_btn β reset | |
| _hidden, # ipa_keyboard β reset | |
| ) | |
| def apply_ipa_edit(new_ipa: str, language_label: str, mode: str): | |
| """Re-render the IPA display from user-edited text and regenerate audio.""" | |
| new_ipa = new_ipa.strip() | |
| lang_code = LANGUAGES.get(language_label, "en-us") | |
| engine = get_engine("base") # engine is always cached after first Analyze | |
| words = new_ipa.split() if new_ipa else [] | |
| word_token_groups = [engine._segment_word(w) for w in words] if words else None | |
| if mode == "text": | |
| html = _ipa_box_html("Target IPA (eSpeak-ng)", new_ipa, | |
| word_token_groups=word_token_groups) | |
| else: | |
| html = _ipa_box_html("Actual IPA (Wav2Vec2)", new_ipa, | |
| word_token_groups=word_token_groups) | |
| audio = engine.synthesize_ipa(new_ipa, lang_code) if new_ipa else None | |
| label = ("Listen to target pronunciation" if mode == "text" | |
| else "Listen to target pronunciation of actual IPA") | |
| return ( | |
| gr.update(value=html, visible=True), # ipa_out | |
| gr.update(visible=False), # ipa_edit_box | |
| gr.update(visible=True), # modify_btn | |
| gr.update(visible=False), # kb_btn | |
| gr.update(visible=False), # apply_btn | |
| gr.update(visible=False), # ipa_keyboard | |
| new_ipa, # ipa_text_state | |
| gr.update(value=audio, label=label), # ipa_audio_out | |
| ) | |
| # ββ User-guide content βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _USER_GUIDE_MD = """ | |
| ## What is this tool? | |
| IPA Translator helps you understand and check pronunciation using the **International Phonetic Alphabet (IPA)** β a standardised system where every sound in every language has its own unique symbol. | |
| The tool has two main uses: | |
| | Mode | What you provide | What you get | | |
| |------|-----------------|--------------| | |
| | **Text mode** | Type or paste text | The *target* IPA β how the word *should* sound according to a phonetic model | | |
| | **Audio mode** | Upload or record speech | The *actual* IPA β how you *actually* pronounced it, compared against the target | | |
| --- | |
| ## Getting started | |
| ### Step 1 β Choose your language | |
| Select a language from the **Language** dropdown. Supported options include: | |
| - English (US / UK) | |
| - Spanish (Castilian / Latin American) | |
| - French (Parisian / Canadian) | |
| - German, Italian, Dutch, Portuguese (Brazilian) | |
| - Arabic, Czech, Greek, Hindi, Hungarian | |
| - Japanese, Mandarin (Simplified / Traditional) | |
| - Russian, Ukrainian, Turkish, Thai | |
| > The language you choose determines both the phonetic model (eSpeak-ng) and the text-to-speech voice (Piper TTS). | |
| --- | |
| ### Step 2 β Provide input | |
| **Text input** | |
| Type or paste any text into the *Text input* box. The app will convert it to IPA immediately β no audio needed. | |
| **Audio input** | |
| Either upload an audio file (WAV, MP3, etc.) or click the microphone icon to record directly in your browser. | |
| > If you provide both, audio takes priority and text is cleared. You can also provide text together with audio to override the automatic transcription (useful if Whisper mishears a word). | |
| --- | |
| ### Step 3 β Select a Whisper model (audio only) | |
| | Model | Speed | Accuracy | | |
| |-------|-------|----------| | |
| | tiny | Fastest | Lower | | |
| | base | Fast | Good for most uses | | |
| | small / medium | Slower | Higher accuracy | | |
| | large | Slowest | Best accuracy | | |
| `base` is a good default. Use `small` or higher for accented speech or difficult audio. | |
| --- | |
| ### Step 4 β Click Analyse | |
| The app will: | |
| 1. Transcribe your audio (if provided) using Whisper | |
| 2. Generate the *target* IPA via eSpeak-ng | |
| 3. Recognise phonemes from your audio using Wav2Vec2 (audio mode only) | |
| 4. Align the two IPA sequences and score them | |
| --- | |
| ## Reading the results | |
| ### The IPA box | |
| After analysis, the IPA is displayed in a styled box with **interactive phoneme symbols**. | |
| | Action | Effect | | |
| |--------|--------| | |
| | **Hover** over a symbol | Shows a tooltip: the phoneme name, a plain-English description, and example words | | |
| | **Click** a symbol | Plays an example word containing that sound (uses your browser's speech engine) | | |
| The IPA is grouped by word, with spaces between words, so you can copy and paste it directly. | |
| --- | |
| ### Text mode β Target IPA | |
| Shows how the text *should* be pronounced. The interactive phoneme display appears in the panel, making it easy to hover over each symbol. | |
| **Listen to target pronunciation** β plays a high-quality TTS reading of your text using the selected language voice. | |
| --- | |
| ### Audio mode β Actual IPA + Accuracy panel | |
| Shows what the model *heard* in your recording. | |
| **Pronunciation accuracy** β percentage of phonemes that matched the target. | |
| **Transcription confidence** β how confident Whisper was about the text it transcribed. | |
| **Phoneme model confidence** β how confident Wav2Vec2 was about each phoneme it detected. | |
| **Alignment table** β a row-by-row comparison of every phoneme: | |
| | Colour | Meaning | | |
| |--------|---------| | |
| | π’ Green | Match β the phoneme was pronounced correctly | | |
| | π‘ Amber | Substitution β a different phoneme was heard | | |
| | π΄ Red | Omission β an expected phoneme was not detected | | |
| | π΅ Blue | Insertion β an extra phoneme was heard | | |
| Each row in the table also shows the Wav2Vec2 confidence score for the actual phoneme. | |
| **Listen to target pronunciation of actual IPA** β plays back *your* phonemes (the actual IPA) through TTS, so you can hear what the model understood. | |
| --- | |
| ## Modifying the IPA | |
| After analysis you can manually correct or experiment with the IPA output. | |
| ### 1. Click **βοΈ Modify** | |
| The IPA display is replaced by an editable text box pre-filled with the current IPA. | |
| ### 2. Click **β¨ IPA Keyboard** (optional) | |
| An IPA keyboard opens below the edit box, organised into sections (stops, affricates, nasals, fricatives, approximants, laterals, vowels, diphthongs, suprasegmentals). | |
| Click any symbol to insert it at the cursor in the edit box. The **β« backspace** key removes the character before the cursor. You can also type directly into the edit box alongside clicking keys. | |
| ### 3. Click **β Apply** | |
| - The IPA display re-renders with your changes (interactive hover/click restored) | |
| - The audio player regenerates so you can listen to your edited IPA | |
| - All edit controls hide and you return to the normal view | |
| --- | |
| ## Tips | |
| - **Stress marks** β use `Λ` (primary) and `Λ` (secondary) from the Suprasegmentals row to mark syllable stress, e.g. `ΛwΙtΙ`. | |
| - **Long vowels** β use `Λ` after a vowel, e.g. `iΛ` for the vowel in *see*. | |
| - **Word boundaries** β separate words with a space in the edit box. | |
| - **Accuracy score of 0%** β usually means the audio was too quiet, too short, or in a language the Wav2Vec2 model does not support well. | |
| - **Dark mode** β the IPA boxes, tooltips, and keyboard are rendered with a forced light colour scheme so they always display correctly regardless of your browser or OS theme. | |
| - **Copying IPA** β click inside the IPA box and use Ctrl/Cmd + A then Ctrl/Cmd + C to copy the full IPA string. | |
| --- | |
| ## IPA quick-reference | |
| Not sure what a symbol means? Hover over it in the app to see its description and examples. Here are the most common English phonemes: | |
| | Symbol | Sound | Example | | |
| |--------|-------|---------| | |
| | p b t d k Ι‘ | Stops | **p**at, **b**at, **t**ap, **d**og, **c**at, **g**o | | |
| | tΚ dΚ | Affricates | **ch**ip, **j**am | | |
| | f v ΞΈ Γ° s z Κ Κ h | Fricatives | **f**at, **v**at, **th**in, **th**is, **s**it, **z**oo, **sh**ip, mea**s**ure, **h**at | | |
| | m n Ε | Nasals | **m**at, **n**ap, si**ng** | | |
| | l ΙΉ w j | Approximants | **l**ip, **r**ed, **w**et, **y**es | | |
| | Ιͺ iΛ Κ uΛ | High vowels | s**i**t, s**ee**, f**oo**t, t**oo** | | |
| | e Ι Ι Κ | Mid vowels | d**ay**, b**e**d, **a**bout, c**u**p | | |
| | Γ¦ ΙΛ Ι ΙΛ | Low vowels | c**a**t, f**a**ther, p**o**t, s**aw** | | |
| | eΙͺ aΙͺ ΙΙͺ aΚ oΚ | Diphthongs | d**ay**, fl**y**, b**oy**, n**ow**, g**o** | | |
| | Ι ΙΛ | R-coloured | butt**er**, b**ir**d | | |
| --- | |
| ## Technical details | |
| | Component | Role | | |
| |-----------|------| | |
| | **OpenAI Whisper** | Speech-to-text transcription | | |
| | **eSpeak-ng** | Text β target IPA (rule-based phonetic model) | | |
| | **facebook/wav2vec2-lv-60-espeak-cv-ft** | Audio β actual IPA (neural phoneme recognition) | | |
| | **Piper TTS** | High-quality text-to-speech and IPA-to-speech playback | | |
| | **difflib SequenceMatcher** | Phoneme-level alignment between target and actual | | |
| """ | |
| # ββ UI layout ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _DARK_THEME = gr.themes.Base( | |
| primary_hue="blue", | |
| neutral_hue="slate", | |
| ).set( | |
| body_background_fill=_C_BG, | |
| body_background_fill_dark=_C_BG, | |
| body_text_color=_C_TEXT, | |
| body_text_color_dark=_C_TEXT, | |
| background_fill_primary=_C_CARD, | |
| background_fill_primary_dark=_C_CARD, | |
| background_fill_secondary=_C_RAISED, | |
| background_fill_secondary_dark=_C_RAISED, | |
| border_color_primary=_C_BORDER, | |
| border_color_primary_dark=_C_BORDER, | |
| input_background_fill=_C_RAISED, | |
| input_background_fill_dark=_C_RAISED, | |
| input_border_color=_C_BORDER, | |
| input_border_color_dark=_C_BORDER, | |
| input_placeholder_color=_C_MUTED, | |
| input_placeholder_color_dark=_C_MUTED, | |
| block_background_fill=_C_CARD, | |
| block_background_fill_dark=_C_CARD, | |
| block_border_color=_C_BORDER, | |
| block_border_color_dark=_C_BORDER, | |
| block_label_text_color=_C_MUTED, | |
| block_label_text_color_dark=_C_MUTED, | |
| block_title_text_color=_C_TEXT2, | |
| block_title_text_color_dark=_C_TEXT2, | |
| button_secondary_background_fill=_C_RAISED, | |
| button_secondary_background_fill_dark=_C_RAISED, | |
| button_secondary_background_fill_hover=_C_BORDER, | |
| button_secondary_background_fill_hover_dark=_C_BORDER, | |
| button_secondary_border_color=_C_BORDER, | |
| button_secondary_border_color_dark=_C_BORDER, | |
| button_secondary_text_color=_C_TEXT, | |
| button_secondary_text_color_dark=_C_TEXT, | |
| checkbox_background_color=_C_RAISED, | |
| checkbox_background_color_dark=_C_RAISED, | |
| table_even_background_fill=_C_CARD, | |
| table_even_background_fill_dark=_C_CARD, | |
| table_odd_background_fill=_C_RAISED, | |
| table_odd_background_fill_dark=_C_RAISED, | |
| table_border_color=_C_BORDER, | |
| table_border_color_dark=_C_BORDER, | |
| stat_background_fill=_C_CARD, | |
| stat_background_fill_dark=_C_CARD, | |
| color_accent_soft=_C_RAISED, | |
| color_accent_soft_dark=_C_RAISED, | |
| ) | |
| _GLOBAL_CSS = f""" | |
| /* Prose / markdown dark overrides */ | |
| .prose, .prose p, .prose li, .prose strong {{ | |
| color: {_C_TEXT2} !important; | |
| }} | |
| .prose h1, .prose h2, .prose h3, .prose h4 {{ | |
| color: {_C_TEXT} !important; | |
| }} | |
| .prose a {{ color: #60a5fa !important; }} | |
| .prose code {{ | |
| background: {_C_RAISED} !important; | |
| color: {_C_TEXT} !important; | |
| border-radius: 3px; | |
| padding: 1px 4px; | |
| }} | |
| .prose blockquote {{ | |
| border-left-color: {_C_BORDER} !important; | |
| color: {_C_MUTED} !important; | |
| }} | |
| .prose table th {{ | |
| background: {_C_RAISED} !important; | |
| color: {_C_TEXT} !important; | |
| border-color: {_C_BORDER} !important; | |
| }} | |
| .prose table td {{ | |
| color: {_C_TEXT2} !important; | |
| border-color: {_C_BORDER} !important; | |
| }} | |
| .prose table tr:nth-child(even) {{ | |
| background: {_C_CARD} !important; | |
| }} | |
| /* Tab bar */ | |
| .tab-nav button {{ | |
| color: {_C_MUTED} !important; | |
| }} | |
| .tab-nav button.selected {{ | |
| color: {_C_TEXT} !important; | |
| border-bottom-color: #3b82f6 !important; | |
| }} | |
| """ | |
| def build_ui(): | |
| with gr.Blocks(title="IPA Translator", theme=_DARK_THEME, css=_GLOBAL_CSS) as demo: | |
| gr.Markdown( | |
| "# IPA Translator\n" | |
| "Compare **target pronunciation** (eSpeak-ng IPA) with " | |
| "**actual pronunciation** (Wav2Vec2 phoneme recognition)." | |
| ) | |
| # ββ State (declared at Blocks level β required for reliable updates) ββββ | |
| ipa_text_state = gr.State("") | |
| ipa_mode_state = gr.State("text") | |
| kb_visible_state = gr.State(False) | |
| with gr.Tabs(): | |
| # ββ Tab 1: Analyser ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.Tab("π Analyser"): | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| audio_input = gr.Audio( | |
| sources=["upload", "microphone"], | |
| type="filepath", | |
| label="Audio recording (optional if text is provided)", | |
| ) | |
| text_input = gr.Textbox( | |
| label="Text input (optional β overrides Whisper transcription)", | |
| placeholder="Type or paste text here to skip transcriptionβ¦", | |
| lines=2, | |
| ) | |
| with gr.Row(): | |
| whisper_size = gr.Dropdown( | |
| choices=WHISPER_SIZES, | |
| value="base", | |
| label="Whisper model", | |
| ) | |
| language = gr.Dropdown( | |
| choices=list(LANGUAGES.keys()), | |
| value="English (US)", | |
| label="Language", | |
| ) | |
| analyze_btn = gr.Button("Analyze", variant="primary") | |
| with gr.Column(scale=2): | |
| # ββ Two-column IPA result display ββββββββββββββββββ | |
| # Audio mode: left = Actual IPA, right = text + Target IPA | |
| # Text mode: left = Target IPA, right = transcribed text | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=1, min_width=200): | |
| # LEFT: Actual IPA (audio) or Target IPA (text) | |
| ipa_out = gr.HTML() | |
| with gr.Column(scale=1, min_width=200): | |
| # RIGHT: transcribed text + Target IPA (audio mode) | |
| text_out = gr.Textbox( | |
| label="Text used for target IPA", interactive=False | |
| ) | |
| target_ipa_out = gr.HTML(visible=False) | |
| # ββ Edit controls (full-width, below both columns) ββ | |
| ipa_edit_box = gr.Textbox( | |
| label="Edit IPA", | |
| interactive=True, | |
| visible=False, | |
| lines=2, | |
| placeholder="Edit the IPA symbols hereβ¦", | |
| elem_id="ipa-edit-box", | |
| ) | |
| ipa_keyboard = gr.HTML(_IPA_KEYBOARD_HTML, visible=False) | |
| with gr.Row(): | |
| modify_btn = gr.Button("βοΈ Modify", visible=False, size="sm") | |
| kb_btn = gr.Button("β¨ IPA Keyboard", visible=False, size="sm") | |
| apply_btn = gr.Button("β Apply", visible=False, size="sm", | |
| variant="primary") | |
| ipa_audio_out = gr.Audio( | |
| label="Listen to pronunciation", | |
| interactive=False, | |
| type="filepath", | |
| ) | |
| alignment_out = gr.HTML(label="Phoneme comparison") | |
| gr.Markdown( | |
| "---\n" | |
| "**Models used:** " | |
| "[OpenAI Whisper](https://github.com/openai/whisper) Β· " | |
| "[facebook/wav2vec2-lv-60-espeak-cv-ft](https://huggingface.co/facebook/wav2vec2-lv-60-espeak-cv-ft) Β· " | |
| "[eSpeak-ng](https://github.com/espeak-ng/espeak-ng)" | |
| ) | |
| # ββ Tab 2: User Guide ββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.Tab("π User Guide"): | |
| gr.Markdown(_USER_GUIDE_MD) | |
| # ββ Event handlers (declared at Blocks level, after Tabs close) ββββββββ | |
| analyze_btn.click( | |
| fn=analyze, | |
| inputs=[audio_input, text_input, whisper_size, language], | |
| outputs=[text_out, target_ipa_out, ipa_out, ipa_audio_out, alignment_out, | |
| ipa_text_state, ipa_mode_state, | |
| modify_btn, ipa_edit_box, apply_btn, kb_btn, ipa_keyboard], | |
| ) | |
| # Modify: swap HTML display β editable textbox; show keyboard button | |
| modify_btn.click( | |
| fn=lambda ipa: ( | |
| gr.update(visible=False), # ipa_out | |
| gr.update(value=ipa, visible=True), # ipa_edit_box | |
| gr.update(visible=False), # modify_btn | |
| gr.update(visible=True), # kb_btn | |
| gr.update(visible=False), # apply_btn (not yet) | |
| ), | |
| inputs=[ipa_text_state], | |
| outputs=[ipa_out, ipa_edit_box, modify_btn, kb_btn, apply_btn], | |
| ) | |
| # IPA Keyboard toggle: show/hide keyboard; reveal Apply on first open | |
| def toggle_kb(currently_visible): | |
| new_vis = not currently_visible | |
| return gr.update(visible=new_vis), gr.update(visible=True), new_vis | |
| kb_btn.click( | |
| fn=toggle_kb, | |
| inputs=[kb_visible_state], | |
| outputs=[ipa_keyboard, apply_btn, kb_visible_state], | |
| ) | |
| # Apply: re-render HTML from edited text, regenerate audio, hide edit UI | |
| apply_btn.click( | |
| fn=apply_ipa_edit, | |
| inputs=[ipa_edit_box, language, ipa_mode_state], | |
| outputs=[ipa_out, ipa_edit_box, modify_btn, kb_btn, apply_btn, | |
| ipa_keyboard, ipa_text_state, ipa_audio_out], | |
| ) | |
| # Mutual exclusion: adding audio clears text, typing text clears audio | |
| audio_input.change( | |
| fn=lambda a: gr.update(value="") if a is not None else gr.update(), | |
| inputs=[audio_input], | |
| outputs=[text_input], | |
| ) | |
| text_input.change( | |
| fn=lambda t: gr.update(value=None) if (t and t.strip()) else gr.update(), | |
| inputs=[text_input], | |
| outputs=[audio_input], | |
| ) | |
| return demo | |
| ui = build_ui() | |
| if __name__ == "__main__": | |
| ui.queue().launch(share=True, server_name="0.0.0.0") | |
| else: | |
| ui.queue().launch() | |