IPA-Translator / app.py
Zac Shaw
Add copy button to IPA boxes; make text selectable
fb50dec
Raw
History Blame Contribute Delete
53.8 kB
"""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("&", "&amp;").replace('"', "&quot;")
# 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 &mdash; 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> &nbsp;
<span style="color:#fbbf24">β–  substitution</span> &nbsp;
<span style="color:#fca5a5">β–  omission (expected but not said)</span> &nbsp;
<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()