import html import json import logging import random import time from collections.abc import Iterator from pathlib import Path import gradio as gr import spaces logging.basicConfig(level=logging.INFO) LOGGER = logging.getLogger(__name__) from filler_words import ( format_filler_chips_html, highlight_fillers_html, summarize_fillers, ) from rehearsal_limits import ( MAX_RECORDING_SECONDS, accepted_recording_window_label, estimate_gpu_duration_seconds, validate_recording_duration_seconds, ) from review import review_speech from speech_stats import build_transcript_stats from timing import ( format_pacing_html, format_pacing_preview_html, get_audio_duration_seconds, summarize_timing, ) from transcribe import transcribe_recording APP_DIR = Path(__file__).parent RECORDING_WINDOW_LABEL = accepted_recording_window_label() SPEECH_FEEDBACK_PENDING = "_The honest review lands once the coach has heard you out._" TIMING_FEEDBACK_PENDING = "_Pacing notes land after the transcript._" FILLER_FEEDBACK_PENDING = '
Crutch-word count lands after the transcript.
' COMPLETION_STATUS = "All done — go raise that glass." INITIAL_STATUS_HTML = '

Glass raised — ready when you are.

' INITIAL_TRANSCRIPT_HTML = ( '
' "Your transcript will land here once we've heard the recording." "
" ) INITIAL_TIMING_HTML = '

Pacing notes land here after the recording.

