profplate's picture
Create app.py
171ca6d verified
"""
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} &mdash; Click any symbol for details
</div>
</div>
<div class="chart-section">
<div class="chart-title">Consonants (Place &times; Manner of Articulation)</div>
{consonant_html}
</div>
<div class="chart-section">
<div class="chart-title">Vowels (Height &times; 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()