Spaces:
Sleeping
Sleeping
| """ | |
| 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...") | |
| # ============================================================================= | |
| # HTML/CSS FOR THE CONSONANT CHART | |
| # ============================================================================= | |
| def build_consonant_table_html(spanish_only=False): | |
| """Build the full IPA consonant chart as an HTML table.""" | |
| # Place labels (abbreviated for column headers) | |
| 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 row | |
| 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-header for voiceless/voiced | |
| 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>") | |
| # Data rows | |
| 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>' | |
| # ============================================================================= | |
| # SVG FOR THE VOWEL TRAPEZOID | |
| # ============================================================================= | |
| def build_vowel_trapezoid_svg(spanish_only=False): | |
| """Build the IPA vowel trapezoid as an inline SVG.""" | |
| # Trapezoid outline points | |
| trapezoid_path = "M 80,30 L 420,30 L 350,370 L 205,370 Z" | |
| # Grid lines (dashed) | |
| grid_lines = [] | |
| # Horizontal lines for height levels | |
| 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(): | |
| # Calculate x endpoints based on trapezoid slope | |
| 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"/>' | |
| ) | |
| # Vertical-ish lines for backness | |
| grid_lines.append('<line x1="250" y1="30" x2="275" y2="370" class="grid-line"/>') | |
| # Height labels (left side) | |
| height_labels = [] | |
| for height, y_val in height_y.items(): | |
| label = height.replace("-", "\u2011") # non-breaking hyphen | |
| height_labels.append( | |
| f'<text x="5" y="{y_val + 5}" class="axis-label">{label}</text>' | |
| ) | |
| # Backness labels (top) | |
| 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 symbols | |
| 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] | |
| # Offset: unrounded left, rounded right | |
| 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 | |
| # ============================================================================= | |
| # COMBINED HTML PAGE | |
| # ============================================================================= | |
| 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 | |
| # ============================================================================= | |
| # GRADIO APP | |
| # ============================================================================= | |
| 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() | |