| """ |
| Sunbird AI Pipeline — Gradio frontend |
| Entry point: app.py |
| """ |
| import os |
| import tempfile |
| import traceback |
|
|
| import gradio as gr |
| from dotenv import load_dotenv |
|
|
| load_dotenv() |
|
|
| from backend.pipeline import run_pipeline |
| from backend.sunbird_client import TTS_SPEAKER_IDS |
|
|
| LANGUAGES = list(TTS_SPEAKER_IDS.keys()) |
|
|
| |
| CUSTOM_CSS = """ |
| @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=Sora:wght@600;700&display=swap'); |
| * { box-sizing: border-box; } |
| body, .gradio-container { |
| font-family: 'DM Sans', sans-serif !important; |
| background: #f5f0eb !important; |
| } |
| #header-box { |
| background: linear-gradient(135deg, #DC7828 0%, #b85e1a 100%); |
| border-radius: 14px; |
| padding: 28px 32px; |
| margin-bottom: 20px; |
| color: white; |
| box-shadow: 0 4px 24px rgba(220, 120, 40, 0.25); |
| } |
| #header-box h1 { |
| margin: 0 0 6px 0; |
| font-family: 'Sora', sans-serif; |
| font-size: 1.9rem; |
| letter-spacing: -0.02em; |
| text-align: center; |
| } |
| #header-box p { |
| margin: 0; |
| opacity: 0.88; |
| font-size: 0.95rem; |
| font-weight: 400; |
| } |
| .section-heading { |
| font-family: 'Sora', sans-serif; |
| font-size: 0.8rem; |
| font-weight: 600; |
| text-transform: uppercase; |
| letter-spacing: 0.1em; |
| color: #8a6a50; |
| margin-bottom: 10px; |
| padding-bottom: 6px; |
| border-bottom: 1.5px solid #e0d4c8; |
| } |
| label.svelte-1b6s6s, .gradio-container label { |
| font-size: 0.82rem !important; |
| font-weight: 500 !important; |
| color: #5c4030 !important; |
| text-transform: uppercase !important; |
| letter-spacing: 0.06em !important; |
| } |
| textarea, input[type="text"] { |
| background: #fffaf6 !important; |
| border: 1.5px solid #e0d0c0 !important; |
| border-radius: 8px !important; |
| font-family: 'DM Sans', sans-serif !important; |
| font-size: 0.93rem !important; |
| color: #2d1a0a !important; |
| transition: border-color 0.15s ease; |
| } |
| textarea:focus, input[type="text"]:focus { |
| border-color: #DC7828 !important; |
| outline: none !important; |
| box-shadow: 0 0 0 3px rgba(220, 120, 40, 0.12) !important; |
| } |
| #run-btn { |
| background: #DC7828 !important; |
| color: white !important; |
| font-family: 'Sora', sans-serif !important; |
| font-size: 0.9rem !important; |
| font-weight: 600 !important; |
| letter-spacing: 0.04em !important; |
| border-radius: 8px !important; |
| border: none !important; |
| padding: 12px 24px !important; |
| transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease !important; |
| box-shadow: 0 2px 12px rgba(220, 120, 40, 0.3) !important; |
| } |
| #run-btn:hover { |
| background: #b85e1a !important; |
| transform: translateY(-1px) !important; |
| box-shadow: 0 4px 18px rgba(220, 120, 40, 0.4) !important; |
| } |
| #run-btn:active { |
| transform: translateY(0) !important; |
| } |
| .gr-panel, .gr-box, .gr-form, [class*="block"] { |
| background: #fffaf6 !important; |
| border: 1.5px solid #e8ddd4 !important; |
| border-radius: 12px !important; |
| } |
| select, .gr-dropdown { |
| background: #fffaf6 !important; |
| border: 1.5px solid #e0d0c0 !important; |
| border-radius: 8px !important; |
| font-family: 'DM Sans', sans-serif !important; |
| color: #2d1a0a !important; |
| } |
| #error-box { |
| color: #c0392b; |
| background: #fff5f5; |
| border: 1.5px solid #c0392b; |
| border-radius: 8px; |
| padding: 10px 14px; |
| font-size: 0.9rem; |
| font-family: 'DM Sans', sans-serif; |
| margin-top: 8px; |
| } |
| .step-badge { |
| display: inline-block; |
| background: #DC7828; |
| color: white; |
| font-family: 'Sora', sans-serif; |
| font-size: 0.65rem; |
| font-weight: 700; |
| padding: 2px 8px; |
| border-radius: 20px; |
| letter-spacing: 0.08em; |
| margin-right: 6px; |
| vertical-align: middle; |
| } |
| .gr-examples { |
| background: transparent !important; |
| border: none !important; |
| } |
| .divider { |
| border: none; |
| border-top: 1.5px solid #e0d4c8; |
| margin: 16px 0; |
| } |
| """ |
|
|
|
|
| |
|
|
| def _friendly_error(e: Exception) -> str: |
| """Convert raw technical exceptions into user-friendly messages.""" |
| import requests as req |
|
|
| msg = str(e) |
|
|
| if isinstance(e, (req.exceptions.ConnectionError, ConnectionError, OSError)): |
| if "NameResolutionError" in msg or "getaddrinfo" in msg or "Failed to resolve" in msg: |
| return "Could not reach the server — please check your internet connection and try again." |
| return "A network error occurred. Please check your connection and try again." |
|
|
| if isinstance(e, req.exceptions.Timeout): |
| return "The request timed out. The server may be busy — please try again in a moment." |
|
|
| if isinstance(e, req.exceptions.HTTPError): |
| if "401" in msg or "403" in msg: |
| return "Authentication failed. Please check that your API token is correct." |
| if "429" in msg: |
| return "Too many requests — please wait a moment and try again." |
| if "5" in msg[:3]: |
| return "The Sunbird server returned an error. Please try again later." |
| return "The server returned an unexpected error. Please try again." |
|
|
| if isinstance(e, (ValueError, RuntimeError)): |
| return msg |
|
|
| return "Something went wrong. Please try again or contact support if the issue persists." |
|
|
|
|
| |
|
|
| def process(input_mode, text_input, audio_input, target_language): |
| """ |
| Called when the user clicks Generate. |
| Validation raises gr.Error immediately (shows as toast, no generator quirk). |
| Pipeline errors are also raised as gr.Error from within the generator. |
| """ |
| print("TOKEN SET:", bool(os.environ.get("SUNBIRD_API_TOKEN"))) |
| print(f"\n=== PROCESS CALLED ===") |
| print(f"Input mode: {input_mode}") |
| print(f"Text input: {text_input[:50] if text_input else 'None'}") |
| print(f"Audio input: {audio_input}") |
| print(f"Target language: {target_language}") |
|
|
| use_audio = (input_mode == "Audio Upload") |
|
|
| |
| if use_audio and not audio_input: |
| raise gr.Error("Please upload an audio file before running the pipeline.") |
|
|
| if not use_audio and not (text_input and text_input.strip()): |
| raise gr.Error("Please enter some text before running the pipeline.") |
|
|
| |
| yield ("", "", "", None, gr.update(value="", visible=False)) |
|
|
| try: |
| audio_path = audio_input if use_audio else None |
| user_text = None if use_audio else text_input |
|
|
| print("Calling run_pipeline...") |
| transcript, summary, translation, audio_bytes = run_pipeline( |
| input_text=user_text, |
| audio_path=audio_path, |
| target_language=target_language, |
| ) |
| print("Pipeline completed successfully") |
|
|
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".wav") |
| tmp.write(audio_bytes) |
| tmp.flush() |
| tmp.close() |
| print(f"Audio saved to: {tmp.name}") |
|
|
| yield ( |
| transcript, |
| summary, |
| translation, |
| tmp.name, |
| gr.update(value="", visible=False), |
| ) |
|
|
| except gr.Error: |
| raise |
|
|
| except Exception as e: |
| print(f"\n=== EXCEPTION CAUGHT ===") |
| print(f"Exception type: {type(e).__name__}") |
| print(f"Exception message: {str(e)}") |
| traceback.print_exc() |
|
|
| raise gr.Error(_friendly_error(e)) |
|
|
|
|
| |
|
|
| def build_ui(): |
| with gr.Blocks(title="GenAI", css=CUSTOM_CSS) as demo: |
|
|
| |
| gr.HTML(""" |
| <div id="header-box"> |
| <h1>GenAI</h1> |
| </div> |
| """) |
|
|
| with gr.Row(): |
| |
| with gr.Column(scale=1): |
| gr.HTML('<p class="section-heading">Input</p>') |
|
|
| input_mode = gr.Radio( |
| choices=["Text Input", "Audio Upload"], |
| value="Text Input", |
| label="Input type", |
| interactive=True, |
| ) |
|
|
| text_input = gr.Textbox( |
| label="Text (ENG OR LUG)", |
| placeholder="Type or paste your text here…", |
| lines=6, |
| visible=True, |
| ) |
|
|
| audio_input = gr.Audio( |
| label="Audio file (MP3, WAV, OGG, M4A, AAC — max 5 min)", |
| type="filepath", |
| sources=["upload"], |
| visible=False, |
| ) |
|
|
| target_language = gr.Dropdown( |
| choices=LANGUAGES, |
| value="Luganda", |
| label="Target language for translation and speech", |
| ) |
|
|
| run_btn = gr.Button("Generate", elem_id="run-btn", variant="primary") |
|
|
| |
| with gr.Column(scale=1): |
| gr.HTML('<p class="section-heading">Results</p>') |
|
|
| transcript_out = gr.Textbox( |
| label="Transcript / Source text", |
| lines=4, |
| interactive=False, |
| ) |
| summary_out = gr.Textbox( |
| label="English summary", |
| lines=4, |
| interactive=False, |
| ) |
| translation_out = gr.Textbox( |
| label="Translated summary", |
| lines=4, |
| interactive=False, |
| ) |
| audio_out = gr.Audio( |
| label="Synthesised speech", |
| type="filepath", |
| interactive=False, |
| ) |
|
|
|
|
| |
| def toggle_inputs(mode): |
| return ( |
| gr.update(visible=(mode == "Text Input"), value=""), |
| gr.update(visible=(mode == "Audio Upload"), value=None), |
| gr.update(value=""), |
| gr.update(value=""), |
| gr.update(value=""), |
| gr.update(value=None), |
| |
| ) |
|
|
| input_mode.change( |
| fn=toggle_inputs, |
| inputs=[input_mode], |
| outputs=[text_input, audio_input, transcript_out, summary_out, translation_out, audio_out], |
| show_progress="hidden", |
| ) |
|
|
| |
| run_btn.click( |
| fn=process, |
| inputs=[input_mode, text_input, audio_input, target_language], |
| outputs=[transcript_out, summary_out, translation_out, audio_out], |
| show_progress="full", |
| ) |
|
|
| return demo |
|
|
|
|
| if __name__ == "__main__": |
| demo = build_ui() |
| demo.queue() |
| demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False) |