Spaces:
Configuration error
Configuration error
| """ | |
| webapp.py - Clinical-Grade Multi-Modal Speech Diagnostics Interface | |
| =================================================================== | |
| A high-end, research-grade Streamlit application for clinical speech assessment: | |
| - Real-Time Live Streaming Diagnostics HUD & Subsystem Telemetry | |
| - Neural Disfluency Detection (wav2vec2 + LoRA) | |
| - Phonetic Word-Level Alignment & Pronunciation Accuracy (wav2vec2-CTC) | |
| - Explicit Sound Disorder Rules (Rhotacism 'r', Sigmatism 's') | |
| - Biomechanical Vocal Fold Phonation (Parselmouth / Praat PointProcess) | |
| - Multi-Modal Decision Fusion with Speaker Self-Calibration | |
| """ | |
| from __future__ import annotations | |
| import io | |
| import os | |
| import tempfile | |
| import time | |
| from pathlib import Path | |
| from typing import Optional, Dict, Any, List, Tuple | |
| import numpy as np | |
| import soundfile as sf | |
| import streamlit as st | |
| from ml.model.engine import SpeechDiagnosticEngine | |
| # Page Configuration | |
| st.set_page_config( | |
| page_title="Anvaya | Clinical Speech Diagnostics", | |
| layout="wide", | |
| initial_sidebar_state="expanded", | |
| ) | |
| # Benchmark Sample Library (Real Physical Audio on Local Disk) | |
| BENCHMARK_SAMPLES = { | |
| "Fluent Control (Normal Cadence)": ( | |
| "data/synthetic_lattice/audio/synth_000000_fluent_control.wav", | |
| "o that like here in the states to beco" | |
| ), | |
| "Syllable Repetition (Disfluency Test)": ( | |
| "data/synthetic_lattice/audio/synth_001000_stutter_repetition.wav", | |
| "it too am" | |
| ), | |
| "Sound Prolongation (Disfluency Test)": ( | |
| "data/synthetic_lattice/audio/synth_002000_stutter_prolongation.wav", | |
| "im going to revert back to you" | |
| ), | |
| "Glottal Block (Laryngeal Arrest)": ( | |
| "data/synthetic_lattice/audio/synth_003000_stutter_block.wav", | |
| "hopes" | |
| ), | |
| } | |
| # Single Word Practice Presets | |
| WORD_PRESETS = [ | |
| "rabbit", | |
| "red", | |
| "sun", | |
| "sweet", | |
| "three", | |
| "water", | |
| "kitten", | |
| "spot", | |
| ] | |
| # Full Sentence Practice Presets | |
| SENTENCE_PRESETS = { | |
| "The Red Rabbit (Rhotacism & Rhotic 'R' Evaluation)": "the red rabbit ran around the green yard", | |
| "The Sweet Sun (Sigmatism & Sibilant 'S' Evaluation)": "the sweet sun shines softly in the sky", | |
| "The Blue Spot (Phonetic Balance & Plosives)": "the blue spot is on the key", | |
| "The Rainbow Passage (Standardized Clinical Protocol)": "the rainbow is a division of white light into many beautiful colors", | |
| } | |
| # High-End Design Engineering CSS (Zero Emojis, Minimalist Modern Health-Tech) | |
| st.markdown(""" | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap'); | |
| html, body, [class*="css"] { | |
| font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif; | |
| letter-spacing: -0.01em; | |
| } | |
| code, pre, .mono { | |
| font-family: 'JetBrains Mono', monospace !important; | |
| } | |
| /* Top Brand Navigation Bar */ | |
| .brand-nav { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 16px 20px; | |
| background: rgba(18, 24, 38, 0.6); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| border-radius: 12px; | |
| margin-bottom: 20px; | |
| } | |
| .brand-title { | |
| font-size: 1.35rem; | |
| font-weight: 800; | |
| letter-spacing: -0.03em; | |
| color: #F8FAFC; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .brand-tag { | |
| font-size: 0.72rem; | |
| font-weight: 700; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| padding: 3px 8px; | |
| border-radius: 4px; | |
| background: rgba(14, 165, 233, 0.15); | |
| color: #38BDF8; | |
| border: 1px solid rgba(14, 165, 233, 0.3); | |
| } | |
| .brand-sub { | |
| font-size: 0.85rem; | |
| color: #94A3B8; | |
| margin-top: 2px; | |
| } | |
| .status-badge { | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| padding: 5px 12px; | |
| border-radius: 9999px; | |
| background: rgba(16, 185, 129, 0.12); | |
| color: #34D399; | |
| border: 1px solid rgba(16, 185, 129, 0.25); | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .status-dot { | |
| width: 6px; | |
| height: 6px; | |
| border-radius: 50%; | |
| background: #10B981; | |
| box-shadow: 0 0 8px #10B981; | |
| } | |
| /* Live Telemetry HUD */ | |
| .telemetry-box { | |
| background: #0B0F19; | |
| border: 1px solid rgba(56, 189, 248, 0.25); | |
| border-radius: 12px; | |
| padding: 16px; | |
| margin-bottom: 20px; | |
| box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); | |
| } | |
| .telemetry-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 12px; | |
| padding-bottom: 8px; | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.08); | |
| } | |
| .telemetry-title { | |
| font-size: 0.85rem; | |
| font-weight: 700; | |
| letter-spacing: 0.05em; | |
| text-transform: uppercase; | |
| color: #38BDF8; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .telemetry-log { | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 0.80rem; | |
| line-height: 1.6; | |
| color: #CBD5E1; | |
| max-height: 180px; | |
| overflow-y: auto; | |
| padding: 6px 0; | |
| } | |
| .log-line { | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 10px; | |
| padding: 3px 0; | |
| border-left: 2px solid transparent; | |
| padding-left: 8px; | |
| } | |
| .log-active { | |
| border-left-color: #38BDF8; | |
| background: rgba(56, 189, 248, 0.05); | |
| color: #F8FAFC; | |
| } | |
| .log-time { | |
| color: #64748B; | |
| font-size: 0.75rem; | |
| min-width: 65px; | |
| } | |
| .log-tag { | |
| color: #34D399; | |
| font-weight: 600; | |
| } | |
| /* KPI Cards */ | |
| .kpi-card { | |
| background: rgba(15, 23, 42, 0.5); | |
| border: 1px solid rgba(255, 255, 255, 0.06); | |
| border-radius: 12px; | |
| padding: 16px 18px; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: space-between; | |
| height: 100%; | |
| } | |
| .kpi-label { | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| color: #64748B; | |
| margin-bottom: 6px; | |
| } | |
| .kpi-val { | |
| font-size: 1.85rem; | |
| font-weight: 800; | |
| letter-spacing: -0.03em; | |
| color: #F8FAFC; | |
| } | |
| .kpi-sub { | |
| font-size: 0.75rem; | |
| color: #94A3B8; | |
| margin-top: 4px; | |
| } | |
| /* Severity Badges */ | |
| .sev-badge { | |
| display: inline-block; | |
| font-size: 0.82rem; | |
| font-weight: 700; | |
| letter-spacing: 0.06em; | |
| text-transform: uppercase; | |
| padding: 4px 12px; | |
| border-radius: 6px; | |
| } | |
| .sev-fluent { | |
| background: rgba(16, 185, 129, 0.15); | |
| color: #34D399; | |
| border: 1px solid rgba(16, 185, 129, 0.35); | |
| } | |
| .sev-mild { | |
| background: rgba(245, 158, 11, 0.15); | |
| color: #FBBF24; | |
| border: 1px solid rgba(245, 158, 11, 0.35); | |
| } | |
| .sev-moderate { | |
| background: rgba(249, 115, 22, 0.15); | |
| color: #FB923C; | |
| border: 1px solid rgba(249, 115, 22, 0.35); | |
| } | |
| .sev-severe { | |
| background: rgba(239, 68, 68, 0.15); | |
| color: #F87171; | |
| border: 1px solid rgba(239, 68, 68, 0.35); | |
| } | |
| .sev-silent { | |
| background: rgba(148, 163, 184, 0.15); | |
| color: #94A3B8; | |
| border: 1px solid rgba(148, 163, 184, 0.35); | |
| } | |
| /* Word Alignment Chips */ | |
| .chip-wrap { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| padding: 14px; | |
| border-radius: 10px; | |
| background: rgba(15, 23, 42, 0.4); | |
| border: 1px solid rgba(255, 255, 255, 0.06); | |
| margin: 10px 0; | |
| } | |
| .word-chip { | |
| padding: 6px 12px; | |
| border-radius: 6px; | |
| font-size: 0.88rem; | |
| font-weight: 600; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .chip-match { | |
| background: rgba(16, 185, 129, 0.12); | |
| color: #34D399; | |
| border: 1px solid rgba(16, 185, 129, 0.28); | |
| } | |
| .chip-mismatch { | |
| background: rgba(239, 68, 68, 0.12); | |
| color: #F87171; | |
| border: 1px solid rgba(239, 68, 68, 0.28); | |
| } | |
| .chip-omitted { | |
| background: rgba(245, 158, 11, 0.12); | |
| color: #FBBF24; | |
| border: 1px solid rgba(245, 158, 11, 0.28); | |
| } | |
| .chip-extra { | |
| background: rgba(168, 85, 247, 0.12); | |
| color: #C084FC; | |
| border: 1px solid rgba(168, 85, 247, 0.28); | |
| } | |
| /* Flaw Diagnostic Items */ | |
| .diag-item-fail { | |
| padding: 10px 14px; | |
| border-radius: 8px; | |
| background: rgba(239, 68, 68, 0.08); | |
| border: 1px solid rgba(239, 68, 68, 0.22); | |
| color: #FCA5A5; | |
| margin-bottom: 8px; | |
| font-size: 0.88rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .diag-item-pass { | |
| padding: 10px 14px; | |
| border-radius: 8px; | |
| background: rgba(16, 185, 129, 0.08); | |
| border: 1px solid rgba(16, 185, 129, 0.22); | |
| color: #6EE7B7; | |
| margin-bottom: 8px; | |
| font-size: 0.88rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| /* Notice Banners */ | |
| .info-banner { | |
| padding: 10px 14px; | |
| border-radius: 8px; | |
| background: rgba(14, 165, 233, 0.08); | |
| border: 1px solid rgba(14, 165, 233, 0.25); | |
| color: #38BDF8; | |
| font-size: 0.85rem; | |
| margin: 8px 0; | |
| } | |
| .warn-banner { | |
| padding: 10px 14px; | |
| border-radius: 8px; | |
| background: rgba(245, 158, 11, 0.08); | |
| border: 1px solid rgba(245, 158, 11, 0.25); | |
| color: #FBBF24; | |
| font-size: 0.85rem; | |
| margin: 8px 0; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| def _get_engine(): | |
| return SpeechDiagnosticEngine.get_instance() | |
| def _process_audio_bytes(raw_bytes: bytes, filename: str = "recording.wav") -> Tuple[str, bytes, float]: | |
| """Resample, condition, and normalize any audio buffer to standard 16kHz PCM WAV.""" | |
| from ml.model.pron_eval import _load_wave, SR | |
| arr = _load_wave(raw_bytes) | |
| tmp_path = Path(tempfile.gettempdir()) / f"anvaya_std_{Path(filename).stem}.wav" | |
| sf.write(str(tmp_path), arr, SR, subtype="PCM_16") | |
| buf = io.BytesIO() | |
| sf.write(buf, arr, SR, format="WAV", subtype="PCM_16") | |
| dur = float(len(arr) / SR) | |
| return str(tmp_path), buf.getvalue(), dur | |
| def _read_file_to_bytes(file_path: str) -> Tuple[str, bytes, float]: | |
| """Read a local audio file and ensure standard 16kHz WAV format for browser playback.""" | |
| from ml.model.pron_eval import _load_wave, SR | |
| arr = _load_wave(file_path) | |
| buf = io.BytesIO() | |
| sf.write(buf, arr, SR, format="WAV", subtype="PCM_16") | |
| dur = float(len(arr) / SR) | |
| return file_path, buf.getvalue(), dur | |
| def _plot_waveform(audio_path: str): | |
| """Plot acoustic waveform & energy envelope.""" | |
| try: | |
| import matplotlib.pyplot as plt | |
| arr, sr = sf.read(audio_path, dtype="float32") | |
| if arr.ndim > 1: | |
| arr = arr.mean(axis=1) | |
| if len(arr) == 0: | |
| return | |
| time_axis = np.linspace(0, len(arr) / sr, num=len(arr)) | |
| fig, ax = plt.subplots(figsize=(10, 1.8), dpi=100) | |
| fig.patch.set_facecolor("none") | |
| ax.set_facecolor("none") | |
| ax.plot(time_axis, arr, color="#38BDF8", alpha=0.9, linewidth=0.8) | |
| ax.fill_between(time_axis, arr, -arr, color="#0284C7", alpha=0.18) | |
| ax.set_xlabel("Time (seconds)", fontsize=8, color="#64748B") | |
| ax.set_ylabel("Amplitude", fontsize=8, color="#64748B") | |
| ax.tick_params(colors="#64748B", labelsize=8) | |
| for spine in ax.spines.values(): | |
| spine.set_color("rgba(255,255,255,0.08)") | |
| plt.tight_layout(pad=0.4) | |
| st.pyplot(fig) | |
| plt.close(fig) | |
| except Exception: | |
| pass | |
| def main(): | |
| engine = _get_engine() | |
| # Session State Initialization | |
| if "target_phrase" not in st.session_state: | |
| st.session_state["target_phrase"] = "the red rabbit ran around the green yard" | |
| # Top Brand Navigation Bar | |
| import torch | |
| device_label = "CUDA (GPU Accelerated)" if torch.cuda.is_available() else "CPU" | |
| st.markdown(f""" | |
| <div class="brand-nav"> | |
| <div> | |
| <div class="brand-title"> | |
| ANVAYA | |
| <span class="brand-tag">v2.0 Assistant</span> | |
| </div> | |
| <div class="brand-sub">Multi-Modal Speech Screening, Phonetic Alignment & Acoustic Phonation Analysis</div> | |
| </div> | |
| <div> | |
| <span class="status-badge"> | |
| <span class="status-dot"></span> | |
| Engine Online · {device_label} | |
| </span> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.info( | |
| "**Clinical Practice & Screening Disclaimer**: Anvaya is an exploratory research prototype for assistive speech practice and acoustic analysis. It is not an FDA-cleared diagnostic medical device and does not substitute for a clinical evaluation by a licensed Speech-Language Pathologist (SLP)." | |
| ) | |
| selected_audio_path: Optional[str] = None | |
| playable_wav_bytes: Optional[bytes] = None | |
| audio_dur: float = 0.0 | |
| # Sidebar: Audio Source Setup | |
| with st.sidebar: | |
| st.markdown("### Audio Ingestion") | |
| input_mode = st.radio( | |
| "Acquisition Method:", | |
| ["Record Microphone Audio", "Upload Audio File (.wav, .mp3, .webm, .m4a)", "Benchmark Sample Library"], | |
| ) | |
| if input_mode == "Record Microphone Audio": | |
| st.caption("Press record to capture speech. If browser errors occur, use direct File Upload.") | |
| try: | |
| mic_audio = st.audio_input("Microphone Input") | |
| if mic_audio: | |
| try: | |
| selected_audio_path, playable_wav_bytes, audio_dur = _process_audio_bytes(mic_audio.getvalue(), "mic_recording.wav") | |
| except Exception as ex: | |
| st.error(f"Could not decode audio: {ex}. Please try again or drop a WAV file.") | |
| except AttributeError: | |
| st.info("Direct audio recording requires Streamlit 1.40+.") | |
| elif input_mode == "Upload Audio File (.wav, .mp3, .webm, .m4a)": | |
| uploaded = st.file_uploader("Upload audio recording", type=["wav", "mp3", "webm", "m4a", "ogg", "flac"]) | |
| if uploaded: | |
| try: | |
| selected_audio_path, playable_wav_bytes, audio_dur = _process_audio_bytes(uploaded.getvalue(), uploaded.name) | |
| except Exception as ex: | |
| st.error(f"Error reading uploaded file: {ex}") | |
| elif input_mode == "Benchmark Sample Library": | |
| available_samples = {k: v for k, v in BENCHMARK_SAMPLES.items() if Path(v[0]).exists()} | |
| if available_samples: | |
| chosen_sample = st.selectbox("Select Ground-Truth Sample:", list(available_samples.keys())) | |
| file_path, matched_text = available_samples[chosen_sample] | |
| selected_audio_path, playable_wav_bytes, audio_dur = _read_file_to_bytes(file_path) | |
| if st.button("Sync Target Phrase to Sample"): | |
| st.session_state["target_phrase"] = matched_text | |
| st.rerun() | |
| st.caption(f"Expected Transcription: \"{matched_text}\"") | |
| else: | |
| st.info("Generate benchmark samples via: python -m ml.cli synth-data") | |
| st.markdown("---") | |
| st.markdown("### Baseline Calibration") | |
| st.caption("Upload a 5-second sample of your healthy voice to calibrate diagnostic thresholds:") | |
| normal_file = st.file_uploader("Healthy Baseline Sample (optional)", type=["wav", "mp3", "webm"], key="normal_uploader") | |
| normal_audio_path = None | |
| if normal_file: | |
| try: | |
| normal_audio_path, _, _ = _process_audio_bytes(normal_file.getvalue(), "normal_baseline.wav") | |
| except Exception: | |
| pass | |
| # Front-and-Center Target Sentence Input Card | |
| with st.container(border=True): | |
| st.markdown("### Target Phrase Configuration") | |
| st.caption("Select a single-word target or choose a standardized clinical reading passage:") | |
| # Quick Word Practice Presets | |
| st.markdown("<div style='font-size:0.75rem; font-weight:700; color:#64748B; text-transform:uppercase; margin-bottom:6px;'>Single Word Evaluation Targets:</div>", unsafe_allow_html=True) | |
| w_cols = st.columns(len(WORD_PRESETS)) | |
| for idx, word in enumerate(WORD_PRESETS): | |
| with w_cols[idx]: | |
| if st.button(word, key=f"btn_word_{word}", use_container_width=True): | |
| st.session_state["target_phrase"] = word | |
| st.rerun() | |
| # Full Practice Sentences | |
| col_preset, col_btn = st.columns([3.5, 1]) | |
| with col_preset: | |
| preset_choice = st.selectbox("Clinical Reading Protocols:", ["(Custom Phrase)"] + list(SENTENCE_PRESETS.keys())) | |
| with col_btn: | |
| if preset_choice != "(Custom Phrase)": | |
| if st.button("Apply Protocol", use_container_width=True): | |
| st.session_state["target_phrase"] = SENTENCE_PRESETS[preset_choice] | |
| st.rerun() | |
| target_sentence = st.text_area( | |
| "Target Phrase (Expected Speech):", | |
| value=st.session_state["target_phrase"], | |
| key="target_phrase_input", | |
| height=60, | |
| ) | |
| st.session_state["target_phrase"] = target_sentence | |
| # Guard: Mandatory Target Phrase | |
| if not target_sentence.strip(): | |
| st.warning("Please configure a Target Phrase above to evaluate speech articulation.") | |
| return | |
| # Direct In-Page Audio Dropzone if nothing selected yet | |
| if not selected_audio_path or not playable_wav_bytes: | |
| with st.container(border=True): | |
| st.markdown("### Audio Acquisition Direct Upload") | |
| st.caption("Record using the left sidebar or drag-and-drop any audio clip (.wav, .mp3, .m4a, voice memo) right here:") | |
| direct_upload = st.file_uploader("Drop audio file here to diagnose", type=["wav", "mp3", "webm", "m4a", "ogg", "flac"], key="main_direct_upload") | |
| if direct_upload: | |
| try: | |
| selected_audio_path, playable_wav_bytes, audio_dur = _process_audio_bytes(direct_upload.getvalue(), direct_upload.name) | |
| st.rerun() | |
| except Exception as ex: | |
| st.error(f"Error loading file: {ex}") | |
| else: | |
| st.info("Record speech in the sidebar, drop an audio file above, or select a Benchmark Sample to run diagnostics.") | |
| return | |
| # Audio Playback and Acoustic Waveform Card | |
| with st.container(border=True): | |
| col_t1, col_t2 = st.columns([1.5, 1.5]) | |
| with col_t1: | |
| st.markdown("##### Reference Target") | |
| st.markdown(f"<div style='font-size:1.15rem; font-weight:700; color:#38BDF8;'>\"{target_sentence.strip()}\"</div>", unsafe_allow_html=True) | |
| st.caption(f"Acoustic Duration: {audio_dur:.2f}s | Sample Rate: 16,000 Hz PCM") | |
| with col_t2: | |
| st.markdown("##### Audio Stream Playback") | |
| st.audio(playable_wav_bytes, format="audio/wav") | |
| st.markdown("##### Acoustic Energy & Waveform Envelope") | |
| _plot_waveform(selected_audio_path) | |
| # -------------------------------------------------------------------------- | |
| # LIVE STREAMING TELEMETRY HUD & DIAGNOSTICS EXECUTION | |
| # -------------------------------------------------------------------------- | |
| st.markdown("---") | |
| # Real-Time Telemetry Container | |
| telemetry_placeholder = st.empty() | |
| logs: List[str] = [] | |
| diag_res: Optional[Dict[str, Any]] = None | |
| if hasattr(engine, "diagnose_audio_stream"): | |
| try: | |
| stream_gen = engine.diagnose_audio_stream( | |
| audio_input=selected_audio_path, | |
| target_phrase=target_sentence, | |
| normal_calibration_audio=normal_audio_path, | |
| ) | |
| for item in stream_gen: | |
| step_num = item["step"] | |
| total_steps = item["total"] | |
| label = item["label"] | |
| detail = item["detail"] | |
| progress = item["progress"] | |
| elapsed = item["elapsed_ms"] | |
| log_entry = f"<div class='log-line log-active'><span class='log-time'>[{elapsed:06.1f}ms]</span> <b>[Step {step_num}/{total_steps}]</b> <span class='log-tag'>{label}</span>: {detail}</div>" | |
| logs.append(log_entry) | |
| telemetry_html = f""" | |
| <div class="telemetry-box"> | |
| <div class="telemetry-header"> | |
| <div class="telemetry-title"> | |
| <span class="status-dot"></span> | |
| LIVE DIAGNOSTIC SUBSYSTEM PIPELINE (STREAMING) | |
| </div> | |
| <div style="font-family:'JetBrains Mono',monospace; font-size:0.75rem; color:#38BDF8;"> | |
| {step_num}/{total_steps} Expert Modules Executed | |
| </div> | |
| </div> | |
| <div class="telemetry-log"> | |
| {''.join(logs)} | |
| </div> | |
| </div> | |
| """ | |
| telemetry_placeholder.markdown(telemetry_html, unsafe_allow_html=True) | |
| if "final_result" in item: | |
| diag_res = item["final_result"] | |
| time.sleep(0.04) | |
| except Exception: | |
| diag_res = engine.diagnose_audio( | |
| audio_input=selected_audio_path, | |
| target_phrase=target_sentence, | |
| normal_calibration_audio=normal_audio_path, | |
| ) | |
| else: | |
| with st.spinner("Executing neural diagnostic pipeline..."): | |
| diag_res = engine.diagnose_audio( | |
| audio_input=selected_audio_path, | |
| target_phrase=target_sentence, | |
| normal_calibration_audio=normal_audio_path, | |
| ) | |
| if not diag_res: | |
| diag_res = engine.diagnose_audio(selected_audio_path, target_sentence, normal_audio_path) | |
| # Handle Silence Guard | |
| if diag_res["is_silent"] or diag_res["decision"].get("is_silent"): | |
| st.warning("No Speech Detected: The audio recording is silent or below acoustic energy thresholds. Please speak clearly into your microphone.") | |
| return | |
| result = diag_res["decision"] | |
| pron = diag_res["pronunciation"] | |
| flaws_report = diag_res["flaws"] | |
| artic = diag_res["articulation"] | |
| p_stut = float(diag_res["stutter_probs"][1]) if (diag_res["stutter_probs"] and len(diag_res["stutter_probs"]) > 1) else 0.0 | |
| # Accuracy and Severity Calculations | |
| pron_acc = max(0.0, min(100.0, (1.0 - pron.get("wer", 0.0)) * 100.0)) | |
| overall_bucket = result["buckets"]["overall"].lower() | |
| fluency_score = int(result["fluency_100"]) if result.get("fluency_100") is not None else 100 | |
| # Length Mismatch Warning Notice | |
| if pron.get("length_warning"): | |
| st.markdown(f""" | |
| <div class="warn-banner"> | |
| <b>Utterance Length Notice</b>: {pron['length_warning']}<br> | |
| <i>Tip: To evaluate individual words (such as <b>'{pron.get('asr_hypothesis')}'</b>), select that word from the <b>Single Word Evaluation Targets</b> above.</i> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Executive Diagnostic KPI Grid | |
| st.markdown(f"### Diagnostic Assessment Summary <span style='font-size:0.78rem; color:#64748B; font-weight:normal;'>(Total Inference Latency: {diag_res['latency_ms']} ms)</span>", unsafe_allow_html=True) | |
| c1, c2, c3, c4 = st.columns(4) | |
| sev_class_map = { | |
| "fluent": "sev-fluent", | |
| "mild": "sev-mild", | |
| "moderate": "sev-moderate", | |
| "severe": "sev-severe", | |
| "silent": "sev-silent", | |
| } | |
| badge_style = sev_class_map.get(overall_bucket, "sev-fluent") | |
| with c1: | |
| st.markdown(f""" | |
| <div class="kpi-card"> | |
| <div class="kpi-label">Clinical Stratification</div> | |
| <div><span class="sev-badge {badge_style}">{overall_bucket.upper()}</span></div> | |
| <div class="kpi-sub">Fused Multi-Modal Assessment</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with c2: | |
| st.markdown(f""" | |
| <div class="kpi-card"> | |
| <div class="kpi-label">Fluency Index</div> | |
| <div class="kpi-val">{fluency_score}<span style="font-size:1rem; color:#64748B;">/100</span></div> | |
| <div class="kpi-sub">Continuous Cadence Rating</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with c3: | |
| st.markdown(f""" | |
| <div class="kpi-card"> | |
| <div class="kpi-label">Pronunciation Accuracy</div> | |
| <div class="kpi-val">{pron_acc:.1f}<span style="font-size:1rem; color:#64748B;">%</span></div> | |
| <div class="kpi-sub">Word Alignment Precision</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with c4: | |
| st.markdown(f""" | |
| <div class="kpi-card"> | |
| <div class="kpi-label">Phonetic Goodness (GOP)</div> | |
| <div class="kpi-val">{pron.get('pron_score', 0) * 100:.1f}<span style="font-size:1rem; color:#64748B;">%</span></div> | |
| <div class="kpi-sub">Sub-Word Acoustic Metric</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Transcription Display | |
| if pron.get("asr_hypothesis"): | |
| st.markdown(f""" | |
| <div class="info-banner" style="margin-top:14px;"> | |
| <b>Decoded Acoustic Transcription</b>: <i>"{pron['asr_hypothesis']}"</i> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Comprehensive Pathology Diagnostic Grid | |
| st.markdown("---") | |
| st.markdown("### Specific Pathology Diagnostic Analysis") | |
| f_col1, f_col2 = st.columns(2) | |
| with f_col1: | |
| with st.container(border=True): | |
| st.markdown("#### Articulatory Sound Disorders ('R' & 'S' Checks)") | |
| # Rhotacism Check | |
| if flaws_report["has_r_flaw"]: | |
| for r_err in flaws_report["r_sound_issues"]: | |
| st.markdown(f"<div class='diag-item-fail'><b>Rhotacism Flaw</b>: {r_err['message']}</div>", unsafe_allow_html=True) | |
| else: | |
| st.markdown("<div class='diag-item-pass'><b>'R' Sound Articulation</b>: Accurate (No R->W/L substitution detected).</div>", unsafe_allow_html=True) | |
| # Sigmatism Check | |
| if flaws_report["has_s_flaw"]: | |
| for s_err in flaws_report["s_sound_issues"]: | |
| st.markdown(f"<div class='diag-item-fail'><b>Sigmatism Flaw</b>: {s_err['message']}</div>", unsafe_allow_html=True) | |
| else: | |
| st.markdown("<div class='diag-item-pass'><b>'S' Sound Articulation</b>: Accurate (No sibilant lisp or 'th' substitution detected).</div>", unsafe_allow_html=True) | |
| with f_col2: | |
| with st.container(border=True): | |
| st.markdown("#### Disfluency Flow & Phonation Acoustics") | |
| # Disfluency / Stutter Check | |
| if p_stut >= 0.78: | |
| st.markdown(f"<div class='diag-item-fail'><b>Disfluency Detected</b>: Elevated probability of repetition/block ({p_stut*100:.1f}%)</div>", unsafe_allow_html=True) | |
| elif p_stut >= 0.60: | |
| st.markdown(f"<div class='diag-item-fail' style='color:#FBBF24; background:rgba(245,158,11,0.08); border-color:rgba(245,158,11,0.25);'><b>Mild Hesitation</b>: Minor syllable repetition observed ({p_stut*100:.1f}%)</div>", unsafe_allow_html=True) | |
| else: | |
| st.markdown("<div class='diag-item-pass'><b>Fluency Cadence</b>: Continuous speech flow (No disfluent events detected).</div>", unsafe_allow_html=True) | |
| # Vocal Phonation Check | |
| if flaws_report["voice_quality_issues"]: | |
| for v_err in flaws_report["voice_quality_issues"]: | |
| st.markdown(f"<div class='diag-item-fail' style='color:#FBBF24; background:rgba(245,158,11,0.08); border-color:rgba(245,158,11,0.25);'><b>Vocal Perturbation</b>: {v_err}</div>", unsafe_allow_html=True) | |
| else: | |
| st.markdown(f"<div class='diag-item-pass'><b>Voice Quality</b>: Healthy phonation (HNR: {artic.get('hnr_db', 0):.1f} dB, Jitter: {artic.get('jitter', 0)*100:.2f}%).</div>", unsafe_allow_html=True) | |
| # Word-by-Word Granular Phonetic Alignment Card | |
| st.markdown("---") | |
| st.markdown("### Granular Word-Level Pronunciation Alignment") | |
| with st.container(border=True): | |
| alignment = pron.get("alignment", []) | |
| # Build visual chips | |
| chips_html = '<div class="chip-wrap">' | |
| for item in alignment: | |
| status = item["status"] | |
| exp = item["expected"] | |
| spk = item["spoken"] | |
| if status == "correct": | |
| chips_html += f'<span class="word-chip chip-match">[MATCH] {exp}</span>' | |
| elif status == "substitution": | |
| chips_html += f'<span class="word-chip chip-mismatch">[DIFF] {exp} (heard: "{spk}")</span>' | |
| elif status == "omission": | |
| chips_html += f'<span class="word-chip chip-omitted">[UNSPOKEN] {exp}</span>' | |
| elif status == "insertion": | |
| chips_html += f'<span class="word-chip chip-extra">[EXTRA] {spk}</span>' | |
| chips_html += '</div>' | |
| st.markdown(chips_html, unsafe_allow_html=True) | |
| # Metrics Sub-Row | |
| m1, m2, m3, m4 = st.columns(4) | |
| with m1: | |
| st.metric("Pronunciation Accuracy", f"{pron_acc:.1f}%") | |
| with m2: | |
| st.metric("Phonetic Goodness (GOP)", f"{pron.get('pron_score', 0) * 100:.1f}%") | |
| with m3: | |
| st.metric("Word Error Rate (WER)", f"{pron.get('wer', 0) * 100:.1f}%") | |
| with m4: | |
| matched_words = max(0, pron.get("n_reference_words", 1) - pron.get("word_error", 0)) | |
| st.metric("Words Matched", f"{matched_words} / {pron.get('n_reference_words', 1)} target words") | |
| # Auditable Evidence Drawer | |
| with st.expander("Auditable Telemetry & Acoustic Evidence Trace"): | |
| st.json({ | |
| "clinical_decision": result, | |
| "pathology_flaws": flaws_report, | |
| "praat_phonation_metrics": artic, | |
| "pronunciation_accuracy_pct": pron_acc, | |
| "step_latencies_ms": diag_res.get("step_timings_ms", {}), | |
| "total_latency_ms": diag_res["latency_ms"], | |
| }) | |
| if __name__ == "__main__": | |
| main() |