Tajweed-AI / ui /builder.py
hetchyy's picture
Add GPU quota fallback to CPU with user notification
df88bcd
"""
Main interface builder for Gradio application.
Assembles all UI components and wires event handlers.
"""
import gradio as gr
from i8n import t, get_locale, set_locale, get_available_locales
from config import (
DEV_TAB_VISIBLE,
IS_HF_SPACE,
IQLAB_IKHFAA_SHAFAWI_SOUND,
GHUNNAH_LENGTH,
MADD_TABII_LENGTH,
MADD_WAJIB_MUTTASIL_LENGTH,
MADD_JAIZ_MUNFASIL_LENGTH,
MADD_LAZIM_LENGTH,
MADD_ARID_LISSUKUN_LENGTH,
MADD_LEEN_LENGTH,
DURATION_TOLERANCE,
DURATION_TOLERANCE_MIN,
DURATION_TOLERANCE_MAX,
DURATION_TOLERANCE_STEP,
LEFT_COLUMN_SCALE,
# Setters for syncing localStorage values to config
set_duration_tolerance,
set_iqlab_ikhfaa_shafawi_sound,
set_ghunnah_length,
set_madd_jaiz_munfasil_length,
set_madd_arid_lissukun_length,
set_madd_leen_length,
set_madd_wajib_muttasil_length,
set_usage_log_audio,
USAGE_LOG_AUDIO,
)
from shared_state import get_model_bundles
from utils.phonemizer_utils import get_chapter_list
from ui.styles import create_app_blocks, inject_css_fallback, get_all_head_js
from ui.components import (
create_verse_selector,
create_control_buttons,
get_initial_verse,
format_no_verse_message,
)
from ui.components.reference_audio import create_reference_audio_components
from ui.handlers import on_verse_selection_change
from ui.handlers.factory import InterfaceContext, HandlerFactory
from ui.handlers.wiring import wire_all_events
from ui.handlers.reference_audio import (
on_ref_audio_prev_click,
on_ref_audio_next_click,
on_ref_audio_slider_change,
init_ref_audio_for_verse_range,
)
from recitation_engine.reference_audio import get_verse_audio_path, parse_verse_range
from ui.handlers.lazy_audio import fetch_segment_clip_lazy
def _on_locale_load(cached_settings: dict, request: gr.Request):
"""
Handle locale initialization and settings sync on page load.
Reads the __locale query parameter and updates all component labels
to use the correct language. Also syncs localStorage settings to config.py
to ensure table rendering uses the same values shown in the UI.
Args:
cached_settings: Dict of settings from localStorage (passed from JS)
request: Gradio request object with query params
"""
# Read locale from query parameter
locale = request.query_params.get("__locale", "en")
# Validate and set locale
if locale in get_available_locales():
set_locale(locale)
else:
locale = "en"
set_locale("en")
# ----- Sync localStorage settings to config.py -----
# This ensures table rendering uses the same values as the UI
if cached_settings:
if cached_settings.get("duration_tolerance") is not None:
set_duration_tolerance(float(cached_settings["duration_tolerance"]))
if cached_settings.get("iqlab_ikhfaa_sound") is not None:
set_iqlab_ikhfaa_shafawi_sound(cached_settings["iqlab_ikhfaa_sound"])
if cached_settings.get("ghunnah_length") is not None:
set_ghunnah_length(int(cached_settings["ghunnah_length"]))
if cached_settings.get("madd_jaiz_length") is not None:
set_madd_jaiz_munfasil_length(int(cached_settings["madd_jaiz_length"]))
if cached_settings.get("madd_arid_length") is not None:
set_madd_arid_lissukun_length(int(cached_settings["madd_arid_length"]))
if cached_settings.get("madd_leen_length") is not None:
set_madd_leen_length(int(cached_settings["madd_leen_length"]))
if cached_settings.get("madd_wajib_length") is not None:
set_madd_wajib_muttasil_length(int(cached_settings["madd_wajib_length"]))
if cached_settings.get("save_audio") is not None:
set_usage_log_audio(bool(cached_settings["save_audio"]))
# Get current values (either from localStorage or config defaults)
current_duration_tolerance = (cached_settings.get("duration_tolerance") or DURATION_TOLERANCE) if cached_settings else DURATION_TOLERANCE
current_iqlab_sound = (cached_settings.get("iqlab_ikhfaa_sound") or IQLAB_IKHFAA_SHAFAWI_SOUND) if cached_settings else IQLAB_IKHFAA_SHAFAWI_SOUND
current_ghunnah = (cached_settings.get("ghunnah_length") or GHUNNAH_LENGTH) if cached_settings else GHUNNAH_LENGTH
current_madd_jaiz = (cached_settings.get("madd_jaiz_length") or MADD_JAIZ_MUNFASIL_LENGTH) if cached_settings else MADD_JAIZ_MUNFASIL_LENGTH
current_madd_arid = (cached_settings.get("madd_arid_length") or MADD_ARID_LISSUKUN_LENGTH) if cached_settings else MADD_ARID_LISSUKUN_LENGTH
current_madd_leen = (cached_settings.get("madd_leen_length") or MADD_LEEN_LENGTH) if cached_settings else MADD_LEEN_LENGTH
current_madd_wajib = (cached_settings.get("madd_wajib_length") or MADD_WAJIB_MUTTASIL_LENGTH) if cached_settings else MADD_WAJIB_MUTTASIL_LENGTH
current_save_audio = cached_settings.get("save_audio") if (cached_settings and cached_settings.get("save_audio") is not None) else USAGE_LOG_AUDIO
# Build updated header HTML with correct locale
# Note: RTL script is applied via app.load(js=...) since scripts in gr.HTML don't execute
rtl_dir = "direction: rtl;" if locale == "ar" else ""
header_html = f"""
<div style="text-align: center;">
<h1 style="margin-bottom: 0;">{t("header.title")}</h1>
<p style="margin-top: 4px; font-size: 0.9em; color: #888; {rtl_dir}">
1. {t("header.step1")} · 2. {t("header.step2")} · 3. {t("header.step3")}
</p>
</div>
"""
# Generate chapter choices with correct locale formatting
chapters = get_chapter_list()
is_arabic = locale == "ar"
chapter_choices = [
f"{num} - {name_ar}" if is_arabic and name_ar else f"{num} - {name_en} ({name_ar})" if name_ar else f"{num} - {name_en}"
for num, name_en, name_ar in chapters
]
# Default to first chapter (Al-Fatiha) with correct locale formatting
initial_chapter_value = chapter_choices[0] if chapter_choices else None
# Return updates for all translatable components
# Order must match the outputs list in the .load() event
# Now includes BOTH label AND value for settings components
return [
# 1. Header HTML
gr.update(value=header_html),
# 2. Language toggle (set to current locale)
gr.update(value=locale),
# 3. Audio input label
gr.update(label=t("audio.label")),
# 4. Analyze button
gr.update(value=t("controls.analyze")),
# 5. Duration tolerance slider - now includes synced value
gr.update(
label=t("settings.duration_tolerance"),
info=t("settings.duration_tolerance_info"),
value=float(current_duration_tolerance)
),
# 6. Iqlab/Ikhfaa sound radio - now includes synced value
gr.update(
label=t("tajweed_settings.iqlab_ikhfaa_sound"),
choices=[
(t("tajweed_settings.meem_ghunnah"), "meem ghunnah"),
(t("tajweed_settings.ikhfaa"), "ikhfaa")
],
value=current_iqlab_sound
),
# 7. Ghunnah length radio - now includes synced value
gr.update(label=t("tajweed_settings.ghunnah_length"), value=int(current_ghunnah)),
# 8. Madd Jaiz length radio - now includes synced value
gr.update(label=t("tajweed_settings.jaiz_munfasil"), value=int(current_madd_jaiz)),
# 9. Madd Arid length radio - now includes synced value
gr.update(label=t("tajweed_settings.arid_lissukun"), value=int(current_madd_arid)),
# 10. Madd Leen length radio - now includes synced value
gr.update(label=t("tajweed_settings.leen"), value=int(current_madd_leen)),
# 11. Madd Wajib length radio - now includes synced value
gr.update(label=t("tajweed_settings.wajib_muttasil"), value=int(current_madd_wajib)),
# 12. Madd Lazim length radio (fixed value, no sync needed)
gr.update(label=t("tajweed_settings.lazim")),
# 13. Madd Tabii length radio (fixed value, no sync needed)
gr.update(label=t("tajweed_settings.tabii")),
# 14. Error sort dropdown
gr.update(choices=[
(t("sort_options.text_order"), "text_order"),
(t("sort_options.by_errors"), "by_errors")
], value="text_order"),
# 15. Ghunnah sort dropdown
gr.update(choices=[
(t("sort_options.text_order"), "text_order"),
(t("sort_options.by_rule"), "by_rule"),
(t("sort_options.by_errors"), "by_errors")
], value="text_order"),
# 16. Madd sort dropdown
gr.update(choices=[
(t("sort_options.text_order"), "text_order"),
(t("sort_options.by_rule"), "by_rule"),
(t("sort_options.by_errors"), "by_errors")
], value="text_order"),
# 17. From chapter dropdown (update choices and value with locale-aware formatting)
gr.update(label=t("verse_selector.surah"), choices=chapter_choices, value=initial_chapter_value),
# 18. From verse dropdown
gr.update(label=t("verse_selector.from_verse")),
# 19. To verse dropdown
gr.update(label=t("verse_selector.to_verse")),
# 20. Random button
gr.update(value=t("controls.random_verse")),
# 21. Reset button
gr.update(value=t("controls.reset")),
# 22. Multi models checkbox
gr.update(label=t("controls.run_all_models")),
# 23. Save audio checkbox - includes synced value
gr.update(label=t("settings.save_audio"), info=t("settings.save_audio_info"), value=bool(current_save_audio)),
# 24. Settings accordion
gr.update(label=t("settings.header")),
]
def _compute_initial_state(chapters_list: list) -> dict:
"""
Compute initial state by phonemizing the default verse selection.
Args:
chapters_list: List of chapter strings
Returns:
Dict with initial values for all components
"""
initial_selection = get_initial_verse(chapters_list)
init_result = on_verse_selection_change(
initial_selection[0], initial_selection[1], initial_selection[2]
)
(init_arabic_html, init_phonemes, init_verse_ref, init_canonical,
init_ghunnah_html, init_madd_html, init_audio_uri) = init_result
# Initialize reference audio for the default verse
init_ref_audio = {
"verse_keys": [],
"verse_index": 0,
"audio_path": None,
"show_nav": False,
"slider_max": 0,
}
if init_verse_ref:
try:
verse_keys = parse_verse_range(init_verse_ref)
if verse_keys:
count = len(verse_keys)
first_key = verse_keys[0]
audio_path = get_verse_audio_path(first_key)
init_ref_audio = {
"verse_keys": verse_keys,
"verse_index": 0,
"audio_path": audio_path,
"show_nav": count > 1,
"slider_max": count - 1,
}
except Exception as exc:
print(f"[UI] Failed to load initial reference audio: {exc}")
return {
"selection": initial_selection,
"arabic_html": init_arabic_html,
"phonemes": init_phonemes,
"verse_ref": init_verse_ref,
"canonical": init_canonical,
"ghunnah_html": init_ghunnah_html,
"madd_html": init_madd_html,
"ref_audio": init_ref_audio,
}
def build_interface() -> gr.Blocks:
"""
Build and return the complete Gradio interface.
This is the main entry point called from app.py.
Returns:
Configured gr.Blocks application
"""
# ----- Runtime context -----
model_bundles = get_model_bundles()
multi_visible = len(model_bundles) > 1
chapters_list = get_chapter_list()
context = InterfaceContext(
chapters_list=chapters_list,
multi_visible=multi_visible
)
handlers = HandlerFactory(context)
# ----- Initial state -----
init = _compute_initial_state(chapters_list)
# ----- Create app block -----
# Head JS: locale sync (for Gradio i18n) + segment clip lazy loading
head_js = get_all_head_js(polling_enabled=False)
app, use_css_fallback = create_app_blocks(head=head_js)
with app:
# CSS fallback for older Gradio
if use_css_fallback:
inject_css_fallback()
# ----- Create all components -----
components = {}
states = {}
# Header with title
init_rtl_dir = "direction: rtl;" if get_locale() == "ar" else ""
components["header_html"] = gr.HTML(
f"""
<div style="text-align: center;">
<h1 style="margin-bottom: 0;">{t("header.title")}</h1>
<p style="margin-top: 4px; font-size: 0.9em; color: #888; {init_rtl_dir}">
1. {t("header.step1")} · 2. {t("header.step2")} · 3. {t("header.step3")}
</p>
</div>
"""
)
# Language toggle using native Gradio Radio (styled via CSS)
current_locale = get_locale()
with gr.Row(elem_classes=["lang-toggle-container"]):
components["lang_toggle"] = gr.Radio(
choices=[("English", "en"), ("العربية", "ar")],
value=current_locale,
show_label=False,
container=False,
elem_classes=["lang-toggle-radio"],
)
# Hidden components to hold session values (must be outside Row/Column for event wiring)
states["expected_phonemes"] = gr.Textbox(
value=init["phonemes"], visible=False, interactive=False
)
states["verse_ref"] = gr.Textbox(
value=init["verse_ref"], visible=False, interactive=False
)
states["canonical_text"] = gr.Textbox(
value=init["canonical"], visible=False, interactive=False
)
states["is_random"] = gr.Checkbox(value=False, visible=False, interactive=False)
states["is_upload"] = gr.Checkbox(value=False, visible=False, interactive=False)
states["is_from_verse_change"] = gr.Checkbox(value=False, visible=False, interactive=False)
states["has_analyzed"] = gr.Checkbox(value=False, visible=False, interactive=False)
states["last_analysis_mode"] = gr.Textbox(
value="", visible=False, interactive=False
)
# Hidden component to receive cached settings from localStorage on page load
# This bridges JS → Python so config.py gets synced with UI values
states["cached_settings"] = gr.JSON(value=None, visible=False)
# Hidden components for segment clip lazy loading (error analysis audio clips)
gr.HTML('<div id="lazy-audio-api-wrapper" style="display:none !important; height:0; overflow:hidden;"></div>')
states["segment_clip_request"] = gr.Textbox(
value="",
visible=True,
interactive=True,
elem_id="segment-clip-request",
elem_classes=["lazy-audio-hidden"]
)
states["segment_clip_response"] = gr.Textbox(
value="",
visible=True,
interactive=False,
elem_id="segment-clip-response",
elem_classes=["lazy-audio-hidden"]
)
# ----- Two-column layout -----
with gr.Row():
# ========== LEFT COLUMN ==========
with gr.Column(scale=LEFT_COLUMN_SCALE):
# Verse selector
verse_components, _ = create_verse_selector(
horizontal=True,
initial_selection=init["selection"],
chapters=chapters_list
)
components["from_chapter"] = verse_components["from_chapter"]
components["from_verse"] = verse_components["from_verse"]
components["to_verse"] = verse_components["to_verse"]
# Control buttons (segmentation is now auto-detected by VAD)
controls = create_control_buttons(multi_visible)
components["random_btn"] = controls["random_btn"]
components["reset_btn"] = controls["reset_btn"]
components["multi_models_cb"] = controls["multi_models_cb"]
# Audio input
components["audio_input"] = gr.Audio(
sources=["microphone", "upload"],
type="filepath",
label=t("audio.label"),
show_label=False
)
# Analyze button (always visible, disabled until audio is present)
with gr.Column(elem_classes=["analyze-btn-container"]):
components["analyze_btn"] = gr.Button(
t("controls.analyze"), variant="primary", visible=True, interactive=False
)
components["save_audio_cb"] = gr.Checkbox(
value=USAGE_LOG_AUDIO,
label=t("settings.save_audio"),
info=t("settings.save_audio_info"),
elem_id="setting-save-audio"
)
# Recitation settings (expanded by default)
with gr.Accordion(t("settings.header"), open=True) as settings_accordion:
# 1. Duration Tolerance
with gr.Group():
components["duration_tolerance"] = gr.Slider(
minimum=DURATION_TOLERANCE_MIN,
maximum=DURATION_TOLERANCE_MAX,
step=DURATION_TOLERANCE_STEP,
value=DURATION_TOLERANCE,
label=t("settings.duration_tolerance"),
info=t("settings.duration_tolerance_info"),
elem_id="setting-duration-tolerance"
)
# 2. Ghunnah settings
with gr.Group():
with gr.Row():
with gr.Column(scale=3, min_width=150):
components["iqlab_ikhfaa_sound"] = gr.Radio(
choices=[
(t("tajweed_settings.meem_ghunnah"), "meem ghunnah"),
(t("tajweed_settings.ikhfaa"), "ikhfaa")
],
value=IQLAB_IKHFAA_SHAFAWI_SOUND,
label=t("tajweed_settings.iqlab_ikhfaa_sound"),
elem_id="setting-iqlab-ikhfaa-sound"
)
with gr.Column(scale=2, min_width=100):
components["ghunnah_length"] = gr.Radio(
choices=[2, 3],
value=GHUNNAH_LENGTH,
label=t("tajweed_settings.ghunnah_length"),
elem_id="setting-ghunnah-length"
)
# 3. Madd length settings
with gr.Group():
with gr.Row(elem_classes=["madd-settings-row"]):
with gr.Column(scale=3, min_width=150):
components["madd_jaiz_length"] = gr.Radio(
choices=[2, 4, 5],
value=MADD_JAIZ_MUNFASIL_LENGTH,
label=t("tajweed_settings.jaiz_munfasil"),
elem_id="setting-madd-jaiz-length"
)
components["madd_arid_length"] = gr.Radio(
choices=[2, 4, 6],
value=MADD_ARID_LISSUKUN_LENGTH,
label=t("tajweed_settings.arid_lissukun"),
elem_id="setting-madd-arid-length"
)
components["madd_leen_length"] = gr.Radio(
choices=[2, 4, 6],
value=MADD_LEEN_LENGTH,
label=t("tajweed_settings.leen"),
elem_id="setting-madd-leen-length"
)
with gr.Column(scale=2, min_width=100):
components["madd_wajib_length"] = gr.Radio(
choices=[4, 5],
value=MADD_WAJIB_MUTTASIL_LENGTH,
label=t("tajweed_settings.wajib_muttasil"),
elem_id="setting-madd-wajib-length"
)
components["madd_lazim_length"] = gr.Radio(
choices=[6],
value=MADD_LAZIM_LENGTH,
label=t("tajweed_settings.lazim"),
interactive=False
)
components["madd_tabii_length"] = gr.Radio(
choices=[2],
value=MADD_TABII_LENGTH,
label=t("tajweed_settings.tabii"),
interactive=False
)
components["settings_accordion"] = settings_accordion
# ========== RIGHT COLUMN ==========
with gr.Column(scale=10 - LEFT_COLUMN_SCALE, elem_classes=["right-column"]):
# Arabic display
components["arabic_display"] = gr.HTML(
value=init["arabic_html"] or format_no_verse_message(),
label="Selected Verse"
)
# Reference audio section (native Gradio components)
ref_audio_init = init["ref_audio"]
ref_components = create_reference_audio_components(
initial_audio_path=ref_audio_init["audio_path"]
)
# Store component references
components["ref_audio_nav_row"] = ref_components["nav_row"]
components["ref_audio_prev_btn"] = ref_components["prev_btn"]
components["ref_audio_next_btn"] = ref_components["next_btn"]
components["ref_audio_slider"] = ref_components["slider"]
components["ref_audio_player"] = ref_components["audio_player"]
states["ref_audio_verse_index"] = ref_components["verse_index"]
states["ref_audio_verse_keys"] = ref_components["verse_keys"]
# Set initial values (audio_path is passed to create_reference_audio_components)
components["ref_audio_nav_row"].visible = ref_audio_init["show_nav"]
components["ref_audio_slider"].maximum = ref_audio_init["slider_max"]
states["ref_audio_verse_index"].value = ref_audio_init["verse_index"]
states["ref_audio_verse_keys"].value = ref_audio_init["verse_keys"]
# Analysis tabs
with gr.Tabs():
with gr.Tab(t("tabs.error_analysis")):
with gr.Row(elem_classes=["sort-row"]):
components["error_sort_dropdown"] = gr.Radio(
choices=[
(t("sort_options.text_order"), "text_order"),
(t("sort_options.by_errors"), "by_errors")
],
value="text_order",
show_label=False,
interactive=True,
container=False,
visible=False,
elem_classes=["sort-toggle"],
)
components["error_display"] = gr.HTML(value="", label=t("tabs.error_analysis"))
with gr.Tab(t("tabs.ghunnah_analysis")):
with gr.Row(elem_classes=["sort-row"]):
components["ghunnah_sort_dropdown"] = gr.Radio(
choices=[
(t("sort_options.text_order"), "text_order"),
(t("sort_options.by_rule"), "by_rule"),
(t("sort_options.by_errors"), "by_errors")
],
value="text_order",
show_label=False,
interactive=True,
container=False,
visible=False,
elem_classes=["sort-toggle"],
)
components["ghunnah_display"] = gr.HTML(
value=init["ghunnah_html"], label=t("tabs.ghunnah_analysis")
)
with gr.Tab(t("tabs.madd_analysis")):
with gr.Row(elem_classes=["sort-row"]):
components["madd_sort_dropdown"] = gr.Radio(
choices=[
(t("sort_options.text_order"), "text_order"),
(t("sort_options.by_rule"), "by_rule"),
(t("sort_options.by_errors"), "by_errors")
],
value="text_order",
show_label=False,
interactive=True,
container=False,
visible=False,
elem_classes=["sort-toggle"],
)
components["madd_display"] = gr.HTML(
value=init["madd_html"], label=t("tabs.madd_analysis")
)
if DEV_TAB_VISIBLE:
with gr.Tab("Dev"):
with gr.Row():
with gr.Column(scale=1):
components["dev_canonical_phonemes"] = gr.Textbox(
label="Canonical Phonemes (from phonemizer)",
value=init["phonemes"],
lines=3,
interactive=False,
)
with gr.Column(scale=1):
components["dev_detected_phonemes"] = gr.Textbox(
label="Detected Phonemes (edit to simulate errors)",
value=init["phonemes"],
lines=3,
interactive=True,
placeholder="Modify phonemes here to simulate detection errors...",
)
with gr.Row():
components["dev_simulate_btn"] = gr.Button(
"Simulate Errors", variant="primary"
)
components["dev_reset_btn"] = gr.Button(
"Reset to Canonical", variant="secondary"
)
components["dev_simulation_output"] = gr.HTML(value="", label="Simulation Result")
with gr.Row():
components["dev_test_name"] = gr.Textbox(
label="Test Name",
placeholder="Enter a name for this test case...",
scale=2,
)
components["dev_save_btn"] = gr.Button(
"Save Test Case", variant="secondary", scale=1
)
components["dev_save_status"] = gr.HTML(value="", label="Save Status")
gr.Markdown("### Test Runner")
with gr.Row():
components["dev_test_source"] = gr.Radio(
choices=["MDD Tests (mdd_tests.yaml)", "Formal Tests (error_analysis/)"],
value="MDD Tests (mdd_tests.yaml)",
label="Test Source",
scale=2,
)
components["dev_load_btn"] = gr.Button(
"Load & Run Tests", variant="primary", scale=1
)
components["dev_tests_output"] = gr.HTML(value="", label="Test Results")
gr.Markdown("### Alignment Visualization")
with gr.Row():
components["dev_alignment_btn"] = gr.Button(
"Generate Alignment Plot", variant="secondary", scale=1
)
components["dev_alignment_status"] = gr.HTML(value="", label="Status")
components["dev_alignment_image"] = gr.Image(
value=None,
label="CTC Forced Alignment",
type="filepath",
)
# ----- Wire all events -----
wire_all_events(components, states, handlers)
# ----- Wire reference audio navigation events -----
# Previous button
components["ref_audio_prev_btn"].click(
fn=on_ref_audio_prev_click,
inputs=[
states["ref_audio_verse_index"],
states["ref_audio_verse_keys"]
],
outputs=[
states["ref_audio_verse_index"],
components["ref_audio_slider"],
components["ref_audio_player"]
]
)
# Next button
components["ref_audio_next_btn"].click(
fn=on_ref_audio_next_click,
inputs=[
states["ref_audio_verse_index"],
states["ref_audio_verse_keys"]
],
outputs=[
states["ref_audio_verse_index"],
components["ref_audio_slider"],
components["ref_audio_player"]
]
)
# Slider input (use .input not .change to avoid firing on programmatic updates)
components["ref_audio_slider"].input(
fn=on_ref_audio_slider_change,
inputs=[
components["ref_audio_slider"],
states["ref_audio_verse_keys"]
],
outputs=[
states["ref_audio_verse_index"],
components["ref_audio_player"]
]
)
# Wire segment clip lazy loading event
# When JavaScript requests a segment clip, fetch and return it
states["segment_clip_request"].change(
fn=fetch_segment_clip_lazy,
inputs=[states["segment_clip_request"]],
outputs=[states["segment_clip_response"]]
)
# ----- Wire locale initialization on page load -----
# This reads the __locale query parameter and updates all translatable components
# The js parameter applies RTL class to body based on locale (works on HF Spaces)
# Build translations from i18n JSON files (avoid hardcoded strings)
translation_keys = [
"sort_options.text_order",
"sort_options.by_rule",
"sort_options.by_errors",
"tabs.error_analysis",
"tabs.ghunnah_analysis",
"tabs.madd_analysis",
"tabs.dev",
]
js_translations = ",\n ".join(
f"'{t(key, locale='en')}': '{t(key, locale='ar')}'"
for key in translation_keys
)
rtl_init_js = f"""
() => {{
const urlParams = new URLSearchParams(window.location.search);
const locale = urlParams.get('__locale') || localStorage.getItem('tajweed_locale') || 'en';
document.body.classList.toggle('rtl-layout', locale === 'ar');
// Read settings from localStorage and return to Python for config sync
// This ensures config.py has the same values as the UI when tables render
const getStoredValue = (key) => {{
const val = localStorage.getItem(key);
return val !== null ? JSON.parse(val) : null;
}};
const cachedSettings = {{
duration_tolerance: getStoredValue('tajweed_duration_tolerance'),
iqlab_ikhfaa_sound: getStoredValue('tajweed_iqlab_ikhfaa_sound'),
ghunnah_length: getStoredValue('tajweed_ghunnah_length'),
madd_jaiz_length: getStoredValue('tajweed_madd_jaiz_length'),
madd_arid_length: getStoredValue('tajweed_madd_arid_length'),
madd_leen_length: getStoredValue('tajweed_madd_leen_length'),
madd_wajib_length: getStoredValue('tajweed_madd_wajib_length'),
save_audio: getStoredValue('tajweed_save_audio'),
}};
// Force-sync language toggle after Gradio renders
// (settings are now synced via Python return values, but lang toggle needs click())
setTimeout(() => {{
const langInputs = document.querySelectorAll('.lang-toggle-radio input[type="radio"]');
console.log('[locale-sync] Detected locale:', locale, 'Found inputs:', langInputs.length);
langInputs.forEach(input => {{
if (input.value === locale && !input.checked) {{
console.log('[locale-sync] Clicking radio for:', locale);
input.click();
}}
}});
}}, 150);
// Update labels for Arabic locale
if (locale === 'ar') {{
const translations = {{
{js_translations}
}};
// Wait for Gradio to render, then update labels
setTimeout(() => {{
// Update sort toggle labels
document.querySelectorAll('.sort-toggle label span').forEach(span => {{
const text = span.textContent.trim();
if (translations[text]) {{
span.textContent = translations[text];
}}
}});
// Update tab labels
document.querySelectorAll('.tabs button').forEach(btn => {{
const text = btn.textContent.trim();
if (translations[text]) {{
btn.textContent = translations[text];
}}
}});
}}, 100);
}}
// Return settings to Python for config.py sync
return cachedSettings;
}}
"""
app.load(
fn=_on_locale_load,
inputs=[states["cached_settings"]], # Receives localStorage settings from JS
outputs=[
components["header_html"],
components["lang_toggle"],
components["audio_input"],
components["analyze_btn"],
components["duration_tolerance"],
components["iqlab_ikhfaa_sound"],
components["ghunnah_length"],
components["madd_jaiz_length"],
components["madd_arid_length"],
components["madd_leen_length"],
components["madd_wajib_length"],
components["madd_lazim_length"],
components["madd_tabii_length"],
components["error_sort_dropdown"],
components["ghunnah_sort_dropdown"],
components["madd_sort_dropdown"],
components["from_chapter"],
components["from_verse"],
components["to_verse"],
components["random_btn"],
components["reset_btn"],
components["multi_models_cb"],
components["save_audio_cb"],
components["settings_accordion"],
],
js=rtl_init_js,
)
# ----- Wire language toggle change -----
# When user clicks the toggle, store in localStorage and reload with new locale
locale_change_js = """
(locale) => {
if (locale) {
const url = new URL(window.location.href);
const currentUrlLocale = url.searchParams.get('__locale');
// Only reload if URL locale is different (prevents infinite loop)
if (currentUrlLocale !== locale) {
localStorage.setItem('tajweed_locale', locale);
url.searchParams.set('__locale', locale);
// Apply RTL class immediately for smooth transition
document.body.classList.toggle('rtl-layout', locale === 'ar');
setTimeout(() => { window.location.href = url.toString(); }, 100);
}
}
}
"""
components["lang_toggle"].change(
fn=None, # No server round-trip needed - JS handles everything
inputs=[components["lang_toggle"]],
outputs=[], # No outputs - JS handles the page reload
js=locale_change_js
)
# ----- Wire settings persistence to localStorage -----
# Save settings when changed so they persist across page reloads
settings_to_cache = [
("duration_tolerance", "tajweed_duration_tolerance"),
("iqlab_ikhfaa_sound", "tajweed_iqlab_ikhfaa_sound"),
("ghunnah_length", "tajweed_ghunnah_length"),
("madd_jaiz_length", "tajweed_madd_jaiz_length"),
("madd_arid_length", "tajweed_madd_arid_length"),
("madd_leen_length", "tajweed_madd_leen_length"),
("madd_wajib_length", "tajweed_madd_wajib_length"),
("save_audio_cb", "tajweed_save_audio"),
]
for component_key, storage_key in settings_to_cache:
components[component_key].change(
fn=None,
inputs=[components[component_key]],
outputs=[],
js=f"(value) => {{ localStorage.setItem('{storage_key}', JSON.stringify(value)); }}"
)
return app