' INITIAL_FILLER_HTML = '
Crutch-word count lands here after the recording.
' INITIAL_FEEDBACK_MARKDOWN = "_The honest review will land here once the coach has heard you out._" CONFETTI_COLORS = ("#bf8a3a", "#7a2636", "#183d34", "#fff9ec", "#efe3ce") CONFETTI_PIECES = 28 def _build_confetti_html() -> str: rng = random.Random(7) pieces: list[str] = [] for _ in range(CONFETTI_PIECES): left = rng.uniform(0, 100) color = rng.choice(CONFETTI_COLORS) delay = rng.uniform(0, 0.7) duration = rng.uniform(2.4, 3.8) drift = rng.uniform(-12, 12) style = ( f"left:{left:.1f}vw;" f"background:{color};" f"--drift:{drift:.1f}vw;" f"animation-delay:{delay:.2f}s;" f"animation-duration:{duration:.2f}s;" ) pieces.append(f'') return f'' HERO_RINGS_SVG = ( '" ) def _format_clock_seconds(seconds: int) -> str: minutes, remaining_seconds = divmod(seconds, 60) return f"{minutes}:{remaining_seconds:02d}" INITIAL_RECORDING_STATUS_HTML = ( f"
Recording limit: {_format_clock_seconds(MAX_RECORDING_SECONDS)}
" ) TRY_AGAIN_RESET_VALUES = json.dumps( [ None, INITIAL_RECORDING_STATUS_HTML, INITIAL_TRANSCRIPT_HTML, INITIAL_FEEDBACK_MARKDOWN, INITIAL_TIMING_HTML, INITIAL_FILLER_HTML, INITIAL_STATUS_HTML, {"__type__": "update", "interactive": False}, ] ) TRY_AGAIN_RESET_JS = f""" () => {{ window.rehearsalResetTimer?.(); return {TRY_AGAIN_RESET_VALUES}; }} """ COUNTDOWN_HEAD = f""" """ COUNTDOWN_HEAD += """ """ CUSTOM_CSS = (APP_DIR / "assets" / "scorecard.css").read_text() SPEECH_FEEDBACK_SECTIONS = ( "What worked", "What to sharpen", "Try this next time", "Bottom line", ) STATUS_STEPS = ("Listening back", "Pacing", "Crutch words", "Coach's note") def _infer_stage(message: str) -> int | None: text = message.lower() if "all done" in text: return 4 if "asking the coach" in text: return 3 if "counting the crutch" in text: return 2 if "clocking your pacing" in text: return 1 if "listening back" in text: return 0 return None def format_status_html(message: str) -> str: body = f'

{html.escape(message)}

' stage = _infer_stage(message) if stage is None: return body rows: list[str] = [] for index, label in enumerate(STATUS_STEPS): if index < stage: state = "done" elif index == stage: state = "active" else: state = "todo" rows.append( f'
' f'{html.escape(label)}
' ) return body + '
' + "".join(rows) + "
" class ProcessingTimer: def __init__(self, requested_gpu_seconds: int | None = None) -> None: self.started_at = time.perf_counter() self.requested_gpu_seconds = requested_gpu_seconds self.steps: list[tuple[str, float]] = [] def add_step(self, label: str, seconds: float) -> None: self.steps.append((label, seconds)) def total_seconds(self) -> float: return time.perf_counter() - self.started_at def log(self, status: str) -> None: parts = [f"{label}={seconds:.1f}s" for label, seconds in self.steps] if self.requested_gpu_seconds is not None: parts.append(f"requested_gpu_budget={self.requested_gpu_seconds}s") parts.append(f"total={self.total_seconds():.1f}s") LOGGER.info("process_rehearsal status=%s timings=%s", status, " ".join(parts)) def _gpu_duration_seconds(audio_path: str, duration_seconds: float, requested_gpu_seconds: int) -> int: return requested_gpu_seconds def _timed_step(timer: ProcessingTimer, label: str, action): step_started_at = time.perf_counter() try: return action() finally: timer.add_step(label, time.perf_counter() - step_started_at) def _status_with_timings(message: str, timer: ProcessingTimer, *, log: bool = True) -> str: if log: timer.log(message.splitlines()[0]) return format_status_html(message) def _outputs( transcript: str, feedback: str, timing_feedback: str, filler_feedback: str, status: str, timer: ProcessingTimer, *, log_status: bool = False, ) -> tuple[str, str, str, str, str]: return ( highlight_fillers_html(transcript), feedback, timing_feedback, filler_feedback, _status_with_timings(status, timer, log=log_status), ) def _format_final_outputs( audio_path: str, transcript: str, feedback: str, timing_feedback: str, filler_feedback: str, status: str, timer: ProcessingTimer, ) -> tuple[str, str, str, str, str]: step_started_at = time.perf_counter() formatted_feedback = _format_speech_feedback_markdown(feedback) formatted_timing = format_pacing_html(audio_path, transcript) formatted_filler = format_filler_chips_html(transcript) timer.add_step("formatting", time.perf_counter() - step_started_at) return ( highlight_fillers_html(transcript), formatted_feedback, formatted_timing, formatted_filler, _status_with_timings(status, timer), ) @spaces.GPU(duration=_gpu_duration_seconds) def _process_valid_rehearsal( audio_path: str, duration_seconds: float, requested_gpu_seconds: int, ) -> Iterator[tuple[str, str, str, str, str]]: timer = ProcessingTimer(requested_gpu_seconds=requested_gpu_seconds) timing_preview = format_pacing_preview_html(duration_seconds) yield _outputs( "", SPEECH_FEEDBACK_PENDING, timing_preview, FILLER_FEEDBACK_PENDING, ( f"Got the recording — {duration_seconds:.1f}s on the clock. " f"GPU budget reserved: {requested_gpu_seconds}s. Listening back now." ), timer, ) try: transcript = _timed_step( timer, "transcription", lambda: transcribe_recording(audio_path, duration_seconds=duration_seconds), ) except ValueError as exc: yield "", "", timing_preview, "", _status_with_timings(str(exc), timer) return except RuntimeError as exc: yield "", "", timing_preview, "", _status_with_timings(str(exc), timer) return except Exception as exc: yield "", "", timing_preview, "", _status_with_timings(f"Transcription failed: {exc}", timer) return yield _outputs( transcript, SPEECH_FEEDBACK_PENDING, timing_preview, FILLER_FEEDBACK_PENDING, "Transcript ready. Clocking your pacing.", timer, ) timing_feedback = _timed_step(timer, "timing analysis", lambda: _build_timing_feedback(audio_path, transcript)) formatted_timing_feedback = format_pacing_html(audio_path, transcript) yield _outputs( transcript, SPEECH_FEEDBACK_PENDING, formatted_timing_feedback, FILLER_FEEDBACK_PENDING, "Pacing logged. Counting the crutch words.", timer, ) filler_feedback = _timed_step(timer, "filler analysis", lambda: _build_filler_feedback(transcript)) formatted_filler_feedback = format_filler_chips_html(transcript) yield _outputs( transcript, SPEECH_FEEDBACK_PENDING, formatted_timing_feedback, formatted_filler_feedback, "Crutch words tallied. Asking the coach for an honest take.", timer, ) try: review_stats = build_transcript_stats(transcript, duration_seconds=duration_seconds) feedback = _timed_step( timer, "review generation", lambda: review_speech(transcript, stats=review_stats), ) except ValueError as exc: yield _format_final_outputs( audio_path, transcript, str(exc), timing_feedback, filler_feedback, "Heard you out — but the coach got stage fright.", timer, ) return except RuntimeError as exc: yield _format_final_outputs( audio_path, transcript, str(exc), timing_feedback, filler_feedback, "Heard you out — but the coach got stage fright.", timer, ) return except Exception as exc: yield _format_final_outputs( audio_path, transcript, f"Review failed: {exc}", timing_feedback, filler_feedback, "Heard you out — but the coach got stage fright.", timer, ) return yield _format_final_outputs( audio_path, transcript, feedback, timing_feedback, filler_feedback, COMPLETION_STATUS, timer, ) def _clear_outputs(status: str) -> tuple[str, str, str, str, str]: return "", "", "", "", format_status_html(status) def process_rehearsal(audio_path: str | None) -> Iterator[tuple[str, str, str, str, str]]: if not audio_path: yield _clear_outputs(f"Record a speech first. We accept run-throughs from {RECORDING_WINDOW_LABEL}.") return try: duration_seconds = get_audio_duration_seconds(audio_path) validate_recording_duration_seconds(duration_seconds) except ValueError as exc: yield _clear_outputs(str(exc)) return except Exception as exc: yield _clear_outputs(f"Could not read the recording duration, so no GPU work was requested: {exc}") return requested_gpu_seconds = estimate_gpu_duration_seconds(duration_seconds) yield from _process_valid_rehearsal(audio_path, duration_seconds, requested_gpu_seconds) def _build_timing_feedback(audio_path: str, transcript: str) -> str: try: return summarize_timing(audio_path, transcript) except Exception as exc: return f"Timing analysis failed: {exc}" def _build_filler_feedback(transcript: str) -> str: try: return summarize_fillers(transcript) except Exception as exc: return f"Filler analysis failed: {exc}" def _format_speech_feedback_markdown(feedback: str) -> str: lines: list[str] = [] for line in feedback.strip().splitlines(): stripped = line.strip() if not stripped: continue if stripped in SPEECH_FEEDBACK_SECTIONS: if lines: lines.append("") lines.append(f"**{stripped}**") lines.append("") else: lines.append(stripped) return "\n".join(lines) def _format_metric_markdown(summary: str) -> str: lines: list[str] = [] for line in summary.strip().splitlines(): stripped = line.strip() if not stripped: continue if stripped.startswith("- "): lines.append(stripped) elif ":" in stripped: label, value = stripped.split(":", 1) lines.append(f"- **{label}:**{value}") else: lines.append(stripped) return "\n".join(lines) with gr.Blocks(title="Best Man Speech Coach", css=CUSTOM_CSS) as demo: with gr.Column(elem_id="wedding-app"): gr.HTML(_build_confetti_html()) gr.HTML( f"""
Before the toast
{HERO_RINGS_SVG}

