""" Speak Lab – Gradio Edition (MVC refactor) ========================================== app.py is the entry-point View/Router only. Business logic lives in controllers/, data shapes in models/, HTML in views/. Missing-from-Gradio features now implemented vs original web app: ✅ No-repeat shuffler (cycles full pool before repeating, state in BrowserState) ✅ PRES method tips card (Impromptu) ✅ STAR method tips card (Interview) ✅ JAM habit tip card ✅ Tongue Twister articulation tip card ✅ Free tab: module category selector (JAM / TT / Impromptu / Interview) ✅ Free tab: prompt badge showing selected category ✅ Countdown bar for JAM (CSS animation, 60s) ✅ Min-session guard (5 s) — toast warning ✅ Streak only increments on "Save / Download" intent, not on Stop ✅ Toast notifications ✅ Analysis panel stub wired to all tabs """ import gradio as gr from controllers import DataController, StreakController, AnalysisController from controllers.ui_handlers import UIHandlers from models.session_models import FreeModuleChoice, AnalysisResult from views import render_streak_html, render_analysis_html, CSS from views.html_helpers import badge_html, prompt_html # ── Bootstrap ──────────────────────────────────────────────────────────────── data_ctrl = DataController() streak_ctrl = StreakController() analysis_ctrl = AnalysisController() handlers = UIHandlers(data_ctrl, streak_ctrl, analysis_ctrl) EMPTY_STREAK_HTML = render_streak_html(None) EMPTY_ANALYSIS_HTML = render_analysis_html(None) MIN_SESSION_SEC = 5 # ── Shuffler pool keys (mirror JS makeShuffler keys) ───────────────────────── IMP_CAT_NAMES = ["All"] + data_ctrl.imp_category_names IV_CAT_NAMES = ["All"] + data_ctrl.iv_category_names FREE_MODULE_CHOICES = [e.value for e in FreeModuleChoice] # ── Toast JS helper injected via gr.HTML ────────────────────────────────────── TOAST_HTML = '
' JAM_COUNTDOWN_HTML = '
' # JS injected via Blocks(head=) so scripts actually execute HEAD_JS = """ """ # ── Build UI ────────────────────────────────────────────────────────────────── with gr.Blocks(title="Speak Lab 🎙️") as demo: # ── Persistent state in browser localStorage ────────────────────────────── streak_store = gr.BrowserState({}) # StreakStore dict shuffler_state = gr.BrowserState({}) # {pool_key: {seen:[...]}} # ── Toast + global helpers ──────────────────────────────────────────────── gr.HTML(TOAST_HTML) # ── Header ──────────────────────────────────────────────────────────────── gr.Markdown( "# 🎙️ Speak Lab\n" "A serverless, account-free app to practice English speaking and build a daily habit.\n" "Record, review, and track your streak." ) with gr.Tabs(): # ════════════════════════════════════════════════════════════════════ # JAM — Just A Minute # ════════════════════════════════════════════════════════════════════ with gr.Tab("⚡ JAM"): gr.HTML('
Speak for 60 seconds. No hesitations, no pauses, and no filler words.
') jam_prompt = gr.HTML(prompt_html((data_ctrl.jam_prompts or [""])[0])) jam_shuffle = gr.Button("🎲 New Question", variant="primary") # Countdown bar (CSS animation, reset by JS) gr.HTML(JAM_COUNTDOWN_HTML) jam_audio = gr.Audio(label="🎙️ Record", sources=["microphone"], type="filepath") jam_duration = gr.Number(value=0, visible=False) # seconds, set by JS with gr.Row(): jam_save_btn = gr.Button("💾 Count this session", variant="primary", interactive=False) jam_analyse = gr.Button("🔬 Analyse Recording", variant="secondary", interactive=False) jam_analysis = gr.HTML(EMPTY_ANALYSIS_HTML) # Framework tip gr.HTML("""
💡 Habit Tip: Try to express your thoughts without pausing or using filler sounds like "um" or "ah". Focus on fluency and flow.
""") jam_shuffle.click(handlers.jam_next, inputs=[shuffler_state], outputs=[jam_prompt, shuffler_state]) jam_audio.change(handlers.audio_ready, inputs=jam_audio, outputs=[jam_save_btn, jam_analyse]) toast_out = gr.Markdown(visible=False) jam_save_btn.click(handlers.jam_count, inputs=[jam_audio, streak_store], outputs=[streak_store, toast_out]) jam_analyse.click(handlers.jam_analyse, inputs=jam_audio, outputs=jam_analysis) # ════════════════════════════════════════════════════════════════════ # Tongue Twisters # ════════════════════════════════════════════════════════════════════ with gr.Tab("👅 Tongue Twisters"): gr.HTML('
Train articulation, muscle memory, and clarity. Practice 3 times slowly, then 3 times fast.
') with gr.Row(): tt_level = gr.Radio(["easy", "medium", "hard"], value="easy", label="Difficulty", interactive=True) tt_shuffle = gr.Button("🎲 New", variant="primary") tt_items = data_ctrl.tt_items("easy") first_tt = tt_items[0] if tt_items else None tt_focus = gr.HTML(badge_html(f"Focus: {first_tt.focus if first_tt else 'Practice'}")) tt_prompt = gr.HTML(prompt_html(first_tt.text if first_tt else "")) tt_audio = gr.Audio(label="🎙️ Record", sources=["microphone"], type="filepath") with gr.Row(): tt_save_btn = gr.Button("💾 Count this session", variant="primary", interactive=False) tt_analyse = gr.Button("🔬 Analyse Recording", variant="secondary", interactive=False) tt_analysis = gr.HTML(EMPTY_ANALYSIS_HTML) gr.HTML("""
💡 Articulation Tip: Focus on pronouncing every single consonant clearly on your slow runs before ramping up the speed.
""") tt_shuffle.click(handlers.tt_next, inputs=[tt_level, shuffler_state], outputs=[tt_focus, tt_prompt, shuffler_state]) tt_level.change(handlers.tt_next, inputs=[tt_level, shuffler_state], outputs=[tt_focus, tt_prompt, shuffler_state]) tt_audio.change(handlers.audio_ready, inputs=tt_audio, outputs=[tt_save_btn, tt_analyse]) tt_save_btn.click(handlers.tt_count, inputs=[tt_audio, streak_store], outputs=streak_store) tt_analyse.click(handlers.tt_analyse, inputs=tt_audio, outputs=tt_analysis) # ════════════════════════════════════════════════════════════════════ # Impromptu Speech # ════════════════════════════════════════════════════════════════════ with gr.Tab("🎤 Impromptu"): gr.HTML('
Spontaneous speaking. Structure your answer in 5 seconds and deliver a 2-minute speech.
') with gr.Row(): imp_cat = gr.Dropdown(IMP_CAT_NAMES, value="All", label="Category", interactive=True) imp_shuffle = gr.Button("🎲 New", variant="primary") imp_prompt = gr.HTML(prompt_html((data_ctrl.imp_questions("All") or [""])[0])) imp_audio = gr.Audio(label="🎙️ Record", sources=["microphone"], type="filepath") with gr.Row(): imp_save_btn = gr.Button("💾 Count this session", variant="primary", interactive=False) imp_analyse = gr.Button("🔬 Analyse Recording", variant="secondary", interactive=False) imp_analysis = gr.HTML(EMPTY_ANALYSIS_HTML) gr.HTML("""
💡 Structure using the PRES Method:
Point (State your position)
Reason (Support it)
Example (Show a story)
Summary (So what?)
""") imp_shuffle.click(handlers.imp_next, inputs=[imp_cat, shuffler_state], outputs=[imp_prompt, shuffler_state]) imp_cat.change(handlers.imp_next, inputs=[imp_cat, shuffler_state], outputs=[imp_prompt, shuffler_state]) imp_audio.change(handlers.audio_ready, inputs=imp_audio, outputs=[imp_save_btn, imp_analyse]) imp_save_btn.click(handlers.imp_count, inputs=[imp_audio, streak_store], outputs=streak_store) imp_analyse.click(handlers.imp_analyse, inputs=imp_audio, outputs=imp_analysis) # ════════════════════════════════════════════════════════════════════ # Interview Practice # ════════════════════════════════════════════════════════════════════ with gr.Tab("💼 Interview"): gr.HTML('
Simulate professional and behavioral questions. Aim for 60–90 seconds per answer.
') with gr.Row(): iv_cat = gr.Dropdown(IV_CAT_NAMES, value="All", label="Category", interactive=True) iv_shuffle = gr.Button("🎲 New", variant="primary") iv_prompt = gr.HTML(prompt_html((data_ctrl.iv_questions("All") or [""])[0])) iv_audio = gr.Audio(label="🎙️ Record", sources=["microphone"], type="filepath") with gr.Row(): iv_save_btn = gr.Button("💾 Count this session", variant="primary", interactive=False) iv_analyse = gr.Button("🔬 Analyse Recording", variant="secondary", interactive=False) iv_analysis = gr.HTML(EMPTY_ANALYSIS_HTML) gr.HTML("""
💡 Structure using the STAR Method:
Situation
Task
Action
Result
""") iv_shuffle.click(handlers.iv_next, inputs=[iv_cat, shuffler_state], outputs=[iv_prompt, shuffler_state]) iv_cat.change(handlers.iv_next, inputs=[iv_cat, shuffler_state], outputs=[iv_prompt, shuffler_state]) iv_audio.change(handlers.audio_ready, inputs=iv_audio, outputs=[iv_save_btn, iv_analyse]) iv_save_btn.click(handlers.iv_count, inputs=[iv_audio, streak_store], outputs=streak_store) iv_analyse.click(handlers.iv_analyse, inputs=iv_audio, outputs=iv_analysis) # ════════════════════════════════════════════════════════════════════ # Free Talk # ════════════════════════════════════════════════════════════════════ with gr.Tab("✏️ Free Talk"): gr.HTML('
Input a custom text, select which category it counts as, and record your session.
') free_text = gr.Textbox( placeholder="Enter a custom prompt, an IELTS question, or any topic…\n(Ctrl+Enter to apply)", label="Your prompt", lines=4, ) with gr.Row(): free_module = gr.Dropdown( FREE_MODULE_CHOICES, value="JAM", label="Save as category", interactive=True ) free_apply = gr.Button("✓ Use prompt", variant="primary") free_badge = gr.HTML(visible=False) free_prompt = gr.HTML(visible=False) free_audio = gr.Audio(label="🎙️ Record", sources=["microphone"], type="filepath") with gr.Row(): free_save_btn = gr.Button("💾 Count this session", variant="primary", interactive=False) free_analyse = gr.Button("🔬 Analyse Recording", variant="secondary", interactive=False) free_analysis = gr.HTML(EMPTY_ANALYSIS_HTML) free_apply.click(handlers.free_apply, inputs=[free_text, free_module], outputs=[free_badge, free_prompt]) free_text.submit(handlers.free_apply, inputs=[free_text, free_module], outputs=[free_badge, free_prompt]) # Re-render badge if module changes while text exists free_module.change(handlers.free_apply, inputs=[free_text, free_module], outputs=[free_badge, free_prompt]) free_audio.change(handlers.audio_ready, inputs=free_audio, outputs=[free_save_btn, free_analyse]) free_save_btn.click(handlers.free_count, inputs=[free_audio, free_module, streak_store], outputs=streak_store) free_analyse.click(handlers.free_analyse, inputs=free_audio, outputs=free_analysis) # ════════════════════════════════════════════════════════════════════ # Progress — Streak Heatmap # ════════════════════════════════════════════════════════════════════ with gr.Tab("📈 Progress"): streak_refresh = gr.Button("🔄 Refresh", variant="secondary") streak_out = gr.HTML(EMPTY_STREAK_HTML) streak_refresh.click(handlers.refresh_streak, inputs=streak_store, outputs=streak_out) demo.load(handlers.refresh_streak, inputs=streak_store, outputs=streak_out) # ── Footer ──────────────────────────────────────────────────────────────── gr.HTML("""
Speak Lab — MIT License · Built with Gradio
""") if __name__ == "__main__": demo.launch(css=CSS, theme=gr.themes.Default(), head=HEAD_JS)