Sliders / app.py
Rileypiper's picture
Update app.py
d0c71f9 verified
Raw History Blame Contribute Delete
111 kB
import gradio as gr import numpy as np import librosa import parselmouth import json import csv import os import math import hashlib import time import threading import io import base64 from dataclasses import dataclass, asdict from typing import Optional, Dict, Any, Tuple, List try: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt _HAS_MPL = True except ImportError: _HAS_MPL = False # ============================================================================= # Constants & Content # ============================================================================= RAINBOW_PASSAGE = ( "When the sunlight strikes raindrops in the air, they act like a prism " "and form a rainbow. The rainbow is a division of white light into many " "beautiful colors. These take the shape of a long round arch, with its " "path high above, and its two ends apparently beyond the horizon. " "There is, according to legend, a boiling pot of gold at one end. " "People look, but no one ever finds it. When a person looks for something " "beyond reach, their friends say they are looking for the pot of gold " "at the end of the rainbow." ) PROMPTS = { "Rainbow Passage": RAINBOW_PASSAGE, "Sustained vowels /i/ then /a/ (5s each)": "Say 'eeee' for ~5 seconds, then 'ahhh' for ~5 seconds.", "Counting 1-20": "Count from 1 to 20 at a comfortable pace.", "Spontaneous speech (10-20s)": "Describe what you did yesterday in 2-3 sentences.", } # --------------------------------------------------------------------------- # Distribution anchors for slider mapping # --------------------------------------------------------------------------- # Each entry defines a Normal(mu, sigma) clipped to [lo, hi]. # A raw metric is mapped to a CDF percentile (0-1) then scaled to 0-100. # # Slider polarity (matching Concept Slider design): # 100 = High / Light / Bright / Bouncy / Soft (feminine-associated end) # 0 = Low / Heavy / Dark / Monotone / Loud (masculine-associated end) # # Weight: Higher H1*-H2* -> lighter -> higher slider. No inversion needed. # Volume: Higher RMS -> louder -> *inverted* so 100 = Soft. # --------------------------------------------------------------------------- # Sources: # F0: Titze 1994, Hillenbrand 1995 β€” cis-M β‰ˆ 120 Hz (Οƒβ‰ˆ20), cis-F β‰ˆ 210 Hz (Οƒβ‰ˆ25) # F0 SD: Henton 1989, PΓ©piot 2014 β€” reading ~2.5 st (Οƒβ‰ˆ1.0) # H1*-H2*: Keating & Esposito 2006 β€” cis-M β‰ˆ 2.5 dB, cis-F β‰ˆ 6.5 dB # F2-F1: Hillenbrand 1995 β€” cis-M β‰ˆ 780 Hz, cis-F β‰ˆ 1020 Hz # Formant dispersion: Fitch 1997 β€” cis-M β‰ˆ 1050 Hz, cis-F β‰ˆ 1150 Hz # --------------------------------------------------------------------------- GLOBAL_DISTS = { "f0_med_hz": {"mu": 165.0, "sigma": 50.0, "lo": 65.0, "hi": 350.0}, "f0_sd_st": {"mu": 2.5, "sigma": 1.0, "lo": 0.2, "hi": 7.0}, "rms_dbfs": {"mu": -22.0, "sigma": 6.0, "lo": -45.0, "hi": -8.0}, "resonance_combo": {"mu": 0.0, "sigma": 1.0, "lo": -3.0, "hi": 3.0}, "h1_h2_corrected": {"mu": 4.5, "sigma": 4.0, "lo": -8.0, "hi": 18.0}, } ANCHORED_DISTS = { **GLOBAL_DISTS, "f0_med_hz": {"mu": 160.0, "sigma": 55.0, "lo": 70.0, "hi": 340.0}, "h1_h2_corrected": {"mu": 3.5, "sigma": 4.5, "lo": -8.0, "hi": 16.0}, } # Legacy keys for baseline compatibility β€” if a baseline was built with old keys, # select_dists will still find them. _LEGACY_DIST_MAP = { "f2_minus_f1_hz": "resonance_combo", "h1_h2_db": "h1_h2_corrected", } # ── Resonance method definitions ── # Each method maps a Metrics field to a distribution and polarity. # "field": attribute name on the Metrics dataclass # "dist": distribution dict (mu/sigma/lo/hi) for percentile mapping # "invert": if True, lower raw value β†’ higher (brighter) slider position # "unit": display unit for the report # "source": brief literature reference RESONANCE_METHODS = { "F2 βˆ’ F1 (Hz)": { "field": "f2_minus_f1_hz", "dist": {"mu": 900.0, "sigma": 200.0, "lo": 300.0, "hi": 1600.0}, "invert": False, # higher = brighter "unit": "Hz", "source": "Hillenbrand 1995 β€” cis-M β‰ˆ 780, cis-F β‰ˆ 1020", }, "Formant Dispersion Df": { "field": "formant_dispersion_hz", "dist": {"mu": 1100.0, "sigma": 150.0, "lo": 700.0, "hi": 1500.0}, "invert": False, # higher spacing = shorter VTL = brighter "unit": "Hz", "source": "Fitch 1997 β€” cis-M β‰ˆ 1050, cis-F β‰ˆ 1150", }, "Est. VTL (cm)": { "field": "vtl_cm", "dist": {"mu": 16.0, "sigma": 1.8, "lo": 10.0, "hi": 22.0}, "invert": True, # shorter VTL = brighter β†’ invert "unit": "cm", "source": "Fitch 1997; VTL = c / (4 Γ— Df)", }, "F2/F1 Ratio": { "field": "f2_f1_ratio", "dist": {"mu": 2.0, "sigma": 0.5, "lo": 1.0, "hi": 4.0}, "invert": False, # higher ratio = brighter "unit": "", "source": "Derived; normalizes vowel-height confound", }, "Composite Z (default)": { "field": "resonance_combo", "dist": {"mu": 0.0, "sigma": 1.0, "lo": -3.0, "hi": 3.0}, "invert": False, # z-score: positive = brighter "unit": "z", "source": "Z-scored mean of F2βˆ’F1 + Df", }, "Spectral Mean (Hz)": { "field": "spectral_mean_hz", "dist": {"mu": 1400.0, "sigma": 350.0, "lo": 600.0, "hi": 3000.0}, "invert": False, # higher CoG = brighter "unit": "Hz", "source": "Power-spectrum center of gravity above F0; formant-free", }, "F3 (Hz)": { "field": "f3_hz", "dist": {"mu": 2800.0, "sigma": 250.0, "lo": 2000.0, "hi": 3800.0}, "invert": False, # higher F3 = shorter pharynx = brighter "unit": "Hz", "source": "Direct pharyngeal-space cue; Vorperian & Kent 2007", }, } RESONANCE_METHOD_DEFAULT = "Composite Z (default)" DEFAULT_SR = 22050 FRAME_LEN = 0.040 HOP_LEN = 0.010 PITCH_FLOOR_DEFAULT = 60 PITCH_CEIL_DEFAULT = 500 MAX_FORMANT_DEFAULT = 5500 # Speed of sound in cm/s for VTL estimation (Fitch 1997) SPEED_OF_SOUND_CM = 35300.0 HISTORY_CAP = 200 MAX_UPLOAD_SECONDS = 120 MAX_LIVE_BUFFER_SECONDS = 12 LIVE_ANALYZE_SECONDS = 8 EXPORT_DIR = "exports" PROFILE_JSON_PATH = os.path.join(EXPORT_DIR, "profile.json") HISTORY_CSV_PATH = os.path.join(EXPORT_DIR, "history.csv") BASELINE_JSON_PATH = os.path.join(EXPORT_DIR, "baseline.json") # ============================================================================= # CSS - colour-coded sliders matching the Concept Slider design # ============================================================================= CUSTOM_CSS = """ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,500;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;600&display=swap'); :root { --vts-pitch: #5b7de8; --vts-weight: #9b6dba; --vts-resonance: #e8956e; --vts-prosody: #d4c24a; --vts-volume: #e878c8; --vts-surface: rgba(255,255,255,0.04); --vts-border: rgba(255,255,255,0.08); --vts-text: rgba(255,255,255,0.88); --vts-muted: rgba(255,255,255,0.45); --vts-radius: 16px; } .gradio-container { max-width: 960px !important; font-family: 'DM Sans', -apple-system, sans-serif !important; } /* --- Header --- */ .vts-header { text-align: center; padding: 2.2rem 1rem 1.4rem; position: relative; overflow: hidden; } .vts-header::before { content: ''; position: absolute; inset: 0; background: radial-gradient(ellipse 70% 50% at 50% 0%, rgba(91,125,232,0.12), transparent), radial-gradient(ellipse 50% 40% at 80% 20%, rgba(232,135,110,0.08), transparent); pointer-events: none; } .vts-header h1 { font-family: 'DM Sans', sans-serif; font-weight: 700; font-size: 2rem; letter-spacing: -0.03em; margin: 0 0 0.3rem; background: linear-gradient(135deg, var(--vts-pitch), var(--vts-resonance)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .vts-header p { color: var(--vts-muted); font-size: 0.92rem; line-height: 1.5; margin: 0; } .vts-header .vts-tag { display: inline-block; margin-top: 0.6rem; padding: 0.25rem 0.75rem; border-radius: 100px; font-size: 0.72rem; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; background: rgba(91,125,232,0.12); color: var(--vts-pitch); border: 1px solid rgba(91,125,232,0.2); } /* --- Tabs --- */ .tabs > .tab-nav > button { font-family: 'DM Sans', sans-serif !important; font-weight: 600 !important; font-size: 0.95rem !important; letter-spacing: -0.01em !important; } /* --- Slider track overrides --- */ .slider-pitch input[type=range]::-webkit-slider-runnable-track { background: linear-gradient(90deg, #2a3578, #5b7de8) !important; border-radius: 6px !important; } .slider-pitch input[type=range]::-moz-range-track { background: linear-gradient(90deg, #2a3578, #5b7de8) !important; border-radius: 6px !important; } .slider-pitch input[type=range]::-webkit-slider-thumb { background: var(--vts-pitch) !important; } .slider-pitch input[type=range]::-moz-range-thumb { background: var(--vts-pitch) !important; } .slider-pitch label span, .slider-pitch .label-wrap span { color: var(--vts-pitch) !important; font-weight: 600 !important; } .slider-weight input[type=range]::-webkit-slider-runnable-track { background: linear-gradient(90deg, #3d1f5c, #9b6dba) !important; border-radius: 6px !important; } .slider-weight input[type=range]::-moz-range-track { background: linear-gradient(90deg, #3d1f5c, #9b6dba) !important; border-radius: 6px !important; } .slider-weight input[type=range]::-webkit-slider-thumb { background: var(--vts-weight) !important; } .slider-weight input[type=range]::-moz-range-thumb { background: var(--vts-weight) !important; } .slider-weight label span, .slider-weight .label-wrap span { color: var(--vts-weight) !important; font-weight: 600 !important; } .slider-resonance input[type=range]::-webkit-slider-runnable-track { background: linear-gradient(90deg, #8b4a2a, #e8956e) !important; border-radius: 6px !important; } .slider-resonance input[type=range]::-moz-range-track { background: linear-gradient(90deg, #8b4a2a, #e8956e) !important; border-radius: 6px !important; } .slider-resonance input[type=range]::-webkit-slider-thumb { background: var(--vts-resonance) !important; } .slider-resonance input[type=range]::-moz-range-thumb { background: var(--vts-resonance) !important; } .slider-resonance label span, .slider-resonance .label-wrap span { color: var(--vts-resonance) !important; font-weight: 600 !important; } .slider-prosody input[type=range]::-webkit-slider-runnable-track { background: linear-gradient(90deg, #6b5a10, #d4c24a) !important; border-radius: 6px !important; } .slider-prosody input[type=range]::-moz-range-track { background: linear-gradient(90deg, #6b5a10, #d4c24a) !important; border-radius: 6px !important; } .slider-prosody input[type=range]::-webkit-slider-thumb { background: var(--vts-prosody) !important; } .slider-prosody input[type=range]::-moz-range-thumb { background: var(--vts-prosody) !important; } .slider-prosody label span, .slider-prosody .label-wrap span { color: var(--vts-prosody) !important; font-weight: 600 !important; } .slider-volume input[type=range]::-webkit-slider-runnable-track { background: linear-gradient(90deg, #8a2070, #e878c8) !important; border-radius: 6px !important; } .slider-volume input[type=range]::-moz-range-track { background: linear-gradient(90deg, #8a2070, #e878c8) !important; border-radius: 6px !important; } .slider-volume input[type=range]::-webkit-slider-thumb { background: var(--vts-volume) !important; } .slider-volume input[type=range]::-moz-range-thumb { background: var(--vts-volume) !important; } .slider-volume label span, .slider-volume .label-wrap span { color: var(--vts-volume) !important; font-weight: 600 !important; } .slider-confidence input[type=range]::-webkit-slider-runnable-track { background: linear-gradient(90deg, #444, #888) !important; border-radius: 6px !important; } .slider-confidence input[type=range]::-moz-range-track { background: linear-gradient(90deg, #444, #888) !important; border-radius: 6px !important; } /* --- Prompt box --- */ .prompt-box textarea { font-size: 1.02em !important; line-height: 1.65 !important; font-family: 'DM Sans', sans-serif !important; opacity: 0.85; } /* --- Coaching card styling --- */ .coaching-card p { line-height: 1.6 !important; } .coaching-card strong { letter-spacing: -0.01em; } /* --- Clean up accordions --- */ .gradio-accordion { border: 1px solid var(--vts-border) !important; border-radius: var(--vts-radius) !important; } /* --- Monospace for reports --- */ .report-box textarea { font-family: 'JetBrains Mono', monospace !important; font-size: 0.82em !important; } """ # ============================================================================= # JSON helper - handles NaN / Inf that stdlib json cannot serialise # ============================================================================= class _SafeEncoder(json.JSONEncoder): def encode(self, o): return super().encode(self._sanitise(o)) def _sanitise(self, o): # Handle numpy scalars first (np.float32 is NOT a subclass of float) if isinstance(o, (np.floating, float)): if math.isnan(float(o)) or math.isinf(float(o)): return None return float(o) if isinstance(o, np.integer): return int(o) if isinstance(o, dict): return {k: self._sanitise(v) for k, v in o.items()} if isinstance(o, (list, tuple)): return [self._sanitise(v) for v in o] return o def _json_dump(obj, fh, **kw): json.dump(obj, fh, cls=_SafeEncoder, **kw) # ============================================================================= # Utilities # ============================================================================= def ensure_exports_dir(): os.makedirs(EXPORT_DIR, exist_ok=True) def sanitize_audio(x): x = np.asarray(x, dtype=np.float32) x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0) if x.size: x = x - float(np.mean(x)) return np.clip(x, -1.0, 1.0) def to_mono_float32(y): if y is None: return None x = np.asarray(y) if x.size == 0: return None x = x.astype(np.float32, copy=False) if x.ndim == 2: x = np.mean(x, axis=0) if x.shape[0] < x.shape[1] else np.mean(x, axis=1) mx = float(np.max(np.abs(x))) if x.size else 0.0 if mx > 1.5: x = x / mx return sanitize_audio(x) def normalize_audio_tuple(audio_like): if audio_like is None: return None if isinstance(audio_like, (tuple, list)) and len(audio_like) == 2: sr, y = audio_like try: sr = int(sr) if sr is not None else DEFAULT_SR except Exception: sr = DEFAULT_SR y = np.asarray(y, dtype=np.float32) return sr, y if isinstance(audio_like, np.ndarray): return DEFAULT_SR, audio_like.astype(np.float32, copy=False) try: arr = np.asarray(audio_like, dtype=np.float32) if arr.ndim >= 1 and arr.size > 0: return DEFAULT_SR, arr except Exception: pass return None def resample_if_needed(y, sr, target_sr=DEFAULT_SR): if sr != target_sr: y = librosa.resample(y, orig_sr=sr, target_sr=target_sr) return y.astype(np.float32), target_sr return y, sr def hard_cap_length(y, sr, max_seconds): if y is None or y.size == 0: return y max_len = int(max_seconds * sr) return y[-max_len:] if y.size > max_len else y def select_analysis_window(y, sr, window_choice): if y is None or y.size == 0: return y if window_choice == "Full": return y secs = {"Last 10s": 10, "Last 20s": 20}.get(window_choice, 0) if secs > 0: n = int(secs * sr) return y[-n:] if y.size > n else y return y def dbfs_rms(x): eps = 1e-12 rms = np.sqrt(np.mean(np.square(x)) + eps) return float(20.0 * np.log10(rms + eps)) def detect_clipping(x, thresh=0.999): if x is None or x.size == 0: return 0.0 return float(np.mean(np.abs(x) >= thresh)) def estimate_snr_db(y, sr): frame = int(FRAME_LEN * sr) hop = int(HOP_LEN * sr) if y.size < frame: return float("nan") try: rms = librosa.feature.rms(y=y, frame_length=frame, hop_length=hop)[0] except Exception: return float("nan") if rms.size < 10: return float("nan") rms_sorted = np.sort(rms) lo = np.mean(rms_sorted[: max(1, rms_sorted.size // 5)]) hi = np.mean(rms_sorted[-max(1, rms_sorted.size // 5):]) eps = 1e-12 return float(20.0 * np.log10((hi + eps) / (lo + eps))) def normal_cdf(z): return 0.5 * (1.0 + math.erf(z / math.sqrt(2.0))) def pct_to_slider(p): if not np.isfinite(p): return float("nan") return float(np.clip(p, 0.0, 1.0) * 100.0) def safe(v, fallback=0.0): return float(v) if np.isfinite(v) else float(fallback) def cap_history(history): return (history or [])[-HISTORY_CAP:] def audio_hash(y, sr, extra=""): h = hashlib.sha256() h.update(str(sr).encode()) h.update(extra.encode()) x = y if x.size > 32000: x = x[::max(1, x.size // 32000)] h.update(x.tobytes()) return h.hexdigest() def apply_preemphasis(y, coef=0.97): try: return librosa.effects.preemphasis(y, coef=coef).astype(np.float32) except Exception: return y def loudness_normalize_dbfs(y, target_dbfs=-20.0): cur = dbfs_rms(y) if not np.isfinite(cur) or cur < -45.0: return y gain = float(10.0 ** ((target_dbfs - cur) / 20.0)) return np.clip(y * gain, -1.0, 1.0).astype(np.float32) def preprocess_audio(y, use_preemph, use_loudnorm): if use_preemph: y = apply_preemphasis(y) if use_loudnorm: y = loudness_normalize_dbfs(y) return y.astype(np.float32) def robust_center_scale(x): x = np.asarray(x, dtype=np.float32) x = x[np.isfinite(x)] if x.size < 5: return float("nan"), float("nan") med = float(np.median(x)) iqr = float(np.percentile(x, 75) - np.percentile(x, 25)) sigma = float(max(iqr / 1.349, 1e-6)) return med, sigma def robust_clip(x, k=3.5): mu, sigma = robust_center_scale(x) if not (np.isfinite(mu) and np.isfinite(sigma)): return x lo, hi = mu - k * sigma, mu + k * sigma return x[(x >= lo) & (x <= hi)] def history_preview(history, max_rows=20): if not history: return [] cols = ["timestamp", "label", "confidence", "Pitch_slider", "Weight_slider", "Resonance_slider", "Prosody_slider", "Volume_slider"] rows = history[-max_rows:] out = [] for r in rows: row = [] for c in cols: v = r.get(c, "") if isinstance(v, float): if math.isnan(v) or math.isinf(v): row.append("\u2014") elif c == "confidence": row.append(round(v, 2)) else: row.append(round(v, 1)) else: row.append(v) out.append(row) return out def _now_iso(): return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) # ============================================================================= # Metrics # ============================================================================= @dataclass class Metrics: # Pitch f0_med_hz: float f0_iqr_hz: float f0_sd_st: float voicing_ratio: float # Prosody (composite) f0_velocity_st_s: float # pitch velocity: semitones per second f0_direction_rate: float # directional changes per second prosody_composite: float # weighted combination for slider # Volume / signal quality rms_dbfs: float snr_db: float clipping_ratio: float # Formants / resonance f1_hz: float f2_hz: float f3_hz: float f2_minus_f1_hz: float formant_dispersion_hz: float # (F3 - F1) / 2 vtl_cm: float # estimated vocal tract length f2_f1_ratio: float # F2/F1, dimensionless resonance ratio resonance_combo: float # z-scored composite of F2-F1 + Df spectral_mean_hz: float # center of gravity of power spectrum above F0 # Voice quality / weight h1_h2_db: float # uncorrected H1-H2 h1_h2_corrected_db: float # H1*-H2* with Iseli-Alwan formant correction cpp_db: float # real Cepstral Peak Prominence jitter_local: float # local jitter (%) shimmer_local: float # local shimmer (%) hnr_db: float # harmonics-to-noise ratio # Meta confidence: float warnings: List[str] def empty_metrics(warnings=None): nan = float("nan") return Metrics( nan, nan, nan, nan, # pitch nan, nan, nan, # prosody nan, nan, nan, # volume nan, nan, nan, nan, nan, nan, nan, nan, nan, # formants/resonance (9) nan, nan, nan, nan, nan, nan, # voice quality 0.0, warnings or [], # meta ) def _compute_cpp(y, sr, f0_med, frame_len_samp, hop_samp, pitch_floor_hz, pitch_ceil_hz): """Compute Cepstral Peak Prominence (Hillenbrand et al. 1994). CPP = height of the cepstral peak (at the expected quefrency for F0) above a regression line fitted to the cepstrum. This is the gold-standard voice quality metric, distinct from HNR. """ if not np.isfinite(f0_med) or f0_med <= 0: return float("nan") # Quefrency range corresponding to plausible F0 q_lo = int(sr / min(pitch_ceil_hz, 600)) # high pitch β†’ low quefrency q_hi = int(sr / max(pitch_floor_hz, 50)) # low pitch β†’ high quefrency if q_lo >= q_hi or q_hi <= 0: return float("nan") n_fft = max(2048, int(2 ** np.ceil(np.log2(frame_len_samp * 2)))) window = np.hanning(frame_len_samp).astype(np.float32) cpp_vals = [] # Process frames for start in range(0, max(1, y.size - frame_len_samp), hop_samp * 4): seg = y[start:start + frame_len_samp] if seg.size < frame_len_samp: break seg_w = seg * window # Power spectrum β†’ log β†’ IFFT = power cepstrum spec = np.fft.rfft(seg_w, n=n_fft) log_power = np.log(np.abs(spec) ** 2 + 1e-20) cepstrum = np.fft.irfft(log_power) # Extract the quefrency range of interest if q_hi >= len(cepstrum): continue cep_region = cepstrum[q_lo:q_hi + 1] if cep_region.size < 3: continue # Find cepstral peak peak_idx_local = int(np.argmax(cep_region)) peak_val = float(cep_region[peak_idx_local]) # Fit regression line to cepstrum in the region x_reg = np.arange(cep_region.size, dtype=np.float64) if x_reg.size >= 2: coeffs = np.polyfit(x_reg, cep_region, 1) regression_at_peak = float(np.polyval(coeffs, peak_idx_local)) cpp_frame = peak_val - regression_at_peak if np.isfinite(cpp_frame): cpp_vals.append(cpp_frame) if not cpp_vals: return float("nan") return float(np.median(cpp_vals)) def extract_distributions(y, sr, pitch_floor_hz, pitch_ceil_hz, max_formant_hz): """Extract comprehensive acoustic metrics with clinical-grade accuracy. Improvements over naive approach: 1. to_pitch_cc(very_accurate=True) for robust F0 estimation 2. Adaptive max_formant based on detected F0 range 3. Formant bandwidth-based vowel detection 4. F3 tracking + formant dispersion + VTL estimation for resonance 5. H1*-H2* with Iseli-Alwan formant correction for vocal weight 6. Real CPP from power cepstrum 7. Jitter and shimmer from Praat for voice quality / confidence 8. Prosody composite: pitch velocity + directional change rate 9. n_fft=4096 for better frequency resolution in H1-H2 10. Composite resonance metric (z-scored F2-F1 + formant dispersion) """ warnings = [] if y is None or y.size == 0: return empty_metrics(["No audio."]), {} try: y_trim, _ = librosa.effects.trim(y, top_db=35) if y_trim.size >= int(0.25 * sr): y = y_trim else: warnings.append("Trim removed most audio; using untrimmed signal.") except Exception: warnings.append("Trim failed; using untrimmed signal.") audio_duration_s = y.size / sr if audio_duration_s < 0.5: warnings.append("Audio is short (<0.5 s). Results may be unstable.") rms_db = dbfs_rms(y) clip_ratio = detect_clipping(y) snr = estimate_snr_db(y, sr) if np.isfinite(snr) and snr < 10: warnings.append("High noise / low SNR. Record in a quieter space.") if clip_ratio > 0.002: warnings.append("Clipping detected. Lower input gain or increase mic distance.") try: snd = parselmouth.Sound(y, sampling_frequency=sr) except Exception: m = empty_metrics(warnings + ["Praat Sound creation failed."]) m.rms_dbfs, m.snr_db, m.clipping_ratio = rms_db, snr, clip_ratio return m, {} # ── 1. Pitch: cross-correlation with very_accurate ── try: pitch = snd.to_pitch_cc(time_step=HOP_LEN, pitch_floor=pitch_floor_hz, pitch_ceiling=pitch_ceil_hz, very_accurate=True) f0 = pitch.selected_array["frequency"] times = pitch.xs() except Exception: # Fallback to standard autocorrelation if cc fails try: pitch = snd.to_pitch(time_step=HOP_LEN, pitch_floor=pitch_floor_hz, pitch_ceiling=pitch_ceil_hz) f0 = pitch.selected_array["frequency"] times = pitch.xs() warnings.append("Fell back to autocorrelation pitch tracker.") except Exception: m = empty_metrics(warnings + ["Pitch extraction failed."]) m.rms_dbfs, m.snr_db, m.clipping_ratio = rms_db, snr, clip_ratio return m, {} if f0.size == 0: m = empty_metrics(warnings + ["Pitch output empty."]) m.rms_dbfs, m.snr_db, m.clipping_ratio = rms_db, snr, clip_ratio return m, {} n = min(len(f0), len(times)) f0, times = f0[:n], times[:n] voiced = (f0 > 0) & np.isfinite(f0) f0_voiced = f0[voiced].astype(np.float32) f0_voiced = f0_voiced[(f0_voiced >= pitch_floor_hz) & (f0_voiced <= pitch_ceil_hz)] # Median filter to remove isolated octave errors if f0_voiced.size >= 9: f0_voiced = librosa.decompose.nn_filter(f0_voiced[None, :], aggregate=np.median)[0] voicing_ratio = float(np.mean(voiced)) if voicing_ratio < 0.35: warnings.append("Low voicing ratio. Speak more continuously for better estimates.") if f0_voiced.size == 0: m = empty_metrics(warnings + ["No voiced frames detected."]) m.voicing_ratio = voicing_ratio m.rms_dbfs, m.snr_db, m.clipping_ratio = rms_db, snr, clip_ratio return m, {} f0_med = float(np.median(f0_voiced)) f0_iqr = float(np.percentile(f0_voiced, 75) - np.percentile(f0_voiced, 25)) st = 12.0 * np.log2(f0_voiced / max(f0_med, 1e-6)) f0_sd_st = float(np.std(st)) # ── 7. Prosody dynamics: velocity + directional changes ── # Use the time-ordered voiced F0 contour for temporal features voiced_times = times[voiced][:f0_voiced.size] f0_velocity_st_s = float("nan") f0_direction_rate = float("nan") prosody_composite = f0_sd_st # fallback: just SD if f0_voiced.size >= 5 and voiced_times.size >= 5: # Semitone contour st_contour = 12.0 * np.log2(f0_voiced / max(f0_med, 1e-6)) dt = np.diff(voiced_times) dt = np.maximum(dt, 1e-6) # avoid divide-by-zero dst = np.abs(np.diff(st_contour)) velocities = dst / dt f0_velocity_st_s = float(np.median(velocities)) # Directional changes: sign changes in pitch derivative pitch_diff = np.diff(st_contour) signs = np.sign(pitch_diff) signs = signs[signs != 0] # remove flat segments if signs.size >= 2: direction_changes = float(np.sum(np.abs(np.diff(signs)) > 0)) total_time = voiced_times[-1] - voiced_times[0] if total_time > 0.1: f0_direction_rate = direction_changes / total_time # Composite: weighted combination (SD 60%, velocity 25%, direction 15%) # Normalize velocity to semitone-scale (typical range: 5-30 st/s) vel_norm = float(np.clip(f0_velocity_st_s / 5.0, 0, 6.0)) if np.isfinite(f0_velocity_st_s) else f0_sd_st dir_norm = float(np.clip(f0_direction_rate / 3.0, 0, 6.0)) if np.isfinite(f0_direction_rate) else f0_sd_st prosody_composite = 0.60 * f0_sd_st + 0.25 * vel_norm + 0.15 * dir_norm # ── RMS frames ── frame = int(FRAME_LEN * sr) hop = int(HOP_LEN * sr) if y.size >= frame: rms_frames = librosa.feature.rms(y=y, frame_length=frame, hop_length=hop)[0] rms_frames_dbfs = (20.0 * np.log10(np.maximum(rms_frames, 1e-12))).astype(np.float32) else: rms_frames_dbfs = np.array([rms_db], dtype=np.float32) # ── 2. Adaptive formant settings based on detected F0 ── if f0_med < 140: adaptive_max_formant = min(max_formant_hz, 5000) adaptive_num_formants = 5 elif f0_med > 200: adaptive_max_formant = max(max_formant_hz, 5500) adaptive_num_formants = 5 else: adaptive_max_formant = max_formant_hz adaptive_num_formants = 5 # ── 3. Vowel mask with formant bandwidth check ── formant = None try: formant = snd.to_formant_burg(time_step=HOP_LEN, max_number_of_formants=adaptive_num_formants, maximum_formant=adaptive_max_formant) except Exception: warnings.append("Formant tracking failed (resonance unavailable).") # Build improved vowel mask: voiced + ZCR + flatness + bandwidth if y.size < frame: vowel_mask = voiced.copy() else: try: zcr = librosa.feature.zero_crossing_rate(y, frame_length=frame, hop_length=hop)[0] n_fft = max(512, int(2 ** np.ceil(np.log2(frame)))) flat = librosa.feature.spectral_flatness(y=y, n_fft=n_fft, hop_length=hop)[0] n_frames = min(len(zcr), len(flat), len(rms_frames_dbfs)) zcr, flat = zcr[:n_frames], flat[:n_frames] idx = np.clip(np.rint(times * sr / hop).astype(int), 0, n_frames - 1) # Base mask: voiced + low ZCR + low flatness vowel_mask = voiced & (zcr[idx] < 0.12) & (flat[idx] < 0.35) # ── 5. Add formant bandwidth criterion ── if formant is not None: bw_ok = np.ones(len(times), dtype=bool) for i, t in enumerate(times): if not vowel_mask[i]: continue b1 = formant.get_bandwidth_at_time(1, float(t)) if np.isfinite(b1) and b1 > 400.0: bw_ok[i] = False # Wide B1 β†’ not a clean vowel vowel_mask = vowel_mask & bw_ok except Exception: vowel_mask = voiced.copy() warnings.append("Frame features failed; using voiced-only mask.") # ── 4. Formant extraction: F1, F2, F3 + dispersion + VTL ── f1_vals, f2_vals, f3_vals, f2_minus_f1_vals, df_vals = [], [], [], [], [] if formant is not None: for t, ok in zip(times, vowel_mask): if not ok: continue f1 = formant.get_value_at_time(1, float(t)) f2 = formant.get_value_at_time(2, float(t)) f3 = formant.get_value_at_time(3, float(t)) if not (np.isfinite(f1) and np.isfinite(f2) and 150 < f1 < 1200 and 500 < f2 < 3500): continue f1_vals.append(float(f1)) f2_vals.append(float(f2)) f2_minus_f1_vals.append(float(f2 - f1)) if np.isfinite(f3) and f3 > f2 and f3 < 4500: f3_vals.append(float(f3)) df_vals.append(float((f3 - f1) / 2.0)) f2m = np.array(f2_minus_f1_vals, dtype=np.float32) if f2m.size: f2m = robust_clip(f2m) if formant is None or f2m.size < 10: warnings.append("Few stable vowel frames for formants. Resonance estimate may be noisy.") f1_med = float(np.median(f1_vals)) if f1_vals else float("nan") f2_med = float(np.median(f2_vals)) if f2_vals else float("nan") f3_med = float(np.median(f3_vals)) if f3_vals else float("nan") f2_minus_f1_med = float(np.median(f2m)) if f2m.size else float("nan") # Formant dispersion (Fitch 1997) df_arr = np.array(df_vals, dtype=np.float32) if df_arr.size: df_arr = robust_clip(df_arr) formant_disp_med = float(np.median(df_arr)) if df_arr.size else float("nan") # ── 6. VTL estimation: VTL = c / (4 * Ξ”f) ── vtl_cm = float("nan") if np.isfinite(formant_disp_med) and formant_disp_med > 50: vtl_cm = SPEED_OF_SOUND_CM / (4.0 * formant_disp_med) # Composite resonance: z-score F2-F1 and Df, then average # Normative: F2-F1 muβ‰ˆ900 Οƒβ‰ˆ200, Df muβ‰ˆ1050 Οƒβ‰ˆ150 resonance_combo = float("nan") z_parts = [] if np.isfinite(f2_minus_f1_med): z_parts.append((f2_minus_f1_med - 900.0) / 200.0) if np.isfinite(formant_disp_med): z_parts.append((formant_disp_med - 1050.0) / 150.0) if z_parts: resonance_combo = float(np.mean(z_parts)) # ── F2/F1 ratio (dimensionless resonance) ── f2_f1_ratio = float("nan") if f1_vals and f2_vals: f1_arr_r = np.array(f1_vals, dtype=np.float32) f2_arr_r = np.array(f2_vals, dtype=np.float32) # Avoid division by zero; exclude extreme F1 values valid = f1_arr_r > 200 if valid.sum() > 3: ratios = f2_arr_r[valid] / f1_arr_r[valid] ratios = robust_clip(ratios) f2_f1_ratio = float(np.median(ratios)) # ── Spectral mean / center of gravity above F0 (formant-free resonance) ── spectral_mean_hz = float("nan") if np.isfinite(f0_med) and y.size >= frame: # Compute average power spectrum across voiced frames, then find CoG n_fft_cog = 4096 freqs_cog = np.fft.rfftfreq(n_fft_cog, d=1.0 / sr) window_cog = np.hanning(frame).astype(np.float32) sum_power = np.zeros(n_fft_cog // 2 + 1, dtype=np.float64) n_frames_cog = 0 centers_cog = (times * sr).astype(int) half_cog = frame // 2 for i, (c, v) in enumerate(zip(centers_cog, voiced)): if not v: continue s0 = max(0, c - half_cog) s1 = s0 + frame if s1 > y.size: continue seg = y[s0:s1] * window_cog power = np.abs(np.fft.rfft(seg, n=n_fft_cog)) ** 2 sum_power += power n_frames_cog += 1 if n_frames_cog > 5: avg_power = sum_power / n_frames_cog # Mask below 1.5 Γ— F0 to exclude fundamental and first harmonic f_cutoff = 1.5 * f0_med mask_above_f0 = freqs_cog >= f_cutoff if mask_above_f0.sum() > 10: p_above = avg_power[mask_above_f0] f_above = freqs_cog[mask_above_f0] total = p_above.sum() if total > 0: spectral_mean_hz = float(np.sum(f_above * p_above) / total) # ── 9. H1*-H2* with Iseli-Alwan formant correction ── h1h2_raw_vals = [] h1h2_corrected_vals = [] if y.size >= frame: n_fft2 = 4096 # ← doubled from 2048 for better frequency resolution window = np.hanning(frame).astype(np.float32) centers = (times * sr).astype(int) half = frame // 2 freqs = np.fft.rfftfreq(n_fft2, d=1.0 / sr) def amp_db_at(mag, f_target): """Get amplitude in dB at frequency f_target with parabolic interpolation.""" i0 = int(np.clip(int(np.argmin(np.abs(freqs - f_target))), 1, mag.size - 2)) # Parabolic interpolation around peak a, b, c = float(mag[i0 - 1]), float(mag[i0]), float(mag[i0 + 1]) if b > 0 and b >= a and b >= c: val = b else: val = float(np.mean(mag[max(0, i0 - 1):i0 + 2])) return float(20.0 * np.log10(val + 1e-12)), (b >= a and b >= c) def formant_correction_db(f_signal, f_formant, b_formant): """Iseli-Alwan (2004) single-formant transfer function magnitude in dB. H(f) = 1 / sqrt((1 - (f/F)^2)^2 + (f*B/(F^2))^2) Returns 20*log10(|H(f)|).""" if not (np.isfinite(f_formant) and np.isfinite(b_formant) and f_formant > 0 and b_formant > 0): return 0.0 r = f_signal / f_formant term1 = (1.0 - r * r) ** 2 term2 = (f_signal * b_formant / (f_formant * f_formant)) ** 2 denom = term1 + term2 if denom < 1e-12: return 0.0 return float(-10.0 * np.log10(denom)) for t, ok, c in zip(times, vowel_mask, centers): if not ok or c - half < 0 or c + half >= y.size: continue f0_t = float(pitch.get_value_at_time(float(t))) if not np.isfinite(f0_t) or f0_t <= 0 or 2.0 * f0_t >= freqs[-1]: continue seg_w = y[c - half:c + half] * window mag = np.abs(np.fft.rfft(seg_w, n=n_fft2)) h1_db, h1_peak = amp_db_at(mag, f0_t) h2_db, h2_peak = amp_db_at(mag, 2.0 * f0_t) if not (h1_peak and h2_peak and np.isfinite(h1_db) and np.isfinite(h2_db)): continue h1h2_raw = h1_db - h2_db h1h2_raw_vals.append(h1h2_raw) # Apply Iseli-Alwan correction using F1 and B1 if formant is not None: f1_t = formant.get_value_at_time(1, float(t)) b1_t = formant.get_bandwidth_at_time(1, float(t)) corr_h1 = formant_correction_db(f0_t, f1_t, b1_t) corr_h2 = formant_correction_db(2.0 * f0_t, f1_t, b1_t) h1_star = h1_db - corr_h1 h2_star = h2_db - corr_h2 h1h2_corrected_vals.append(h1_star - h2_star) else: h1h2_corrected_vals.append(h1h2_raw) h12_raw = np.array(h1h2_raw_vals, dtype=np.float32) h12_corr = np.array(h1h2_corrected_vals, dtype=np.float32) if h12_raw.size: h12_raw = robust_clip(h12_raw) if h12_corr.size: h12_corr = robust_clip(h12_corr) if h12_corr.size < 10: warnings.append("Few stable frames for H1*-H2*. Weight estimate may be noisy.") h1_h2_med = float(np.median(h12_raw)) if h12_raw.size else float("nan") h1_h2_corrected_med = float(np.median(h12_corr)) if h12_corr.size else float("nan") # ── 6. Real CPP from power cepstrum ── cpp_db_val = float("nan") try: cpp_db_val = _compute_cpp(y, sr, f0_med, frame, hop, pitch_floor_hz, pitch_ceil_hz) except Exception: warnings.append("CPP computation failed; using NaN.") # ── HNR (keep as supplementary metric) ── hnr_db = float("nan") try: harm = snd.to_harmonicity_cc(time_step=HOP_LEN, minimum_pitch=pitch_floor_hz) harm_vals = np.array(harm.values).flatten() harm_vals = harm_vals[np.isfinite(harm_vals) & (harm_vals > -200)] hnr_db = float(np.median(harm_vals)) if harm_vals.size else float("nan") except Exception: warnings.append("Harmonicity calculation failed.") # ── 7. Jitter and shimmer from Praat ── jitter_local = float("nan") shimmer_local = float("nan") try: pp = parselmouth.praat.call(snd, "To PointProcess (periodic, cc)...", pitch_floor_hz, pitch_ceil_hz) jitter_local = float(parselmouth.praat.call(pp, "Get jitter (local)", 0.0, 0.0, 0.0001, 0.02, 1.3) * 100.0) shimmer_local = float(parselmouth.praat.call( [snd, pp], "Get shimmer (local)", 0.0, 0.0, 0.0001, 0.02, 1.3, 1.6) * 100.0) except Exception: pass # Jitter/shimmer are supplementary β€” silently skip # ── Confidence scoring (improved) ── conf_parts = [ # Voicing quality (weight: 0.20) float(np.clip((voicing_ratio - 0.2) / 0.6, 0.0, 1.0)), # SNR (weight: 0.15) float(np.clip((snr - 5.0) / 20.0, 0.0, 1.0)) if np.isfinite(snr) else 0.5, # No clipping (weight: 0.10) float(1.0 - np.clip(clip_ratio / 0.01, 0.0, 1.0)), # Formant frame count (weight: 0.15) float(np.clip(f2m.size / 50.0, 0.0, 1.0)), # H1-H2 frame count (weight: 0.15) float(np.clip(h12_corr.size / 50.0, 0.0, 1.0)), # Audio duration (weight: 0.15) β€” at least 3s, ideal 8+s float(np.clip((audio_duration_s - 1.0) / 7.0, 0.0, 1.0)), # Low jitter β†’ good quality (weight: 0.10) float(np.clip(1.0 - jitter_local / 5.0, 0.0, 1.0)) if np.isfinite(jitter_local) else 0.5, ] conf_weights = [0.20, 0.15, 0.10, 0.15, 0.15, 0.15, 0.10] confidence = float(sum(c * w for c, w in zip(conf_parts, conf_weights))) m = Metrics( f0_med_hz=f0_med, f0_iqr_hz=f0_iqr, f0_sd_st=f0_sd_st, voicing_ratio=voicing_ratio, f0_velocity_st_s=f0_velocity_st_s, f0_direction_rate=f0_direction_rate, prosody_composite=prosody_composite, rms_dbfs=rms_db, snr_db=snr, clipping_ratio=clip_ratio, f1_hz=f1_med, f2_hz=f2_med, f3_hz=f3_med, f2_minus_f1_hz=f2_minus_f1_med, formant_dispersion_hz=formant_disp_med, vtl_cm=vtl_cm, f2_f1_ratio=f2_f1_ratio, resonance_combo=resonance_combo, spectral_mean_hz=spectral_mean_hz, h1_h2_db=h1_h2_med, h1_h2_corrected_db=h1_h2_corrected_med, cpp_db=cpp_db_val, jitter_local=jitter_local, shimmer_local=shimmer_local, hnr_db=hnr_db, confidence=confidence, warnings=warnings, ) dists = { "f0_voiced": np.asarray(f0_voiced, dtype=np.float32), "st": np.asarray(st, dtype=np.float32), "rms_frames_dbfs": np.asarray(rms_frames_dbfs, dtype=np.float32), "f2_minus_f1_vals": np.asarray(f2m, dtype=np.float32), "h1_h2_corrected": np.asarray(h12_corr, dtype=np.float32), "resonance_combo": np.asarray([resonance_combo], dtype=np.float32) if np.isfinite(resonance_combo) else np.array([], dtype=np.float32), "formant_dispersion": np.asarray(df_arr, dtype=np.float32), } return m, dists def extract_metrics(y, sr, pitch_floor, pitch_ceil, max_formant): m, _ = extract_distributions(y, sr, pitch_floor, pitch_ceil, max_formant) return m # ============================================================================= # Mapping: metrics -> 0-100 sliders # ============================================================================= def dist_to_percentile(value, dist): if not np.isfinite(value): return float("nan") mu, sigma = dist.get("mu", float("nan")), dist.get("sigma", float("nan")) if not (np.isfinite(mu) and np.isfinite(sigma) and sigma > 0): return float("nan") lo, hi = dist.get("lo", float("-inf")), dist.get("hi", float("inf")) v = float(np.clip(value, lo, hi)) z = (v - mu) / max(sigma, 1e-6) return float(np.clip(normal_cdf(z), 0.0, 1.0)) def select_dists(mapping_mode, baseline_state): if mapping_mode == "Baseline (personal)" and baseline_state: dists = baseline_state.get("dists", GLOBAL_DISTS) # Handle baselines built with legacy key names resolved = {} for new_key in GLOBAL_DISTS: if new_key in dists: resolved[new_key] = dists[new_key] else: # Check legacy mappings for old_key, mapped_new in _LEGACY_DIST_MAP.items(): if mapped_new == new_key and old_key in dists: resolved[new_key] = dists[old_key] break else: resolved[new_key] = GLOBAL_DISTS[new_key] return resolved if mapping_mode == "Anchored (literature)": return ANCHORED_DISTS return GLOBAL_DISTS def metrics_to_sliders(m, dists, resonance_method=None): pitch_p = dist_to_percentile(m.f0_med_hz, dists["f0_med_hz"]) prosody_p = dist_to_percentile(m.prosody_composite, dists["f0_sd_st"]) volume_p = dist_to_percentile(m.rms_dbfs, dists["rms_dbfs"]) weight_p = dist_to_percentile(m.h1_h2_corrected_db, dists["h1_h2_corrected"]) # Resonance: use selected method's field and distribution method_name = resonance_method or RESONANCE_METHOD_DEFAULT method_cfg = RESONANCE_METHODS.get(method_name, RESONANCE_METHODS[RESONANCE_METHOD_DEFAULT]) res_field = method_cfg["field"] res_val = getattr(m, res_field, float("nan")) # Use baseline dist for resonance_combo key if available, else method-specific dist res_dist = dists.get("resonance_combo", method_cfg["dist"]) # If mapping mode is Global/Anchored, always use the method-specific dist if res_dist is dists.get("resonance_combo") and method_name != RESONANCE_METHOD_DEFAULT: res_dist = method_cfg["dist"] resonance_p = dist_to_percentile(res_val, res_dist) if method_cfg.get("invert") and np.isfinite(resonance_p): resonance_p = 1.0 - resonance_p # Volume: higher RMS -> louder -> 100 should be Soft -> invert. if np.isfinite(volume_p): volume_p = 1.0 - volume_p return { "Pitch": pct_to_slider(pitch_p), "Weight": pct_to_slider(weight_p), "Resonance": pct_to_slider(resonance_p), "Prosody": pct_to_slider(prosody_p), "Volume": pct_to_slider(volume_p), } # ============================================================================= # Formatting # ============================================================================= def _fmt(val, fmt_spec, unit=""): if np.isfinite(val): return format(val, fmt_spec) + unit return "\u2014" def format_report(m): lines = [ "Confidence: {:.2f}".format(m.confidence), "", "── Pitch ──", " Median F0: {}".format(_fmt(m.f0_med_hz, '.1f', ' Hz')), " Pitch spread (IQR): {}".format(_fmt(m.f0_iqr_hz, '.1f', ' Hz')), " Voicing ratio: {}".format(_fmt(m.voicing_ratio, '.2f')), "", "── Prosody ──", " F0 SD (semitones): {}".format(_fmt(m.f0_sd_st, '.2f', ' st')), " Pitch velocity: {}".format(_fmt(m.f0_velocity_st_s, '.1f', ' st/s')), " Direction changes: {}".format(_fmt(m.f0_direction_rate, '.1f', ' /s')), " Composite score: {}".format(_fmt(m.prosody_composite, '.2f')), "", "── Resonance ──", " F1 (median): {}".format(_fmt(m.f1_hz, '.0f', ' Hz')), " F2 (median): {}".format(_fmt(m.f2_hz, '.0f', ' Hz')), " F3 (median): {}".format(_fmt(m.f3_hz, '.0f', ' Hz')), " F2 βˆ’ F1: {}".format(_fmt(m.f2_minus_f1_hz, '.0f', ' Hz')), " F2/F1 ratio: {}".format(_fmt(m.f2_f1_ratio, '.2f')), " Formant dispersion: {}".format(_fmt(m.formant_dispersion_hz, '.0f', ' Hz')), " Est. VTL: {}".format(_fmt(m.vtl_cm, '.1f', ' cm')), " Spectral mean: {}".format(_fmt(m.spectral_mean_hz, '.0f', ' Hz')), " Resonance composite:{}".format(_fmt(m.resonance_combo, '.2f', ' z')), "", "── Voice Quality / Weight ──", " H1-H2 (raw): {}".format(_fmt(m.h1_h2_db, '.2f', ' dB')), " H1*-H2* (corrected):{}".format(_fmt(m.h1_h2_corrected_db, '.2f', ' dB')), " CPP: {}".format(_fmt(m.cpp_db, '.2f', ' dB')), " HNR: {}".format(_fmt(m.hnr_db, '.1f', ' dB')), " Jitter (local): {}".format(_fmt(m.jitter_local, '.3f', ' %')), " Shimmer (local): {}".format(_fmt(m.shimmer_local, '.3f', ' %')), "", "── Signal ──", " Volume (RMS): {}".format(_fmt(m.rms_dbfs, '.1f', ' dBFS')), " SNR (rough): {}".format(_fmt(m.snr_db, '.1f', ' dB')), " Clipping: {}".format(_fmt(m.clipping_ratio, '.4f')), ] if m.warnings: lines += ["", "Warnings:"] + [" β–Έ {}".format(w) for w in m.warnings] return "\n".join(lines) def format_sliders(s): return "\n".join( "{}: {:.0f}".format(k, s[k]) if np.isfinite(s.get(k, float("nan"))) else "{}: \u2014".format(k) for k in ["Pitch", "Weight", "Resonance", "Prosody", "Volume"] ) def compute_delta(user_s, target_s): d = {} for k in ["Pitch", "Weight", "Resonance", "Prosody", "Volume"]: u, t = user_s.get(k, float("nan")), target_s.get(k, float("nan")) d[k] = (u - t) if np.isfinite(u) and np.isfinite(t) else float("nan") return d def format_delta(d): return "\n".join( "{}: {:+.0f}".format(k, d[k]) if np.isfinite(d.get(k, float("nan"))) else "{}: \u2014".format(k) for k in ["Pitch", "Weight", "Resonance", "Prosody", "Volume"] ) def append_gain_warning(target_state, user_sliders, warnings): if not target_state: return t = (target_state or {}).get("sliders", {}) tv = t.get("Volume", float("nan")) uv = user_sliders.get("Volume", float("nan")) if np.isfinite(tv) and np.isfinite(uv) and abs(uv - tv) > 30: warnings.append( "Large volume delta likely reflects mic gain/distance " "mismatch (not loudness).") # ============================================================================= # Export / history # ============================================================================= def export_profile_json(name, m, sliders): ensure_exports_dir() payload = {"name": name, "metrics": asdict(m), "sliders_0_100": sliders} with open(PROFILE_JSON_PATH, "w") as f: _json_dump(payload, f, indent=2) return PROFILE_JSON_PATH def export_history_csv(history): ensure_exports_dir() fnames = sorted({k for row in history for k in row}) # Sanitise NaN/Inf to empty string for clean CSV output clean_rows = [] for row in history: clean = {} for k, v in row.items(): if isinstance(v, float) and (math.isnan(v) or math.isinf(v)): clean[k] = "" else: clean[k] = v clean_rows.append(clean) with open(HISTORY_CSV_PATH, "w", newline="") as f: w = csv.DictWriter(f, fieldnames=fnames) w.writeheader() w.writerows(clean_rows) return HISTORY_CSV_PATH def history_row(label, m, sliders): return { "timestamp": _now_iso(), "label": label, "confidence": m.confidence, "f0_med_hz": m.f0_med_hz, "f0_iqr_hz": m.f0_iqr_hz, "f0_sd_st": m.f0_sd_st, "voicing_ratio": m.voicing_ratio, "prosody_composite": m.prosody_composite, "f0_velocity_st_s": m.f0_velocity_st_s, "rms_dbfs": m.rms_dbfs, "snr_db": m.snr_db, "clipping_ratio": m.clipping_ratio, "f1_hz": m.f1_hz, "f2_hz": m.f2_hz, "f3_hz": m.f3_hz, "f2_minus_f1_hz": m.f2_minus_f1_hz, "f2_f1_ratio": m.f2_f1_ratio, "formant_dispersion_hz": m.formant_dispersion_hz, "vtl_cm": m.vtl_cm, "spectral_mean_hz": m.spectral_mean_hz, "resonance_combo": m.resonance_combo, "h1_h2_db": m.h1_h2_db, "h1_h2_corrected_db": m.h1_h2_corrected_db, "cpp_db": m.cpp_db, "hnr_db": m.hnr_db, "jitter_local": m.jitter_local, "shimmer_local": m.shimmer_local, "Pitch_slider": sliders["Pitch"], "Weight_slider": sliders["Weight"], "Resonance_slider": sliders["Resonance"], "Prosody_slider": sliders["Prosody"], "Volume_slider": sliders["Volume"], } def export_baseline_json(baseline_state): if not baseline_state: return None ensure_exports_dir() with open(BASELINE_JSON_PATH, "w") as f: _json_dump(baseline_state, f, indent=2) return BASELINE_JSON_PATH # ============================================================================= # Cache # ============================================================================= ANALYSIS_CACHE = {} _CACHE_LOCK = threading.Lock() def cached_analyze(y, sr, pf, pc, mf, cache_key, dists, resonance_method=None): rm = resonance_method or RESONANCE_METHOD_DEFAULT k = audio_hash(y, sr, extra="{}-{}-{}-{}-rm:{}".format(pf, pc, mf, cache_key, rm)) with _CACHE_LOCK: if k in ANALYSIS_CACHE: return ANALYSIS_CACHE[k] m = extract_metrics(y, sr, pf, pc, mf) sliders = metrics_to_sliders(m, dists, resonance_method=rm) with _CACHE_LOCK: ANALYSIS_CACHE[k] = (m, sliders) if len(ANALYSIS_CACHE) > 128: ANALYSIS_CACHE.pop(next(iter(ANALYSIS_CACHE))) return m, sliders # ============================================================================= # Baseline # ============================================================================= def build_baseline_from_audio(y, sr, pf, pc, mf): m, d = extract_distributions(y, sr, pf, pc, mf) if not d: return None, "Baseline failed: could not compute distributions." f0_mu, f0_sigma = robust_center_scale(d["f0_voiced"]) if not np.isfinite(f0_mu) or d["f0_voiced"].size < 30: return None, "Baseline failed: not enough voiced frames. Speak continuously for 10-15 s." st_mu, st_sigma = robust_center_scale(d["st"]) rms_mu, rms_sigma = robust_center_scale(d["rms_frames_dbfs"]) # Resonance composite z-score distribution res_combo_arr = d.get("resonance_combo", np.array([], dtype=np.float32)) if res_combo_arr.size > 0: rc_mu, rc_sigma = float(np.median(res_combo_arr)), max(float(np.std(res_combo_arr)), 0.3) else: # Fallback: derive from F2-F1 f2m = d.get("f2_minus_f1_vals", np.array([], dtype=np.float32)) if f2m.size > 0: rc_mu = float((np.median(f2m) - 900.0) / 200.0) rc_sigma = 1.0 else: rc_mu, rc_sigma = 0.0, 1.0 h12_mu, h12_sigma = robust_center_scale(d.get("h1_h2_corrected", np.array([], dtype=np.float32))) def mk(mu, sigma, min_sigma, pad=3.0): if not (np.isfinite(mu) and np.isfinite(sigma) and sigma > 0): return {"mu": float("nan"), "sigma": float("nan"), "lo": -1e9, "hi": 1e9} s = max(sigma, min_sigma) return {"mu": float(mu), "sigma": float(s), "lo": float(mu - pad * s), "hi": float(mu + pad * s)} baseline_state = { "note": "Baseline distributions derived from your sample.", "metrics_snapshot": asdict(m), "dists": { "f0_med_hz": mk(f0_mu, f0_sigma, 5.0), "f0_sd_st": mk(max(float(np.std(d["st"])), 0.01), st_sigma, 0.25), "rms_dbfs": mk(rms_mu, rms_sigma, 2.0), "resonance_combo": mk(rc_mu, rc_sigma, 0.3), "h1_h2_corrected": mk(h12_mu, h12_sigma, 1.0), }, "counts": { "f0_voiced": int(d["f0_voiced"].size), "f2_minus_f1_vals": int(d["f2_minus_f1_vals"].size), "h1_h2_corrected": int(d.get("h1_h2_corrected", np.array([])).size), } } return baseline_state, "Baseline set." # ============================================================================= # Audio preprocessing pipeline # ============================================================================= def _prep_audio(audio_like, window_choice, pitch_floor, pitch_ceil, max_formant, use_preemph, use_loudnorm, max_secs=MAX_UPLOAD_SECONDS): normalized = normalize_audio_tuple(audio_like) if normalized is None: raise ValueError("No audio provided.") sr, y = normalized y = to_mono_float32(y) if y is None: raise ValueError("Audio is empty after conversion.") y, sr = resample_if_needed(y, sr, DEFAULT_SR) y = hard_cap_length(y, sr, max_secs) y = select_analysis_window(y, sr, window_choice) y = preprocess_audio(y, use_preemph, use_loudnorm) pf = int(np.clip(int(round(pitch_floor)), 40, 150)) pc = int(np.clip(int(round(pitch_ceil)), 250, 800)) if pf >= pc: pf = min(pf, pc - 1) mf = int(np.clip(int(round(max_formant)), 4500, 6500)) return y, sr, pf, pc, mf # ============================================================================= # Resonance method comparison # ============================================================================= def resonance_comparison_table(m, dists, active_method=None): """Generate an HTML comparison table showing all 7 resonance methods side-by-side. For each method, shows: raw value, unit, slider position (0-100), and whether it's the currently active method. """ active = active_method or RESONANCE_METHOD_DEFAULT rows = [] for name, cfg in RESONANCE_METHODS.items(): field = cfg["field"] raw_val = getattr(m, field, float("nan")) unit = cfg["unit"] is_active = (name == active) # Compute slider position using method-specific dist d = cfg["dist"] pct = dist_to_percentile(raw_val, d) if cfg.get("invert") and np.isfinite(pct): pct = 1.0 - pct slider_val = pct_to_slider(pct) if np.isfinite(pct) else float("nan") # Format values if np.isfinite(raw_val): if unit == "cm": raw_str = "{:.1f}".format(raw_val) elif unit == "z": raw_str = "{:+.2f}".format(raw_val) elif unit == "": raw_str = "{:.2f}".format(raw_val) else: raw_str = "{:.0f}".format(raw_val) else: raw_str = "β€”" slider_str = "{:.0f}".format(slider_val) if np.isfinite(slider_val) else "β€”" # Color-code slider value: gradient from dark (0) to bright (100) if np.isfinite(slider_val): hue = 25 + (slider_val / 100.0) * 10 # orange range 25-35 sat = 70 light = 30 + (slider_val / 100.0) * 35 # 30% to 65% bar_pct = max(3, slider_val) bar_color = "hsl({}, {}%, {}%)".format(hue, sat, light) else: bar_pct = 0 bar_color = "#555" active_marker = " ✦" if is_active else "" row_class = "res-row-active" if is_active else "res-row" rows.append( '<tr class="{cls}">' '<td style="font-weight:{fw}; white-space:nowrap">{name}{mark}</td>' '<td style="text-align:right; font-variant-numeric:tabular-nums">{raw} {u}</td>' '<td style="width:45%">' ' <div style="background:#333;border-radius:4px;height:18px;position:relative;overflow:hidden">' ' <div style="background:{bc};height:100%;width:{bp}%;border-radius:4px;' ' transition:width 0.4s ease"></div>' ' <span style="position:absolute;right:6px;top:0;line-height:18px;' ' font-size:11px;color:#eee">{sv}</span>' ' </div>' '</td>' '<td style="text-align:center;font-size:11px;color:#999">{src}</td>' '</tr>'.format( cls=row_class, fw="700" if is_active else "400", name=name, mark=active_marker, raw=raw_str, u=unit, bc=bar_color, bp=bar_pct, sv=slider_str, src=cfg["source"].split("β€”")[0].strip() if "β€”" in cfg["source"] else cfg["source"][:25], ) ) html = ( '<div style="margin:8px 0">' '<table style="width:100%;border-collapse:collapse;font-size:13px;color:#ddd">' '<thead><tr style="border-bottom:1px solid #555">' '<th style="text-align:left;padding:4px 6px;color:#e8956e">Method</th>' '<th style="text-align:right;padding:4px 6px;color:#e8956e">Value</th>' '<th style="text-align:left;padding:4px 6px;color:#e8956e">Slider (0–100)</th>' '<th style="text-align:center;padding:4px 6px;color:#e8956e">Source</th>' '</tr></thead><tbody>' ) for r in rows: html += r html += '</tbody></table></div>' return html # ============================================================================= # Coaching tips # ============================================================================= _COACHING = { "Pitch": { "up": [ "Try speaking from a higher placement \u2014 imagine your voice resonating behind your eyes.", "Gently raise your habitual pitch. Start by humming at a comfortable higher note and then speaking from there.", "Practice glides (sirens) from your current pitch upward to build comfort in higher registers.", ], "down": [ "Relax your larynx and speak from a lower placement.", "Practice speaking on a gentle sigh to find a lower comfortable pitch.", ], "close": [ "Your pitch is close to target \u2014 focus on making it feel natural and sustainable.", ], }, "Weight": { "up": [ "Aim for a lighter, breathier quality. Think 'heady' rather than 'chesty'.", "Reduce vocal fold compression \u2014 imagine speaking on a gentle breath flow.", "Practice the 'whisper to voice' continuum: start breathy, add just enough voice.", ], "down": [ "Add more vocal fold body/mass to your sound. Speak with more chest resonance.", "Try a slightly firmer vocal onset while keeping the voice relaxed.", ], "close": [ "Your vocal weight is well-matched. Work on consistency across different volumes.", ], }, "Resonance": { "up": [ "Brighten your resonance: smile slightly while speaking, spread your lips.", "Raise your larynx gently \u2014 think of a 'small yawn' or swallowing position.", "Practice 'forward placement': imagine sending your voice to the front of your mouth.", "Try the 'big dog / small dog' exercise: pant like a small dog to feel a raised larynx.", ], "down": [ "Lower your larynx for darker resonance. Think of a gentle yawn while speaking.", "Open your throat more and direct sound toward the back of your mouth.", ], "close": [ "Resonance is near target. Practice maintaining this placement in conversation.", ], }, "Prosody": { "up": [ "Add more pitch variation. Try exaggerating intonation patterns, then dial back to natural.", "Practice reading children's books aloud with expressive intonation.", "Focus on question intonation (rising) and statement intonation (falling) patterns.", ], "down": [ "Reduce pitch variation for a more level intonation pattern.", "Practice speaking in a more measured, even cadence.", ], "close": [ "Your prosody matches well. Ensure it feels natural in spontaneous speech.", ], }, "Volume": { "up": [ "Speak more softly. Reduce breath pressure and let the voice float.", "Practice speaking at conversational distance with gentle airflow.", ], "down": [ "Project your voice more. Use diaphragmatic support without pushing.", "Note: volume differences often reflect mic setup rather than actual loudness.", ], "close": [ "Volume is well-matched (though this is heavily influenced by mic distance).", ], }, } def generate_coaching(delta, user_metrics=None, threshold_close=8, threshold_far=25): """Generate per-dimension coaching tips from delta values.""" if not delta: return "" lines = [] for dim in ["Pitch", "Weight", "Resonance", "Prosody", "Volume"]: d = delta.get(dim, float("nan")) if not np.isfinite(d): continue tips = _COACHING.get(dim, {}) if abs(d) <= threshold_close: bucket = "close" icon = "\u2705" elif d < 0: bucket = "up" icon = "\u2b06\ufe0f" if abs(d) > threshold_far else "\u2197\ufe0f" else: bucket = "down" icon = "\u2b07\ufe0f" if abs(d) > threshold_far else "\u2198\ufe0f" pool = tips.get(bucket, []) if pool: # Vary tip selection across analyses using current time tip_idx = (int(time.time()) + hash(dim)) % len(pool) tip = pool[tip_idx] severity = "({:+.0f})".format(d) lines.append("{} **{}** {} \n{}".format(icon, dim, severity, tip)) if not lines: return "*Analyze your voice and set a target to receive coaching tips.*" # Add general advice based on metrics extras = [] if user_metrics and hasattr(user_metrics, 'voicing_ratio'): if np.isfinite(user_metrics.voicing_ratio) and user_metrics.voicing_ratio < 0.4: extras.append( "\U0001f4a1 **Tip:** Speak more continuously for more stable analysis. " "Pauses reduce voicing ratio.") if np.isfinite(user_metrics.confidence) and user_metrics.confidence < 0.5: extras.append( "\U0001f4a1 **Tip:** Confidence is low. Try recording in a quieter " "environment with more continuous speech.") # CPP-based breathiness feedback if np.isfinite(user_metrics.cpp_db) and user_metrics.cpp_db < 0.02: extras.append( "\U0001f4a1 **Voice quality:** Low cepstral peak prominence suggests " "breathiness or roughness. Focus on consistent vocal fold closure.") # Jitter feedback if np.isfinite(user_metrics.jitter_local) and user_metrics.jitter_local > 2.0: extras.append( "\U0001f4a1 **Stability:** Elevated jitter detected. Steady, supported " "breathing can improve pitch stability.") # VTL feedback if np.isfinite(user_metrics.vtl_cm): if user_metrics.vtl_cm > 17.0: extras.append( "\U0001f4a1 **VTL:** Estimated vocal tract length is {:.1f} cm " "(longer range). Larynx elevation and mouth shape exercises " "can help shorten apparent VTL.".format(user_metrics.vtl_cm)) elif user_metrics.vtl_cm < 14.0: extras.append( "\U0001f4a1 **VTL:** Estimated vocal tract length is {:.1f} cm " "(shorter range). This contributes to brighter resonance.".format( user_metrics.vtl_cm)) return "\n\n".join(lines + extras) # ============================================================================= # Charts: radar + trend # ============================================================================= _DIM_COLORS = { "Pitch": "#5b7de8", "Weight": "#9b6dba", "Resonance": "#e8956e", "Prosody": "#d4c24a", "Volume": "#e878c8", } _DIM_ORDER = ["Pitch", "Weight", "Resonance", "Prosody", "Volume"] def _fig_to_base64(fig): """Render a matplotlib figure to a base64 PNG data URI.""" buf = io.BytesIO() fig.savefig(buf, format="png", dpi=120, bbox_inches="tight", facecolor=fig.get_facecolor(), edgecolor="none") buf.seek(0) b64 = base64.b64encode(buf.read()).decode("ascii") buf.close() return "data:image/png;base64," + b64 def render_radar_chart(target_sliders=None, user_sliders=None): """Return HTML <img> of a radar chart comparing target vs user.""" if not _HAS_MPL: return "<p><em>matplotlib not available for charts.</em></p>" if not target_sliders and not user_sliders: return "" dims = _DIM_ORDER n = len(dims) angles = [i * 2 * math.pi / n for i in range(n)] angles.append(angles[0]) fig = None try: fig, ax = plt.subplots(figsize=(3.8, 3.8), subplot_kw={"polar": True}) fig.patch.set_facecolor("#1a1a2e") ax.set_facecolor("#1a1a2e") ax.set_thetagrids([a * 180 / math.pi for a in angles[:-1]], dims, fontsize=9, fontweight="bold", color="#aaa") ax.set_ylim(0, 100) ax.set_yticks([20, 40, 60, 80]) ax.set_yticklabels(["20", "40", "60", "80"], fontsize=7, color="#555") ax.spines["polar"].set_visible(False) ax.tick_params(colors="#555") ax.grid(color="#333", linewidth=0.5) if target_sliders: t_vals = [safe(target_sliders.get(d, 0)) for d in dims] + \ [safe(target_sliders.get(dims[0], 0))] ax.plot(angles, t_vals, "o-", color="#888", linewidth=2, markersize=5, label="Target", zorder=3) ax.fill(angles, t_vals, alpha=0.08, color="#888") if user_sliders: u_vals = [safe(user_sliders.get(d, 0)) for d in dims] + \ [safe(user_sliders.get(dims[0], 0))] ax.plot(angles, u_vals, "o-", color="#5b7de8", linewidth=2.2, markersize=5, label="You", zorder=4) ax.fill(angles, u_vals, alpha=0.12, color="#5b7de8") ax.legend(loc="upper right", bbox_to_anchor=(1.25, 1.15), fontsize=8, facecolor="#1a1a2e", edgecolor="#333", labelcolor="#aaa") plt.tight_layout() uri = _fig_to_base64(fig) return '<img src="{}" alt="Radar chart" style="max-width:100%;border-radius:8px;">'.format(uri) except Exception: return "" finally: if fig is not None: plt.close(fig) def render_trend_plot(history, max_points=40): """Return HTML <img> of a trend line chart from session history.""" if not _HAS_MPL: return "<p><em>matplotlib not available for charts.</em></p>" user_rows = [r for r in (history or []) if r.get("label", "").startswith("You")] if len(user_rows) < 2: return "<p style='color:#999;font-size:0.9em;text-align:center;'>Record at least two samples to see trends.</p>" rows = user_rows[-max_points:] x = list(range(len(rows))) x_labels = [r.get("timestamp", "")[11:16] for r in rows] # HH:MM fig = None try: fig, ax = plt.subplots(figsize=(6, 2.8)) fig.patch.set_facecolor("#1a1a2e") ax.set_facecolor("#1a1a2e") for dim in _DIM_ORDER: key = dim + "_slider" vals = [] for r in rows: v = r.get(key, float("nan")) vals.append(v if np.isfinite(v) else float("nan")) ax.plot(x, vals, "o-", color=_DIM_COLORS[dim], linewidth=1.8, markersize=4, label=dim, alpha=0.85) ax.set_ylim(0, 100) ax.set_ylabel("Slider value", fontsize=9, color="#888") ax.set_xlabel("Session recordings", fontsize=9, color="#888") ax.tick_params(colors="#666") if len(x_labels) <= 15: ax.set_xticks(x) ax.set_xticklabels(x_labels, fontsize=7, rotation=45, color="#666") else: step = max(1, len(x) // 10) ax.set_xticks(x[::step]) ax.set_xticklabels(x_labels[::step], fontsize=7, rotation=45, color="#666") ax.legend(fontsize=7, ncol=5, loc="upper center", bbox_to_anchor=(0.5, 1.18), facecolor="#1a1a2e", edgecolor="#333", labelcolor="#aaa") ax.grid(True, alpha=0.15, color="#555") for spine in ax.spines.values(): spine.set_color("#333") plt.tight_layout() uri = _fig_to_base64(fig) return '<img src="{}" alt="Trend plot" style="max-width:100%;border-radius:8px;">'.format(uri) except Exception: return "" finally: if fig is not None: plt.close(fig) # ============================================================================= # Custom HTML bar visualization (matches Concept Slider design) # ============================================================================= _BAR_COLORS = { "Pitch": {"grad": "linear-gradient(to top, #1e2a5c, #3b5abd, #5b7de8, #8da6f0)", "marker": "#5b7de8", "glow": "rgba(91,125,232,0.4)", "bg": "rgba(91,125,232,0.08)"}, "Weight": {"grad": "linear-gradient(to top, #2d1548, #5a2d82, #9b6dba, #c4a0d8)", "marker": "#9b6dba", "glow": "rgba(155,109,186,0.4)", "bg": "rgba(155,109,186,0.08)"}, "Resonance": {"grad": "linear-gradient(to top, #6b3a1a, #b86040, #e8956e, #f0c0a0)", "marker": "#e8956e", "glow": "rgba(232,149,110,0.4)", "bg": "rgba(232,149,110,0.08)"}, "Prosody": {"grad": "linear-gradient(to top, #4a4010, #8a7520, #d4c24a, #e8dc80)", "marker": "#d4c24a", "glow": "rgba(212,194,74,0.4)", "bg": "rgba(212,194,74,0.08)"}, "Volume": {"grad": "linear-gradient(to top, #6a1850, #b84090, #e878c8, #f0a8d8)", "marker": "#e878c8", "glow": "rgba(232,120,200,0.4)", "bg": "rgba(232,120,200,0.08)"}, } _BAR_LABELS = { "Pitch": ("Low", "High"), "Weight": ("Heavy", "Light"), "Resonance": ("Dark", "Bright"), "Prosody": ("Mono", "Bouncy"), "Volume": ("Loud", "Soft"), } def render_slider_bars(target_sliders=None, user_sliders=None, confidence=None, context="target"): """Render vertical colour-coded slider bars as a dark-mode-native HTML block. context: "target" | "user" β€” changes empty-state messaging. """ has_target = target_sliders and any( np.isfinite(target_sliders.get(d, float("nan"))) for d in _DIM_ORDER) has_user = user_sliders and any( np.isfinite(user_sliders.get(d, float("nan"))) for d in _DIM_ORDER) bars_html = [] for dim in _DIM_ORDER: colors = _BAR_COLORS[dim] lo_label, hi_label = _BAR_LABELS[dim] t_val = safe(target_sliders.get(dim, float("nan")), -1) if target_sliders else -1 u_val = safe(user_sliders.get(dim, float("nan")), -1) if user_sliders else -1 # Clamp display positions t_pct = max(2.0, min(98.0, t_val)) if t_val >= 0 else -1 u_pct = max(2.0, min(98.0, u_val)) if u_val >= 0 else -1 # Target marker β€” horizontal line with subtle triangles target_marker = "" if t_pct >= 0: target_marker = ( '<div style="position:absolute;left:-10px;right:-10px;bottom:{pct}%;' 'height:0;display:flex;align-items:center;z-index:3;' 'transition:bottom 0.5s cubic-bezier(0.22,1,0.36,1);" ' 'title="Target: {val:.0f}">' '<div style="width:0;height:0;border-top:5px solid transparent;' 'border-bottom:5px solid transparent;border-left:7px solid rgba(255,255,255,0.7);"></div>' '<div style="flex:1;height:1.5px;background:rgba(255,255,255,0.5);' 'box-shadow:0 0 6px rgba(255,255,255,0.2);"></div>' '<div style="width:0;height:0;border-top:5px solid transparent;' 'border-bottom:5px solid transparent;border-right:7px solid rgba(255,255,255,0.7);"></div>' '</div>' ).format(pct=t_pct, val=t_val) # User marker β€” glowing circle user_marker = "" if u_pct >= 0: user_marker = ( '<div style="position:absolute;left:50%;bottom:{pct}%;' 'transform:translate(-50%,50%);z-index:4;' 'transition:bottom 0.5s cubic-bezier(0.22,1,0.36,1);" ' 'title="You: {val:.0f}">' '<div style="width:20px;height:20px;border-radius:50%;' 'background:{color};border:2.5px solid rgba(255,255,255,0.9);' 'box-shadow:0 0 12px {glow},0 0 24px {glow2},0 2px 6px rgba(0,0,0,0.4);' 'transition:transform 0.2s ease;"></div>' '</div>' ).format(pct=u_pct, val=u_val, color=colors["marker"], glow=colors["glow"], glow2=colors["glow"].replace("0.4", "0.15")) # Delta badge delta_badge = "" if t_val >= 0 and u_val >= 0: d = u_val - t_val if abs(d) <= 8: d_color = "#4ade80" d_bg = "rgba(74,222,128,0.1)" elif abs(d) <= 20: d_color = "#fbbf24" d_bg = "rgba(251,191,36,0.1)" else: d_color = "#f87171" d_bg = "rgba(248,113,113,0.1)" delta_badge = ( '<div style="text-align:center;font-family:\'JetBrains Mono\',monospace;' 'font-size:0.72rem;font-weight:600;color:{color};' 'margin-top:4px;padding:2px 6px;border-radius:6px;' 'background:{bg};">{delta:+.0f}</div>' ).format(color=d_color, bg=d_bg, delta=d) # Value display val_display = "" if u_val >= 0: val_display = ( '<div style="text-align:center;font-family:\'JetBrains Mono\',monospace;' 'font-size:0.85rem;font-weight:600;color:{c};margin-top:6px;">{v:.0f}</div>' ).format(c=colors["marker"], v=u_val) elif t_val >= 0: val_display = ( '<div style="text-align:center;font-family:\'JetBrains Mono\',monospace;' 'font-size:0.85rem;font-weight:600;color:rgba(255,255,255,0.5);margin-top:6px;">{v:.0f}</div>' ).format(v=t_val) bar = ( '<div style="display:flex;flex-direction:column;align-items:center;' 'flex:1;min-width:56px;max-width:110px;padding:0 4px;">' # Top label ' <div style="font-size:0.68rem;color:rgba(255,255,255,0.4);' ' letter-spacing:0.03em;text-transform:uppercase;margin-bottom:6px;' ' font-weight:500;">{hi}</div>' # Bar track ' <div style="position:relative;width:38px;height:240px;' ' border-radius:19px;overflow:visible;' ' background:{grad};' ' box-shadow:inset 0 2px 8px rgba(0,0,0,0.4),0 0 20px {bg},' ' 0 4px 16px rgba(0,0,0,0.3);' ' border:1px solid rgba(255,255,255,0.08);">' # Inner glow overlay ' <div style="position:absolute;inset:0;border-radius:19px;' ' background:linear-gradient(180deg,rgba(255,255,255,0.08) 0%,transparent 40%);' ' pointer-events:none;"></div>' ' {target}{user}' ' </div>' # Bottom label ' <div style="font-size:0.68rem;color:rgba(255,255,255,0.4);' ' letter-spacing:0.03em;text-transform:uppercase;margin-top:6px;' ' font-weight:500;">{lo}</div>' # Dimension name ' <div style="font-weight:700;font-size:0.82rem;color:{mc};' ' margin-top:8px;letter-spacing:-0.01em;">{dim}</div>' ' {val}{delta}' '</div>' ).format( hi=hi_label, lo=lo_label, grad=colors["grad"], bg=colors["bg"], target=target_marker, user=user_marker, dim=dim, mc=colors["marker"], val=val_display, delta=delta_badge, ) bars_html.append(bar) # Confidence indicator (if provided and analyzing user) conf_html = "" if confidence is not None and np.isfinite(confidence): conf_pct = max(0, min(100, confidence * 100)) if conf_pct >= 70: conf_color, conf_label = "#4ade80", "High" elif conf_pct >= 45: conf_color, conf_label = "#fbbf24", "Medium" else: conf_color, conf_label = "#f87171", "Low" conf_html = ( '<div style="display:flex;align-items:center;gap:8px;' 'justify-content:center;margin-top:12px;' 'padding:6px 14px;border-radius:10px;' 'background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);">' '<span style="font-size:0.72rem;color:rgba(255,255,255,0.4);' 'text-transform:uppercase;letter-spacing:0.05em;font-weight:500;">Confidence</span>' '<div style="flex:0 0 60px;height:4px;border-radius:2px;' 'background:rgba(255,255,255,0.08);overflow:hidden;">' '<div style="height:100%;width:{pct}%;background:{color};' 'border-radius:2px;transition:width 0.5s ease;"></div></div>' '<span style="font-family:\'JetBrains Mono\',monospace;' 'font-size:0.72rem;font-weight:600;color:{color};">' '{pct:.0f}% {label}</span>' '</div>' ).format(pct=conf_pct, color=conf_color, label=conf_label) # Legend legend_items = [] if has_target or (context == "target"): legend_items.append( '<span style="display:inline-flex;align-items:center;gap:4px;">' '<span style="display:inline-block;width:18px;height:1.5px;' 'background:rgba(255,255,255,0.5);position:relative;">' '<span style="position:absolute;left:-4px;top:-3px;width:0;height:0;' 'border-top:3.5px solid transparent;border-bottom:3.5px solid transparent;' 'border-left:5px solid rgba(255,255,255,0.5);"></span>' '<span style="position:absolute;right:-4px;top:-3px;width:0;height:0;' 'border-top:3.5px solid transparent;border-bottom:3.5px solid transparent;' 'border-right:5px solid rgba(255,255,255,0.5);"></span>' '</span>Target</span>') if has_user: legend_items.append( '<span style="display:inline-flex;align-items:center;gap:4px;">' '<span style="display:inline-block;width:10px;height:10px;' 'border-radius:50%;background:#5b7de8;border:1.5px solid rgba(255,255,255,0.8);' 'box-shadow:0 0 6px rgba(91,125,232,0.4);"></span>You</span>') legend = "" if legend_items: legend = ( '<div style="display:flex;gap:20px;justify-content:center;' 'font-size:0.75rem;color:rgba(255,255,255,0.45);margin-top:14px;' 'font-weight:500;">{}</div>' ).format("".join(legend_items)) # Empty state empty_hint = "" if not has_target and not has_user: if context == "target": empty_hint = ( '<div style="text-align:center;padding:10px 0 2px;' 'color:rgba(255,255,255,0.3);font-size:0.82rem;">' 'Upload a voice sample above to see where it lands on each dimension' '</div>') else: empty_hint = ( '<div style="text-align:center;padding:10px 0 2px;' 'color:rgba(255,255,255,0.3);font-size:0.82rem;">' 'Record or upload your voice to see your slider positions' '</div>') return ( '<div style="padding:20px 12px 10px;' 'background:rgba(255,255,255,0.02);border-radius:16px;' 'border:1px solid rgba(255,255,255,0.06);' 'backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);">' '<div style="display:flex;justify-content:center;gap:12px;' 'flex-wrap:wrap;">' + "".join(bars_html) + '</div>' + legend + conf_html + empty_hint + '</div>' ) # ============================================================================= # Callbacks # ============================================================================= def update_prompt(choice): return gr.update(value=PROMPTS.get(choice, "")) def reset_live(): return None def clear_baseline(): return None, "No baseline set.", None def set_baseline_from_user(audio_like, window_choice, pitch_floor, pitch_ceil, max_formant, use_preemph, use_loudnorm): try: y, sr, pf, pc, mf = _prep_audio(audio_like, window_choice, pitch_floor, pitch_ceil, max_formant, use_preemph, use_loudnorm) except ValueError as e: return None, str(e), None baseline_state, msg = build_baseline_from_audio(y, sr, pf, pc, mf) if baseline_state: path = export_baseline_json(baseline_state) return baseline_state, msg, path return None, msg, None def clear_target(history_state): z = gr.update(value=0) return None, history_state, "", "", z, z, z, z, z, render_slider_bars(None, None, context="target") def set_target(audio_like, window_choice, pitch_floor, pitch_ceil, max_formant, use_preemph, use_loudnorm, mapping_mode, resonance_method, baseline_state, history_state): z = gr.update(value=0) empty_bars = render_slider_bars(None, None, context="target") try: y, sr, pf, pc, mf = _prep_audio(audio_like, window_choice, pitch_floor, pitch_ceil, max_formant, use_preemph, use_loudnorm) except ValueError as e: return None, history_state, str(e), "", z, z, z, z, z, empty_bars, "" dists = select_dists(mapping_mode, baseline_state) rm = resonance_method or RESONANCE_METHOD_DEFAULT cache_key = "target-{}-{}-{}-{}".format(window_choice, use_preemph, use_loudnorm, mapping_mode) m, sliders = cached_analyze(y, sr, pf, pc, mf, cache_key=cache_key, dists=dists, resonance_method=rm) target_state = {"metrics": asdict(m), "sliders": sliders} history_state = cap_history((history_state or []) + [history_row("Target", m, sliders)]) bar_html = render_slider_bars(sliders, None, context="target") res_compare = resonance_comparison_table(m, dists, active_method=rm) return ( target_state, history_state, format_report(m), format_sliders(sliders), gr.update(value=safe(sliders["Pitch"])), gr.update(value=safe(sliders["Weight"])), gr.update(value=safe(sliders["Resonance"])), gr.update(value=safe(sliders["Prosody"])), gr.update(value=safe(sliders["Volume"])), bar_html, res_compare, ) def analyze_user(audio_like, label, window_choice, pitch_floor, pitch_ceil, max_formant, use_preemph, use_loudnorm, mapping_mode, resonance_method, baseline_state, target_state, history_state): z = gr.update(value=0) z0 = gr.update(value=0.0) empty_out = ( history_state, z, z, z, z, z, z0, z0, z0, z0, z0, z0, "", "", "", None, None, None, [], render_slider_bars((target_state or {}).get("sliders"), None, context="user"), "", "", "", "", ) try: y, sr, pf, pc, mf = _prep_audio(audio_like, window_choice, pitch_floor, pitch_ceil, max_formant, use_preemph, use_loudnorm) except ValueError: return empty_out rm = resonance_method or RESONANCE_METHOD_DEFAULT dists = select_dists(mapping_mode, baseline_state) cache_key = "user-{}-{}-{}-{}-{}".format(label, window_choice, use_preemph, use_loudnorm, mapping_mode) m, sliders = cached_analyze(y, sr, pf, pc, mf, cache_key=cache_key, dists=dists, resonance_method=rm) warnings = list(m.warnings) append_gain_warning(target_state, sliders, warnings) m = Metrics(**{**asdict(m), "warnings": warnings}) delta = compute_delta(sliders, (target_state or {}).get("sliders", {})) history_state = cap_history((history_state or []) + [history_row(label, m, sliders)]) json_path = export_profile_json(label, m, sliders) csv_path = export_history_csv(history_state) base_path = export_baseline_json(baseline_state) dval = lambda k: safe(delta.get(k, float("nan")), 0.0) # New visualisations t_sliders = (target_state or {}).get("sliders") bar_html = render_slider_bars(t_sliders, sliders, confidence=m.confidence, context="user") radar_html = render_radar_chart(t_sliders, sliders) trend_html = render_trend_plot(history_state) coaching = generate_coaching(delta, m) res_compare = resonance_comparison_table(m, dists, active_method=rm) return ( history_state, gr.update(value=safe(sliders["Pitch"])), gr.update(value=safe(sliders["Weight"])), gr.update(value=safe(sliders["Resonance"])), gr.update(value=safe(sliders["Prosody"])), gr.update(value=safe(sliders["Volume"])), gr.update(value=safe(m.confidence, 0.0)), gr.update(value=dval("Pitch")), gr.update(value=dval("Weight")), gr.update(value=dval("Resonance")), gr.update(value=dval("Prosody")), gr.update(value=dval("Volume")), format_report(m), format_sliders(sliders), format_delta(delta), json_path, csv_path, base_path, history_preview(history_state), bar_html, radar_html, trend_html, coaching, res_compare, ) def live_analyze(stream_like, buffer_state, pitch_floor, pitch_ceil, max_formant, use_preemph, use_loudnorm, mapping_mode, resonance_method, baseline_state, target_state): no_change = (buffer_state, *(gr.update() for _ in range(16))) normalized = normalize_audio_tuple(stream_like) if normalized is None: return no_change sr, y = normalized y = to_mono_float32(y) if y is None: return no_change y, sr = resample_if_needed(y, sr, DEFAULT_SR) y = preprocess_audio(y, use_preemph, use_loudnorm) if buffer_state is None or buffer_state.get("sr") != sr: buffer_state = {"sr": sr, "y": np.zeros(0, dtype=np.float32), "ema": {}} buf = np.concatenate([buffer_state["y"], y]) buf = hard_cap_length(buf, sr, MAX_LIVE_BUFFER_SECONDS) buffer_state["y"] = buf if buf.size < int(2.0 * sr): return no_change seg = buf[-int(LIVE_ANALYZE_SECONDS * sr):] seg_rms = dbfs_rms(seg) if seg_rms < -40.0: return ( buffer_state, *(gr.update() for _ in range(5)), gr.update(), *(gr.update() for _ in range(5)), gr.update(value="Live: signal too quiet ({:.1f} dBFS).".format(seg_rms)), gr.update(), gr.update(), gr.update(), gr.update(), ) pf = int(np.clip(int(round(pitch_floor)), 40, 150)) pc = int(np.clip(int(round(pitch_ceil)), 250, 800)) if pf >= pc: pf = min(pf, pc - 1) mf = int(np.clip(int(round(max_formant)), 4500, 6500)) rm = resonance_method or RESONANCE_METHOD_DEFAULT m = extract_metrics(seg, sr, pf, pc, mf) dists = select_dists(mapping_mode, baseline_state) sliders = metrics_to_sliders(m, dists, resonance_method=rm) warnings = list(m.warnings) append_gain_warning(target_state, sliders, warnings) m = Metrics(**{**asdict(m), "warnings": warnings}) ema = buffer_state.get("ema", {}) alpha = 0.25 smoothed = {} for k, v in sliders.items(): if not np.isfinite(v): smoothed[k] = v continue prev = ema.get(k, v) # Guard against NaN-poisoned EMA: if prev is NaN, reset to current value if not np.isfinite(prev): prev = v smoothed[k] = (1 - alpha) * prev + alpha * v ema[k] = smoothed[k] buffer_state["ema"] = ema t_sliders = (target_state or {}).get("sliders") delta = compute_delta(smoothed, t_sliders or {}) dval = lambda k: safe(delta.get(k, float("nan")), 0.0) bar_html = render_slider_bars(t_sliders, smoothed, confidence=m.confidence, context="user") coaching = generate_coaching(delta, m) return ( buffer_state, gr.update(value=safe(smoothed["Pitch"])), gr.update(value=safe(smoothed["Weight"])), gr.update(value=safe(smoothed["Resonance"])), gr.update(value=safe(smoothed["Prosody"])), gr.update(value=safe(smoothed["Volume"])), gr.update(value=safe(m.confidence, 0.0)), gr.update(value=dval("Pitch")), gr.update(value=dval("Weight")), gr.update(value=dval("Resonance")), gr.update(value=dval("Prosody")), gr.update(value=dval("Volume")), gr.update(value=format_report(m)), gr.update(value=format_sliders(smoothed)), gr.update(value=format_delta(delta)), bar_html, coaching, ) # ============================================================================= # UI # ============================================================================= _THEME = gr.themes.Base( primary_hue=gr.themes.Color( c50="#eef2ff", c100="#dde5ff", c200="#bccaff", c300="#8da6f0", c400="#5b7de8", c500="#4a6ad0", c600="#3a55b0", c700="#2d4290", c800="#1e2e6e", c900="#141f4e", c950="#0d1430"), neutral_hue="slate", font=("DM Sans", "system-ui", "sans-serif"), font_mono=("JetBrains Mono", "monospace"), ).set( body_background_fill="#0f0f1a", body_background_fill_dark="#0f0f1a", block_background_fill="rgba(255,255,255,0.03)", block_background_fill_dark="rgba(255,255,255,0.03)", block_border_color="rgba(255,255,255,0.06)", block_border_color_dark="rgba(255,255,255,0.06)", block_label_text_color="rgba(255,255,255,0.6)", block_label_text_color_dark="rgba(255,255,255,0.6)", block_title_text_color="rgba(255,255,255,0.85)", block_title_text_color_dark="rgba(255,255,255,0.85)", body_text_color="rgba(255,255,255,0.85)", body_text_color_dark="rgba(255,255,255,0.85)", body_text_color_subdued="rgba(255,255,255,0.45)", body_text_color_subdued_dark="rgba(255,255,255,0.45)", border_color_primary="rgba(255,255,255,0.08)", border_color_primary_dark="rgba(255,255,255,0.08)", input_background_fill="rgba(255,255,255,0.05)", input_background_fill_dark="rgba(255,255,255,0.05)", input_border_color="rgba(255,255,255,0.08)", input_border_color_dark="rgba(255,255,255,0.08)", button_primary_background_fill="#3a55b0", button_primary_background_fill_dark="#3a55b0", button_primary_background_fill_hover="#4a68c8", button_primary_background_fill_hover_dark="#4a68c8", button_primary_text_color="white", button_primary_text_color_dark="white", button_secondary_background_fill="rgba(255,255,255,0.06)", button_secondary_background_fill_dark="rgba(255,255,255,0.06)", button_secondary_text_color="rgba(255,255,255,0.7)", button_secondary_text_color_dark="rgba(255,255,255,0.7)", shadow_drop="none", shadow_drop_lg="none", ) # Gradio 6 moved css/theme to launch(); Gradio 5 wants them in Blocks(). _GRADIO_MAJOR = int(gr.__version__.split(".")[0]) _blocks_kw = dict(title="Voice Target Slider") _launch_kw = dict() if _GRADIO_MAJOR >= 6: _launch_kw.update(css=CUSTOM_CSS, theme=_THEME) else: _blocks_kw.update(css=CUSTOM_CSS, theme=_THEME) with gr.Blocks(**_blocks_kw) as demo: target_state = gr.State(None) history_state = gr.State([]) live_buffer_state = gr.State(None) baseline_state = gr.State(None) label_recorded = gr.State("You (recorded)") label_uploaded = gr.State("You (uploaded)") # ── Header ── gr.HTML(""" <div class="vts-header"> <h1>Voice Target Slider</h1> <p>Upload a reference voice to see where it lands, then record your own to compare.</p> <span class="vts-tag">Gender-affirming voice therapy</span> </div> """) with gr.Accordion("Analysis Settings", open=False): gr.Markdown("Fine-tune analysis parameters. Defaults work well for most voices.") with gr.Row(): analysis_window = gr.Dropdown( ["Full", "Last 10s", "Last 20s"], value="Full", label="Analysis window") mapping_mode = gr.Dropdown( ["Global", "Anchored (literature)", "Baseline (personal)"], value="Global", label="Mapping mode") resonance_method = gr.Dropdown( list(RESONANCE_METHODS.keys()), value=RESONANCE_METHOD_DEFAULT, label="Resonance method", info="Which acoustic measure drives the Resonance slider") with gr.Row(): pitch_floor = gr.Slider(40, 150, value=PITCH_FLOOR_DEFAULT, step=1, label="Pitch floor (Hz)") pitch_ceil = gr.Slider(250, 800, value=PITCH_CEIL_DEFAULT, step=1, label="Pitch ceiling (Hz)") max_formant = gr.Slider(4500, 6500, value=MAX_FORMANT_DEFAULT, step=50, label="Max formant (Hz)") with gr.Row(): use_preemph = gr.Checkbox(value=False, label="Pre-emphasis (0.97)") use_loudnorm = gr.Checkbox(value=False, label="Loudness-normalize to -20 dBFS") gr.Markdown("**Personal baseline** β€” changes how slider values are mapped, relative to your own voice.") with gr.Row(): baseline_mic = gr.Audio(sources=["microphone"], type="numpy", label="Record baseline") baseline_up = gr.Audio(sources=["upload"], type="numpy", label="Upload baseline") with gr.Row(): btn_set_baseline_mic = gr.Button("Set Baseline (recording)", size="sm") btn_set_baseline_up = gr.Button("Set Baseline (upload)", size="sm") btn_clear_baseline = gr.Button("Clear Baseline", size="sm", variant="secondary") baseline_status = gr.Textbox(label="Baseline status", value="No baseline set.", lines=1, interactive=False) baseline_file = gr.File(label="Baseline JSON", visible=False) with gr.Tabs(): # ════════════════════════════════════════════════════════ # TAB 1: TARGET VOICE # ════════════════════════════════════════════════════════ with gr.Tab("β‘  Target Voice"): gr.Markdown("Upload or record the voice you want to sound like.") with gr.Row(): target_audio = gr.Audio(sources=["upload", "microphone"], type="numpy", label="Target audio") with gr.Column(scale=0, min_width=140): btn_set_target = gr.Button("Analyze", variant="primary", size="lg") btn_clear_target = gr.Button("Clear", variant="secondary", size="sm") target_bars = gr.HTML(value=render_slider_bars(None, None, context="target"), label="Target visualization") with gr.Accordion("Detailed values", open=False): with gr.Row(): t_pitch = gr.Slider(0, 100, value=0, step=1, interactive=False, label="Pitch (Low β†’ High)", elem_classes=["slider-pitch"]) t_weight = gr.Slider(0, 100, value=0, step=1, interactive=False, label="Weight (Heavy β†’ Light)", elem_classes=["slider-weight"]) t_res = gr.Slider(0, 100, value=0, step=1, interactive=False, label="Resonance (Dark β†’ Bright)", elem_classes=["slider-resonance"]) t_pros = gr.Slider(0, 100, value=0, step=1, interactive=False, label="Prosody (Monotone β†’ Bouncy)", elem_classes=["slider-prosody"]) t_vol = gr.Slider(0, 100, value=0, step=1, interactive=False, label="Volume (Loud β†’ Soft)", elem_classes=["slider-volume"]) target_report = gr.Textbox(label="Analysis report", lines=10, elem_classes=["report-box"]) target_slider_text = gr.Textbox(label="Slider values", lines=4, elem_classes=["report-box"]) with gr.Accordion("Resonance Methods Comparison", open=False): gr.Markdown("All 7 resonance measurement methods computed for this audio. " "The **active method** (✦) drives the Resonance slider above. " "Change the method in Analysis Settings to compare.") target_res_compare = gr.HTML(value="") # ════════════════════════════════════════════════════════ # TAB 2: YOUR VOICE # ════════════════════════════════════════════════════════ with gr.Tab("β‘‘ Your Voice"): with gr.Accordion("Reading prompt", open=False): prompt_choice = gr.Dropdown(list(PROMPTS.keys()), value="Rainbow Passage", label="Choose a prompt") prompt_text = gr.Textbox(value=PROMPTS["Rainbow Passage"], label="Read this aloud while recording", lines=4, interactive=False, elem_classes=["prompt-box"]) with gr.Row(equal_height=True): with gr.Column(): user_audio_mic = gr.Audio(sources=["microphone"], type="numpy", label="Record your voice") btn_analyze_mic = gr.Button("Analyze Recording", variant="primary") with gr.Column(): user_audio_up = gr.Audio(sources=["upload"], type="numpy", label="Upload a recording") btn_analyze_upload = gr.Button("Analyze Upload", variant="primary") # Hero visualization user_bars = gr.HTML(value=render_slider_bars(None, None, context="user"), label="Voice comparison") # Coaching tips coaching_md = gr.Markdown( value="*Analyze your voice and set a target to receive coaching tips.*", elem_classes=["coaching-card"]) with gr.Accordion("Charts & trends", open=False): with gr.Row(): radar_html = gr.HTML(value="", label="Radar comparison") trend_html = gr.HTML(value="", label="Session trend") with gr.Accordion("Resonance Methods Comparison", open=True): gr.Markdown("All 7 resonance metrics computed simultaneously. " "The **active method** (✦) drives the slider. " "Compare to find which tracks your perceived resonance best.") user_res_compare = gr.HTML(value="") with gr.Accordion("Live mode", open=False): gr.Markdown("Live mode streams from your microphone with EMA smoothing.") live_audio = gr.Audio(sources=["microphone"], type="numpy", streaming=True, label="Live microphone") btn_reset_live = gr.Button("Reset live buffer", size="sm", variant="secondary") # All numerical details β€” tucked away with gr.Accordion("Detailed values & deltas", open=False): with gr.Row(): u_pitch = gr.Slider(0, 100, value=0, step=1, interactive=False, label="Pitch (Low β†’ High)", elem_classes=["slider-pitch"]) u_weight = gr.Slider(0, 100, value=0, step=1, interactive=False, label="Weight (Heavy β†’ Light)", elem_classes=["slider-weight"]) u_res = gr.Slider(0, 100, value=0, step=1, interactive=False, label="Resonance (Dark β†’ Bright)", elem_classes=["slider-resonance"]) u_pros = gr.Slider(0, 100, value=0, step=1, interactive=False, label="Prosody (Monotone β†’ Bouncy)", elem_classes=["slider-prosody"]) u_vol = gr.Slider(0, 100, value=0, step=1, interactive=False, label="Volume (Loud β†’ Soft)", elem_classes=["slider-volume"]) confidence_bar = gr.Slider(0.0, 1.0, value=0.0, step=0.01, interactive=False, label="Confidence", elem_classes=["slider-confidence"]) gr.Markdown("**Delta** (You βˆ’ Target) Β· positive = you are higher") with gr.Row(): d_pitch = gr.Number(value=0.0, label="Pitch Ξ”") d_weight = gr.Number(value=0.0, label="Weight Ξ”") d_res = gr.Number(value=0.0, label="Resonance Ξ”") d_pros = gr.Number(value=0.0, label="Prosody Ξ”") d_vol = gr.Number(value=0.0, label="Volume Ξ”") user_report = gr.Textbox(label="Analysis report", lines=10, elem_classes=["report-box"]) user_slider_text = gr.Textbox(label="Slider values", lines=4, elem_classes=["report-box"]) delta_text = gr.Textbox(label="Delta text", lines=4, elem_classes=["report-box"]) with gr.Accordion("Exports & session history", open=False): with gr.Row(): export_json = gr.File(label="Profile JSON") export_csv = gr.File(label="History CSV") export_baseline = gr.File(label="Baseline JSON") _df_kw = dict(headers=["Time", "Label", "Conf.", "Pitch", "Weight", "Resonance", "Prosody", "Volume"], value=[], interactive=False, row_count=10, label="Recent history") if _GRADIO_MAJOR >= 6: _df_kw["column_count"] = (8, "fixed") else: _df_kw["col_count"] = (8, "fixed") history_table = gr.Dataframe(**_df_kw) # ═══════════════════════════════════════════════════════════ # Wiring # ═══════════════════════════════════════════════════════════ prompt_choice.change(fn=update_prompt, inputs=[prompt_choice], outputs=[prompt_text]) baseline_inputs = [analysis_window, pitch_floor, pitch_ceil, max_formant, use_preemph, use_loudnorm] baseline_outputs = [baseline_state, baseline_status, baseline_file] btn_set_baseline_mic.click(set_baseline_from_user, [baseline_mic] + baseline_inputs, baseline_outputs) btn_set_baseline_up.click(set_baseline_from_user, [baseline_up] + baseline_inputs, baseline_outputs) btn_clear_baseline.click(clear_baseline, [], baseline_outputs) target_inputs = [target_audio, analysis_window, pitch_floor, pitch_ceil, max_formant, use_preemph, use_loudnorm, mapping_mode, resonance_method, baseline_state, history_state] target_outputs = [target_state, history_state, target_report, target_slider_text, t_pitch, t_weight, t_res, t_pros, t_vol, target_bars, target_res_compare] btn_set_target.click(set_target, target_inputs, target_outputs) btn_clear_target.click(clear_target, [history_state], [target_state, history_state, target_report, target_slider_text, t_pitch, t_weight, t_res, t_pros, t_vol, target_bars]) user_outputs = [history_state, u_pitch, u_weight, u_res, u_pros, u_vol, confidence_bar, d_pitch, d_weight, d_res, d_pros, d_vol, user_report, user_slider_text, delta_text, export_json, export_csv, export_baseline, history_table, user_bars, radar_html, trend_html, coaching_md, user_res_compare] user_common_inputs = [analysis_window, pitch_floor, pitch_ceil, max_formant, use_preemph, use_loudnorm, mapping_mode, resonance_method, baseline_state, target_state, history_state] btn_analyze_mic.click(analyze_user, [user_audio_mic, label_recorded] + user_common_inputs, user_outputs) btn_analyze_upload.click(analyze_user, [user_audio_up, label_uploaded] + user_common_inputs, user_outputs) live_outputs = [live_buffer_state, u_pitch, u_weight, u_res, u_pros, u_vol, confidence_bar, d_pitch, d_weight, d_res, d_pros, d_vol, user_report, user_slider_text, delta_text, user_bars, coaching_md] live_audio.stream(live_analyze, [live_audio, live_buffer_state, pitch_floor, pitch_ceil, max_formant, use_preemph, use_loudnorm, mapping_mode, resonance_method, baseline_state, target_state], live_outputs) btn_reset_live.click(reset_live, [], [live_buffer_state]) demo.queue(max_size=32).launch(**_launch_kw)
import gradio as gr
import numpy as np
import librosa
import parselmouth
import json
import csv
import os
import math
import hashlib
import time
import threading
import io
import base64
from dataclasses import dataclass, asdict
from typing import Optional, Dict, Any, Tuple, List
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
_HAS_MPL = True
except ImportError:
_HAS_MPL = False
# =============================================================================
# Constants & Content
# =============================================================================
RAINBOW_PASSAGE = (
"When the sunlight strikes raindrops in the air, they act like a prism "
"and form a rainbow. The rainbow is a division of white light into many "
"beautiful colors. These take the shape of a long round arch, with its "
"path high above, and its two ends apparently beyond the horizon. "
"There is, according to legend, a boiling pot of gold at one end. "
"People look, but no one ever finds it. When a person looks for something "
"beyond reach, their friends say they are looking for the pot of gold "
"at the end of the rainbow."
)
PROMPTS = {
"Rainbow Passage": RAINBOW_PASSAGE,
"Sustained vowels /i/ then /a/ (5s each)":
"Say 'eeee' for ~5 seconds, then 'ahhh' for ~5 seconds.",
"Counting 1-20":
"Count from 1 to 20 at a comfortable pace.",
"Spontaneous speech (10-20s)":
"Describe what you did yesterday in 2-3 sentences.",
}
# ---------------------------------------------------------------------------
# Distribution anchors for slider mapping
# ---------------------------------------------------------------------------
# Each entry defines a Normal(mu, sigma) clipped to [lo, hi].
# A raw metric is mapped to a CDF percentile (0-1) then scaled to 0-100.
#
# Slider polarity (matching Concept Slider design):
# 100 = High / Light / Bright / Bouncy / Soft (feminine-associated end)
# 0 = Low / Heavy / Dark / Monotone / Loud (masculine-associated end)
#
# Weight: Higher H1*-H2* -> lighter -> higher slider. No inversion needed.
# Volume: Higher RMS -> louder -> *inverted* so 100 = Soft.
# ---------------------------------------------------------------------------
# Sources:
# F0: Titze 1994, Hillenbrand 1995 β€” cis-M β‰ˆ 120 Hz (Οƒβ‰ˆ20), cis-F β‰ˆ 210 Hz (Οƒβ‰ˆ25)
# F0 SD: Henton 1989, PΓ©piot 2014 β€” reading ~2.5 st (Οƒβ‰ˆ1.0)
# H1*-H2*: Keating & Esposito 2006 β€” cis-M β‰ˆ 2.5 dB, cis-F β‰ˆ 6.5 dB
# F2-F1: Hillenbrand 1995 β€” cis-M β‰ˆ 780 Hz, cis-F β‰ˆ 1020 Hz
# Formant dispersion: Fitch 1997 β€” cis-M β‰ˆ 1050 Hz, cis-F β‰ˆ 1150 Hz
# ---------------------------------------------------------------------------
GLOBAL_DISTS = {
"f0_med_hz": {"mu": 165.0, "sigma": 50.0, "lo": 65.0, "hi": 350.0},
"f0_sd_st": {"mu": 2.5, "sigma": 1.0, "lo": 0.2, "hi": 7.0},
"rms_dbfs": {"mu": -22.0, "sigma": 6.0, "lo": -45.0, "hi": -8.0},
"resonance_combo": {"mu": 0.0, "sigma": 1.0, "lo": -3.0, "hi": 3.0},
"h1_h2_corrected": {"mu": 4.5, "sigma": 4.0, "lo": -8.0, "hi": 18.0},
}
ANCHORED_DISTS = {
**GLOBAL_DISTS,
"f0_med_hz": {"mu": 160.0, "sigma": 55.0, "lo": 70.0, "hi": 340.0},
"h1_h2_corrected": {"mu": 3.5, "sigma": 4.5, "lo": -8.0, "hi": 16.0},
}
# Legacy keys for baseline compatibility β€” if a baseline was built with old keys,
# select_dists will still find them.
_LEGACY_DIST_MAP = {
"f2_minus_f1_hz": "resonance_combo",
"h1_h2_db": "h1_h2_corrected",
}
# ── Resonance method definitions ──
# Each method maps a Metrics field to a distribution and polarity.
# "field": attribute name on the Metrics dataclass
# "dist": distribution dict (mu/sigma/lo/hi) for percentile mapping
# "invert": if True, lower raw value β†’ higher (brighter) slider position
# "unit": display unit for the report
# "source": brief literature reference
RESONANCE_METHODS = {