seba3y's picture
Upload 36 files
a3847b2 verified
Raw
History Blame Contribute Delete
18 kB
"""
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 = '<div id="sl-toast"></div>'
JAM_COUNTDOWN_HTML = '<div id="jam-bar-wrap"><div id="jam-bar"></div></div>'
# JS injected via Blocks(head=) so scripts actually execute
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>
"""
# ── 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('<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")
# 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("""
<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)
# ════════════════════════════════════════════════════════════════════
# Tongue Twisters
# ════════════════════════════════════════════════════════════════════
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)
# ════════════════════════════════════════════════════════════════════
# Impromptu Speech
# ════════════════════════════════════════════════════════════════════
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)
# ════════════════════════════════════════════════════════════════════
# Interview Practice
# ════════════════════════════════════════════════════════════════════
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)
# ════════════════════════════════════════════════════════════════════
# Free Talk
# ════════════════════════════════════════════════════════════════════
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])
# 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("""
<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)