"""
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("""
""")
if __name__ == "__main__":
demo.launch(css=CSS, theme=gr.themes.Default(), head=HEAD_JS)