| """ |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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_HTML = '<div id="sl-toast"></div>' |
|
|
| JAM_COUNTDOWN_HTML = '<div id="jam-bar-wrap"><div id="jam-bar"></div></div>' |
|
|
| |
| HEAD_JS = """ |
| <style> |
| @keyframes jam-shrink { from { width: 100%; } to { width: 0%; } } |
| #jam-bar.live { animation: jam-shrink 60s linear forwards; background: linear-gradient(90deg, #6366f1, #a855f7); } |
| #jam-bar { height: 6px; border-radius: 3px; background: #1e2130; transition: none; } |
| #jam-bar-wrap { width:100%; background:#1e2130; border-radius:3px; overflow:hidden; margin-top:8px; } |
| </style> |
| <script> |
| function slToast(msg, ms) { |
| ms = ms || 2200; |
| var el = document.getElementById('sl-toast'); |
| if (!el) { el = document.createElement('div'); el.id='sl-toast'; document.body.appendChild(el); } |
| el.textContent = msg; |
| el.classList.add('show'); |
| clearTimeout(window._slToastT); |
| window._slToastT = setTimeout(function(){ el.classList.remove('show'); }, ms); |
| } |
| function jamStartCountdown() { |
| var bar = document.getElementById('jam-bar'); |
| if (!bar) return; |
| bar.classList.remove('live'); |
| void bar.offsetWidth; |
| bar.classList.add('live'); |
| } |
| function jamStopCountdown() { |
| var bar = document.getElementById('jam-bar'); |
| if (bar) bar.classList.remove('live'); |
| } |
| </script> |
| """ |
|
|
| |
| with gr.Blocks(title="Speak Lab ποΈ") as demo: |
|
|
| |
| streak_store = gr.BrowserState({}) |
| shuffler_state = gr.BrowserState({}) |
|
|
| |
| gr.HTML(TOAST_HTML) |
|
|
| |
| 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(): |
|
|
| |
| |
| |
| with gr.Tab("β‘ JAM"): |
| gr.HTML('<div class="desc-text">Speak for <strong>60 seconds</strong>. No hesitations, no pauses, and no filler words.</div>') |
|
|
| jam_prompt = gr.HTML(prompt_html((data_ctrl.jam_prompts or [""])[0])) |
| jam_shuffle = gr.Button("π² New Question", variant="primary") |
|
|
| |
| gr.HTML(JAM_COUNTDOWN_HTML) |
|
|
| jam_audio = gr.Audio(label="ποΈ Record", sources=["microphone"], type="filepath") |
| jam_duration = gr.Number(value=0, visible=False) |
| 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) |
|
|
| |
| gr.HTML(""" |
| <div class="framework-tips"> |
| <strong>π‘ Habit Tip:</strong> Try to express your thoughts without pausing |
| or using filler sounds like "um" or "ah". Focus on fluency and flow. |
| </div>""") |
|
|
| 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) |
|
|
| |
| |
| |
| with gr.Tab("π
Tongue Twisters"): |
| gr.HTML('<div class="desc-text">Train articulation, muscle memory, and clarity. Practice 3 times slowly, then 3 times fast.</div>') |
|
|
| 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(""" |
| <div class="framework-tips"> |
| <strong>π‘ Articulation Tip:</strong> Focus on pronouncing every single |
| consonant clearly on your slow runs before ramping up the speed. |
| </div>""") |
|
|
| 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) |
|
|
| |
| |
| |
| with gr.Tab("π€ Impromptu"): |
| gr.HTML('<div class="desc-text">Spontaneous speaking. Structure your answer in 5 seconds and deliver a 2-minute speech.</div>') |
|
|
| 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(""" |
| <div class="framework-tips"> |
| <strong>π‘ Structure using the PRES Method:</strong> |
| <div class="framework-steps"> |
| <div class="framework-step active"><strong>P</strong>oint (State your position)</div> |
| <div class="framework-step"><strong>R</strong>eason (Support it)</div> |
| <div class="framework-step"><strong>E</strong>xample (Show a story)</div> |
| <div class="framework-step"><strong>S</strong>ummary (So what?)</div> |
| </div> |
| </div>""") |
|
|
| 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) |
|
|
| |
| |
| |
| with gr.Tab("πΌ Interview"): |
| gr.HTML('<div class="desc-text">Simulate professional and behavioral questions. Aim for 60β90 seconds per answer.</div>') |
|
|
| 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(""" |
| <div class="framework-tips"> |
| <strong>π‘ Structure using the STAR Method:</strong> |
| <div class="framework-steps"> |
| <div class="framework-step active"><strong>S</strong>ituation</div> |
| <div class="framework-step"><strong>T</strong>ask</div> |
| <div class="framework-step"><strong>A</strong>ction</div> |
| <div class="framework-step"><strong>R</strong>esult</div> |
| </div> |
| </div>""") |
|
|
| 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) |
|
|
| |
| |
| |
| with gr.Tab("βοΈ Free Talk"): |
| gr.HTML('<div class="desc-text">Input a custom text, select which category it counts as, and record your session.</div>') |
|
|
| 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]) |
| |
| 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) |
|
|
| |
| |
| |
| 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) |
|
|
| |
| gr.HTML(""" |
| <div style="text-align:center;color:#475569;font-size:.8rem;padding:16px 0 4px;border-top:1px solid #1e2130;margin-top:12px"> |
| <a href="https://github.com/hosamation/Speak-Lab" style="color:#6366f1;text-decoration:none"> |
| Speak Lab |
| </a> β MIT License Β· Built with Gradio |
| </div>""") |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch(css=CSS, theme=gr.themes.Default(), head=HEAD_JS) |
|
|
|
|