Best Man Speech Coach

Two minutes at the mic and you'll get the toast back as a transcript, with pacing, crutch words, and a coach's honest note attached.

A two-minute rehearsal coach
""" ) with gr.Row(elem_id="practice-grid"): with gr.Column(scale=7, elem_classes=["scorecard-card"]): gr.Markdown( "## Step up to the mic\n" f"Take a breath, then go. We need {RECORDING_WINDOW_LABEL} to give you a fair read." ) audio_input = gr.Audio( sources=["microphone"], type="filepath", label="Speech recording", elem_id="speech-audio", editable=False, buttons=[], waveform_options=gr.WaveformOptions( waveform_color="#bf8a3a", waveform_progress_color="#7a2636", ), ) countdown = gr.HTML( INITIAL_RECORDING_STATUS_HTML, label="Recording timer", ) with gr.Row(elem_id="rehearsal-actions"): transcribe_button = gr.Button( "Review speech", variant="primary", elem_id="review-button", scale=3, interactive=False, ) try_again_button = gr.Button( "Try again", variant="secondary", elem_id="try-again-button", scale=1, ) with gr.Column(scale=5, elem_classes=["scorecard-card"]): gr.Markdown("## How it's going") status_output = gr.HTML( value=INITIAL_STATUS_HTML, elem_id="status-output", ) with gr.Row(elem_id="results-grid"): with gr.Column(scale=6, elem_classes=["scorecard-card", "result-panel"]): gr.Markdown("## What you said") transcript_output = gr.HTML( value=INITIAL_TRANSCRIPT_HTML, elem_id="transcript-output", ) with gr.Column(scale=6, elem_classes=["scorecard-card", "metric-panel"]): gr.Markdown("## Pacing") timing_output = gr.HTML( value=INITIAL_TIMING_HTML, elem_id="timing-output", elem_classes=["score-output"], ) with gr.Row(): with gr.Column(scale=6, elem_classes=["scorecard-card", "metric-panel"]): gr.Markdown("## Crutch words") filler_output = gr.HTML( value=INITIAL_FILLER_HTML, elem_classes=["score-output"], ) with gr.Column(scale=6, elem_classes=["scorecard-card", "result-panel"], elem_id="review-card"): gr.Markdown("## The honest review") feedback_output = gr.Markdown( value=INITIAL_FEEDBACK_MARKDOWN, elem_classes=["score-output"], ) review_event = transcribe_button.click( fn=process_rehearsal, inputs=audio_input, outputs=[transcript_output, feedback_output, timing_output, filler_output, status_output], ) audio_input.change( fn=None, inputs=audio_input, outputs=transcribe_button, js="(audio) => ({ __type__: 'update', interactive: Boolean(audio) })", ) audio_input.clear( fn=None, inputs=None, outputs=transcribe_button, js="() => ({ __type__: 'update', interactive: false })", ) audio_input.start_recording( fn=lambda: gr.update(interactive=False), inputs=None, outputs=transcribe_button, ) try_again_button.click( fn=None, inputs=None, outputs=[ audio_input, countdown, transcript_output, feedback_output, timing_output, filler_output, status_output, transcribe_button, ], js=TRY_AGAIN_RESET_JS, cancels=[review_event], ) demo.queue() if __name__ == "__main__": demo.launch(head=COUNTDOWN_HEAD)