Spaces:
Running
on
Zero
Running
on
Zero
| """ | |
| 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 | |