Spaces:
Sleeping
Sleeping
Download app.py from Rileypiper/Sliders: direct link, hf CLI and curl.
- Browser
- Download file 111 kB
-
https://huggingface.co/spaces/Rileypiper/Sliders/resolve/main/app.py
- Command line
-
hf download hf://spaces/Rileypiper/Sliders/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Rileypiper/Sliders/resolve/main/app.py
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 = {