| """ |
| IPA Chart Explorer — Interactive visual IPA chart with Spanish highlighting. |
| |
| Click any IPA symbol to see its articulatory description, whether it exists |
| in Spanish, and which other languages use it. |
| """ |
|
|
| import gradio as gr |
| from ipa_data import ( |
| CONSONANTS, VOWELS, |
| CONSONANT_PLACES, CONSONANT_MANNERS, |
| VOWEL_HEIGHTS, VOWEL_BACKNESSES, |
| VOWEL_TRAPEZOID_COORDS, |
| get_consonant_at, get_phoneme_info, |
| ) |
|
|
| print("IPA Chart Explorer loading...") |
|
|
|
|
| |
| |
| |
|
|
| def build_consonant_table_html(spanish_only=False): |
| """Build the full IPA consonant chart as an HTML table.""" |
|
|
| |
| place_labels = { |
| "bilabial": "Bilabial", |
| "labiodental": "Labio-\ndental", |
| "dental": "Dental", |
| "alveolar": "Alveolar", |
| "postalveolar": "Post-\nalveolar", |
| "retroflex": "Retroflex", |
| "palatal": "Palatal", |
| "velar": "Velar", |
| "uvular": "Uvular", |
| "pharyngeal": "Pharyn-\ngeal", |
| "glottal": "Glottal", |
| } |
|
|
| rows_html = [] |
|
|
| |
| header_cells = '<th class="manner-header"></th>' |
| for place in CONSONANT_PLACES: |
| label = place_labels[place].replace("\n", "<br>") |
| header_cells += f'<th class="place-header" colspan="2">{label}</th>' |
| rows_html.append(f"<tr>{header_cells}</tr>") |
|
|
| |
| sub_cells = '<th class="manner-header"></th>' |
| for _ in CONSONANT_PLACES: |
| sub_cells += '<th class="voicing-sub">vl</th><th class="voicing-sub">vd</th>' |
| rows_html.append(f"<tr>{sub_cells}</tr>") |
|
|
| |
| for manner in CONSONANT_MANNERS: |
| manner_label = manner.replace("/", " / ").title() |
| cells = f'<th class="manner-header">{manner_label}</th>' |
|
|
| for place in CONSONANT_PLACES: |
| for voicing in ["voiceless", "voiced"]: |
| symbol = get_consonant_at(place, manner, voicing) |
| if symbol: |
| info = CONSONANTS[symbol] |
| is_spanish = info["spanish"] |
|
|
| if spanish_only and not is_spanish: |
| cells += '<td class="ipa-cell empty"></td>' |
| continue |
|
|
| css_class = "ipa-cell spanish" if is_spanish else "ipa-cell" |
| cells += ( |
| f'<td class="{css_class}" ' |
| f'data-symbol="{symbol}" ' |
| f'onclick="selectPhoneme(\'{symbol}\')" ' |
| f'title="{info["name"]}">' |
| f'{symbol}</td>' |
| ) |
| else: |
| cells += '<td class="ipa-cell empty"></td>' |
|
|
| rows_html.append(f"<tr>{cells}</tr>") |
|
|
| return f'<table class="consonant-chart">{"".join(rows_html)}</table>' |
|
|
|
|
| |
| |
| |
|
|
| def build_vowel_trapezoid_svg(spanish_only=False): |
| """Build the IPA vowel trapezoid as an inline SVG.""" |
|
|
| |
| trapezoid_path = "M 80,30 L 420,30 L 350,370 L 205,370 Z" |
|
|
| |
| grid_lines = [] |
| |
| height_y = {"close": 40, "near-close": 95, "close-mid": 145, "mid": 200, |
| "open-mid": 250, "near-open": 305, "open": 355} |
| for height, y_val in height_y.items(): |
| |
| left_x = 80 + (y_val - 30) * (205 - 80) / (370 - 30) |
| right_x = 420 + (y_val - 30) * (350 - 420) / (370 - 30) |
| grid_lines.append( |
| f'<line x1="{left_x}" y1="{y_val}" x2="{right_x}" y2="{y_val}" ' |
| f'class="grid-line"/>' |
| ) |
|
|
| |
| grid_lines.append('<line x1="250" y1="30" x2="275" y2="370" class="grid-line"/>') |
|
|
| |
| height_labels = [] |
| for height, y_val in height_y.items(): |
| label = height.replace("-", "\u2011") |
| height_labels.append( |
| f'<text x="5" y="{y_val + 5}" class="axis-label">{label}</text>' |
| ) |
|
|
| |
| backness_labels = [ |
| '<text x="80" y="20" class="axis-label" text-anchor="middle">Front</text>', |
| '<text x="250" y="20" class="axis-label" text-anchor="middle">Central</text>', |
| '<text x="420" y="20" class="axis-label" text-anchor="middle">Back</text>', |
| ] |
|
|
| |
| vowel_elements = [] |
| for symbol, data in VOWELS.items(): |
| pos_key = (data["height"], data["backness"]) |
| if pos_key not in VOWEL_TRAPEZOID_COORDS: |
| continue |
|
|
| if spanish_only and not data["spanish"]: |
| continue |
|
|
| x, y = VOWEL_TRAPEZOID_COORDS[pos_key] |
|
|
| |
| if data["rounding"] == "unrounded": |
| x -= 15 |
| else: |
| x += 15 |
|
|
| is_spanish = data["spanish"] |
| css_class = "vowel-symbol spanish" if is_spanish else "vowel-symbol" |
|
|
| vowel_elements.append( |
| f'<text x="{x}" y="{y}" class="{css_class}" ' |
| f'data-symbol="{symbol}" ' |
| f'onclick="selectPhoneme(\'{symbol}\')" ' |
| f'style="cursor:pointer">' |
| f'{symbol}</text>' |
| ) |
|
|
| svg = f""" |
| <svg viewBox="-10 0 500 400" class="vowel-trapezoid" xmlns="http://www.w3.org/2000/svg"> |
| <path d="{trapezoid_path}" class="trapezoid-outline"/> |
| {"".join(grid_lines)} |
| {"".join(height_labels)} |
| {"".join(backness_labels)} |
| {"".join(vowel_elements)} |
| </svg> |
| """ |
| return svg |
|
|
|
|
| |
| |
| |
|
|
| CSS = """ |
| <style> |
| .ipa-explorer { font-family: 'Segoe UI', system-ui, sans-serif; max-width: 1100px; margin: 0 auto; } |
| .chart-section { margin-bottom: 30px; } |
| .chart-title { font-size: 1.3em; font-weight: 600; margin-bottom: 10px; color: #333; } |
| |
| /* Consonant chart */ |
| .consonant-chart { border-collapse: collapse; width: 100%; font-size: 0.85em; } |
| .consonant-chart th, .consonant-chart td { border: 1px solid #ccc; padding: 4px 6px; text-align: center; } |
| .place-header { background: #f0f0f0; font-size: 0.8em; font-weight: 600; min-width: 50px; } |
| .manner-header { background: #f0f0f0; font-weight: 600; text-align: right !important; padding-right: 8px !important; white-space: nowrap; } |
| .voicing-sub { background: #f8f8f8; font-size: 0.7em; color: #888; font-style: italic; } |
| |
| .ipa-cell { font-size: 1.3em; cursor: pointer; padding: 6px !important; transition: all 0.15s; } |
| .ipa-cell:hover { background: #e3f2fd; transform: scale(1.1); } |
| .ipa-cell.spanish { background: #e8f5e9; font-weight: 600; } |
| .ipa-cell.spanish:hover { background: #c8e6c9; } |
| .ipa-cell.empty { background: #fafafa; cursor: default; } |
| .ipa-cell.selected { background: #bbdefb !important; outline: 2px solid #1976d2; } |
| |
| /* Vowel trapezoid */ |
| .vowel-trapezoid { max-width: 500px; margin: 0 auto; display: block; } |
| .trapezoid-outline { fill: none; stroke: #999; stroke-width: 1.5; } |
| .grid-line { stroke: #ddd; stroke-width: 0.5; stroke-dasharray: 4 4; } |
| .axis-label { font-size: 11px; fill: #666; font-family: sans-serif; } |
| |
| .vowel-symbol { font-size: 18px; fill: #333; font-family: 'Noto Sans', sans-serif; text-anchor: middle; dominant-baseline: middle; } |
| .vowel-symbol:hover { fill: #1976d2; font-size: 22px; } |
| .vowel-symbol.spanish { fill: #2e7d32; font-weight: bold; } |
| .vowel-symbol.spanish:hover { fill: #1b5e20; } |
| .vowel-symbol.selected { fill: #1976d2 !important; font-size: 22px; } |
| |
| /* Info panel */ |
| .info-panel { background: #f5f5f5; border: 1px solid #ddd; border-radius: 8px; padding: 20px; margin-top: 15px; min-height: 100px; } |
| .info-symbol { font-size: 3em; font-weight: bold; margin-bottom: 10px; } |
| .info-symbol.is-spanish { color: #2e7d32; } |
| .info-name { font-size: 1.1em; color: #555; margin-bottom: 15px; } |
| .info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } |
| .info-item { padding: 8px 12px; background: white; border-radius: 4px; border: 1px solid #eee; } |
| .info-label { font-size: 0.8em; color: #888; text-transform: uppercase; letter-spacing: 0.05em; } |
| .info-value { font-size: 1em; color: #333; margin-top: 2px; } |
| .spanish-badge { display: inline-block; background: #e8f5e9; color: #2e7d32; padding: 2px 10px; border-radius: 12px; font-size: 0.85em; font-weight: 600; } |
| .not-spanish-badge { display: inline-block; background: #f5f5f5; color: #999; padding: 2px 10px; border-radius: 12px; font-size: 0.85em; } |
| |
| /* Legend */ |
| .legend { display: flex; gap: 20px; margin-bottom: 15px; font-size: 0.9em; } |
| .legend-item { display: flex; align-items: center; gap: 6px; } |
| .legend-swatch { width: 16px; height: 16px; border-radius: 3px; border: 1px solid #ccc; } |
| .legend-swatch.spanish { background: #e8f5e9; } |
| .legend-swatch.other { background: white; } |
| </style> |
| """ |
|
|
| JAVASCRIPT = """ |
| <script> |
| // Store all phoneme data passed from Python |
| let phonemeData = PHONEME_DATA_PLACEHOLDER; |
| |
| function selectPhoneme(symbol) { |
| // Remove previous selection |
| document.querySelectorAll('.selected').forEach(el => el.classList.remove('selected')); |
| |
| // Highlight selected cell |
| document.querySelectorAll(`[data-symbol="${symbol}"]`).forEach(el => { |
| el.classList.add('selected'); |
| }); |
| |
| // Update info panel |
| let data = phonemeData[symbol]; |
| if (!data) return; |
| |
| let panel = document.getElementById('info-panel'); |
| let spanishHtml = data.spanish |
| ? `<span class="spanish-badge">Used in Spanish</span>` |
| : `<span class="not-spanish-badge">Not in Spanish</span>`; |
| |
| let spanishExample = data.spanish && data.spanish_example |
| ? `<div class="info-item"><div class="info-label">Spanish Example</div><div class="info-value">${data.spanish_example}</div></div>` |
| : ''; |
| |
| let symbolClass = data.spanish ? 'info-symbol is-spanish' : 'info-symbol'; |
| let typeLabel = data.type === 'consonant' ? 'Consonant' : 'Vowel'; |
| |
| let featureHtml = ''; |
| if (data.type === 'consonant') { |
| featureHtml = ` |
| <div class="info-item"><div class="info-label">Place</div><div class="info-value">${data.place}</div></div> |
| <div class="info-item"><div class="info-label">Manner</div><div class="info-value">${data.manner}</div></div> |
| <div class="info-item"><div class="info-label">Voicing</div><div class="info-value">${data.voicing}</div></div> |
| `; |
| } else { |
| featureHtml = ` |
| <div class="info-item"><div class="info-label">Height</div><div class="info-value">${data.height}</div></div> |
| <div class="info-item"><div class="info-label">Backness</div><div class="info-value">${data.backness}</div></div> |
| <div class="info-item"><div class="info-label">Rounding</div><div class="info-value">${data.rounding}</div></div> |
| `; |
| } |
| |
| panel.innerHTML = ` |
| <div class="${symbolClass}">/${symbol}/</div> |
| <div class="info-name">${data.name} ${spanishHtml}</div> |
| <div class="info-grid"> |
| <div class="info-item"><div class="info-label">Type</div><div class="info-value">${typeLabel}</div></div> |
| ${featureHtml} |
| ${spanishExample} |
| <div class="info-item"><div class="info-label">Also Found In</div><div class="info-value">${data.languages.join(', ')}</div></div> |
| </div> |
| `; |
| } |
| </script> |
| """ |
|
|
|
|
| def build_phoneme_data_json(): |
| """Build a JSON-compatible dict of all phoneme data for JavaScript.""" |
| import json |
| data = {} |
| for sym, info in CONSONANTS.items(): |
| data[sym] = { |
| "type": "consonant", |
| "name": info["name"], |
| "place": info["place"], |
| "manner": info["manner"], |
| "voicing": info["voicing"], |
| "spanish": info["spanish"], |
| "spanish_example": info["spanish_example"], |
| "languages": info["languages"], |
| } |
| for sym, info in VOWELS.items(): |
| data[sym] = { |
| "type": "vowel", |
| "name": info["name"], |
| "height": info["height"], |
| "backness": info["backness"], |
| "rounding": info["rounding"], |
| "spanish": info["spanish"], |
| "spanish_example": info["spanish_example"], |
| "languages": info["languages"], |
| } |
| return json.dumps(data) |
|
|
|
|
| def build_full_page(spanish_only=False): |
| """Build the complete HTML page with charts and info panel.""" |
| import json |
|
|
| consonant_html = build_consonant_table_html(spanish_only) |
| vowel_svg = build_vowel_trapezoid_svg(spanish_only) |
| phoneme_json = build_phoneme_data_json() |
|
|
| js = JAVASCRIPT.replace("PHONEME_DATA_PLACEHOLDER", phoneme_json) |
|
|
| mode_label = "Spanish Phonemes" if spanish_only else "Full IPA" |
|
|
| html = f""" |
| {CSS} |
| {js} |
| <div class="ipa-explorer"> |
| <div class="legend"> |
| <div class="legend-item"> |
| <div class="legend-swatch spanish"></div> |
| <span>Spanish phoneme</span> |
| </div> |
| <div class="legend-item"> |
| <div class="legend-swatch other"></div> |
| <span>Other languages</span> |
| </div> |
| <div style="margin-left: auto; color: #888; font-style: italic;"> |
| Showing: {mode_label} — Click any symbol for details |
| </div> |
| </div> |
| |
| <div class="chart-section"> |
| <div class="chart-title">Consonants (Place × Manner of Articulation)</div> |
| {consonant_html} |
| </div> |
| |
| <div class="chart-section"> |
| <div class="chart-title">Vowels (Height × Backness)</div> |
| {vowel_svg} |
| </div> |
| |
| <div id="info-panel" class="info-panel"> |
| <div style="color: #999; font-style: italic; text-align: center; padding: 30px;"> |
| Click any IPA symbol above to see its details |
| </div> |
| </div> |
| </div> |
| """ |
| return html |
|
|
|
|
| |
| |
| |
|
|
| def update_chart(mode): |
| """Rebuild chart based on selected display mode.""" |
| spanish_only = (mode == "Spanish Only") |
| return build_full_page(spanish_only) |
|
|
|
|
| with gr.Blocks( |
| title="IPA Chart Explorer", |
| theme=gr.themes.Soft(), |
| ) as demo: |
| gr.Markdown( |
| "# IPA Chart Explorer\n" |
| "Interactive International Phonetic Alphabet chart. " |
| "Spanish phonemes are highlighted in green. " |
| "Click any symbol to see where it lives in the human sound system." |
| ) |
|
|
| mode = gr.Radio( |
| choices=["Full IPA", "Spanish Only"], |
| value="Full IPA", |
| label="Display Mode", |
| ) |
|
|
| chart_html = gr.HTML(value=build_full_page(spanish_only=False)) |
|
|
| mode.change(fn=update_chart, inputs=[mode], outputs=[chart_html]) |
|
|
| gr.Markdown( |
| "---\n" |
| "**How to read the consonant chart:** Columns = where in the mouth " |
| "(lips → throat). Rows = how the air flows (stopped, through the nose, etc.). " |
| "Each cell has two slots: voiceless (left) and voiced (right).\n\n" |
| "**How to read the vowel trapezoid:** Top = tongue high, bottom = tongue low. " |
| "Left = tongue forward, right = tongue back. Pairs show unrounded (left) and " |
| "rounded (right) at each position." |
| ) |
|
|
|
|
| print("IPA Chart Explorer ready!") |
| demo.launch() |
|
|