from __future__ import annotations from pathlib import Path import gradio as gr from echocoach.config import get_echo_coach_config from echocoach.pipeline import run_echo_coach from gradio_space.model_loading import ensure_model_loaded, get_active_model_key from gradio_space.spaces_runtime import gpu_task from gradio_space.ui.components import ( build_advanced_panel, build_recording_block, empty_state, wire_recording_handlers, ) from inference.factory import get_backend _config = get_echo_coach_config() _SAMPLE_AUDIO = ( Path(__file__).resolve().parents[5] / "libs" / "echocoach" / "tests" / "fixtures" / "silence_2s.wav" ) def _error_html(message: str) -> str: safe = ( message.replace("&", "&") .replace("<", "<") .replace(">", ">") ) return ( f'
{safe}
' ) def _error_outputs(message: str) -> tuple: return ( message, _error_html(message), "", gr.update(value=None, visible=False), gr.update(value=None, visible=False), gr.update(value=None, visible=False), f"Trace: {message}", {}, gr.update(visible=False), gr.update(visible=True), ) def load_sample_pitch() -> tuple[str | None, str]: if not _SAMPLE_AUDIO.is_file(): return ( None, f"Sample clip missing at `{_SAMPLE_AUDIO}`. Run `uv run python libs/echocoach/tests/make_fixture.py`.", ) return ( gr.update(value=str(_SAMPLE_AUDIO)), "Sample clip loaded — click **Analyze pitch** when ready.", ) @gpu_task(duration=180) def analyze_pitch( audio_path: str | None, language: str, asr_preset: str, speak_rewrite: bool, progress: gr.Progress = gr.Progress(), ) -> tuple: progress(0, desc="Loading model…") model_key = get_active_model_key() load_error = ensure_model_loaded(model_key) if load_error: return _error_outputs(load_error) if not audio_path: return _error_outputs("Record or upload a pitch (up to 30 seconds), then click **Analyze pitch**.") try: progress(0.2, desc="Transcribing & analyzing…") result = run_echo_coach( audio_path, language=language, asr_preset=asr_preset, backend=get_backend(model_key), speak_rewrite=speak_rewrite, ) except Exception as exc: # noqa: BLE001 return _error_outputs(f"EchoCoach failed: {exc}") progress(1.0, desc="Done") status = "**Analysis complete.** Review transcript, charts, and VoiceOut on the right." if result.voiceout_warning: status += f" VoiceOut note: {result.voiceout_warning}" has_filler = bool(result.filler_chart_path) has_pace = bool(result.pace_chart_path) has_voiceout = bool(result.voiceout_path) return ( status, result.transcript_html, result.report_markdown, gr.update(value=result.filler_chart_path, visible=has_filler), gr.update(value=result.pace_chart_path, visible=has_pace), gr.update(value=result.voiceout_path, visible=has_voiceout), f"Trace saved: `{result.trace_path}`", result.trace, gr.update(visible=False), gr.update(visible=True), ) def build_echo_coach_tab() -> None: lang_choices = _config.language_choices() asr_choices = _config.asr_choices() default_lang = lang_choices[0][1] if lang_choices else "en" default_asr = _config.asr_preset gr.Markdown("### EchoCoach", elem_classes=["form-tab-heading"]) gr.HTML( '

' "Record a short pitch and get transcript, pace analysis, filler highlights, and spoken feedback." "

" ) gr.HTML( '" ) with gr.Row(elem_classes=["ec-workflow-columns"]): with gr.Column(scale=1, elem_classes=["ec-input-col"]): gr.HTML('

Step 1 · Record your pitch

') with gr.Column(elem_classes=["form-primary"]): rec = build_recording_block( max_seconds=_config.max_seconds, default_seconds=min(30, _config.max_seconds), lang_choices=lang_choices, asr_choices=asr_choices, default_lang=default_lang, default_asr=default_asr, audio_label="Your pitch (mic or upload, up to 30s)", include_sample=True, compact=True, ) status = gr.Markdown( value="_Record or upload audio, then analyze._", elem_classes=["form-status"], ) rec.status = status with gr.Accordion( "VoiceOut options", open=False, elem_classes=["form-optional-accordion"], ): speak_rewrite = gr.Checkbox( label="Speak full rewrite (otherwise summary + tip)", value=False, ) with gr.Row(elem_classes=["form-cta-row"]): analyze_btn = gr.Button( "Analyze pitch", variant="primary", elem_classes=["primary-cta"], ) wire_recording_handlers( rec, stop_next_action="Click **Analyze pitch**.", status_output=status, sample_loader=load_sample_pitch, ) advanced = build_advanced_panel(use_json=True) with gr.Column(scale=2, elem_classes=["ec-results-col"]): gr.HTML('

Step 2 · Review feedback

') results_empty = gr.HTML( value=empty_state( "Your transcript, pace charts, filler highlights, and VoiceOut audio " "will appear here after you analyze a recording." ) ) with gr.Column(visible=False) as results_panel: report_md = gr.Markdown( label="Coach summary", elem_classes=["ec-coach-report"], ) transcript_html = gr.HTML( label="Transcript", elem_classes=["ec-transcript"], ) with gr.Row(elem_classes=["ec-charts-row"]): filler_chart = gr.Image( label="Filler words", type="filepath", visible=False, ) pace_chart = gr.Image( label="Pace timeline", type="filepath", visible=False, ) voiceout = gr.Audio(label="VoiceOut feedback", type="filepath", visible=False) analyze_btn.click( analyze_pitch, inputs=[ rec.audio_in, rec.language, rec.asr_preset, speak_rewrite, ], outputs=[ status, transcript_html, report_md, filler_chart, pace_chart, voiceout, advanced.trace_summary, advanced.trace_box, results_empty, results_panel, ], ) def echo_coach_allowed_paths() -> list[str]: base = get_echo_coach_config() paths: list[str] = [] if base.presets_path: paths.append(str(base.presets_path.parent)) from echocoach.config import outputs_dir paths.append(str(outputs_dir())) paths.append(str(outputs_dir() / "recordings")) if _SAMPLE_AUDIO.is_file(): paths.append(str(_SAMPLE_AUDIO.parent)) return paths