Rileypiper commited on
Commit
d0c71f9
Β·
verified Β·
1 Parent(s): 38b47f1

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +274 -20
app.py CHANGED
@@ -90,6 +90,66 @@ _LEGACY_DIST_MAP = {
90
  "h1_h2_db": "h1_h2_corrected",
91
  }
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  DEFAULT_SR = 22050
94
  FRAME_LEN = 0.040
95
  HOP_LEN = 0.010
@@ -506,7 +566,9 @@ class Metrics:
506
  f2_minus_f1_hz: float
507
  formant_dispersion_hz: float # (F3 - F1) / 2
508
  vtl_cm: float # estimated vocal tract length
 
509
  resonance_combo: float # z-scored composite of F2-F1 + Df
 
510
  # Voice quality / weight
511
  h1_h2_db: float # uncorrected H1-H2
512
  h1_h2_corrected_db: float # H1*-H2* with Iseli-Alwan formant correction
@@ -525,7 +587,7 @@ def empty_metrics(warnings=None):
525
  nan, nan, nan, nan, # pitch
526
  nan, nan, nan, # prosody
527
  nan, nan, nan, # volume
528
- nan, nan, nan, nan, nan, nan, nan, # formants/resonance
529
  nan, nan, nan, nan, nan, nan, # voice quality
530
  0.0, warnings or [], # meta
531
  )
@@ -822,6 +884,52 @@ def extract_distributions(y, sr, pitch_floor_hz, pitch_ceil_hz, max_formant_hz):
822
  if z_parts:
823
  resonance_combo = float(np.mean(z_parts))
824
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
825
  # ── 9. H1*-H2* with Iseli-Alwan formant correction ──
826
  h1h2_raw_vals = []
827
  h1h2_corrected_vals = []
@@ -961,7 +1069,9 @@ def extract_distributions(y, sr, pitch_floor_hz, pitch_ceil_hz, max_formant_hz):
961
  f2_minus_f1_hz=f2_minus_f1_med,
962
  formant_dispersion_hz=formant_disp_med,
963
  vtl_cm=vtl_cm,
 
964
  resonance_combo=resonance_combo,
 
965
  h1_h2_db=h1_h2_med,
966
  h1_h2_corrected_db=h1_h2_corrected_med,
967
  cpp_db=cpp_db_val,
@@ -1025,13 +1135,26 @@ def select_dists(mapping_mode, baseline_state):
1025
  return GLOBAL_DISTS
1026
 
1027
 
1028
- def metrics_to_sliders(m, dists):
1029
  pitch_p = dist_to_percentile(m.f0_med_hz, dists["f0_med_hz"])
1030
  prosody_p = dist_to_percentile(m.prosody_composite, dists["f0_sd_st"])
1031
  volume_p = dist_to_percentile(m.rms_dbfs, dists["rms_dbfs"])
1032
- resonance_p = dist_to_percentile(m.resonance_combo, dists["resonance_combo"])
1033
  weight_p = dist_to_percentile(m.h1_h2_corrected_db, dists["h1_h2_corrected"])
1034
- # Weight: higher H1*-H2* -> lighter voice -> higher slider. No inversion.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1035
  # Volume: higher RMS -> louder -> 100 should be Soft -> invert.
1036
  if np.isfinite(volume_p):
1037
  volume_p = 1.0 - volume_p
@@ -1074,8 +1197,10 @@ def format_report(m):
1074
  " F2 (median): {}".format(_fmt(m.f2_hz, '.0f', ' Hz')),
1075
  " F3 (median): {}".format(_fmt(m.f3_hz, '.0f', ' Hz')),
1076
  " F2 βˆ’ F1: {}".format(_fmt(m.f2_minus_f1_hz, '.0f', ' Hz')),
 
1077
  " Formant dispersion: {}".format(_fmt(m.formant_dispersion_hz, '.0f', ' Hz')),
1078
  " Est. VTL: {}".format(_fmt(m.vtl_cm, '.1f', ' cm')),
 
1079
  " Resonance composite:{}".format(_fmt(m.resonance_combo, '.2f', ' z')),
1080
  "",
1081
  "── Voice Quality / Weight ──",
@@ -1176,8 +1301,10 @@ def history_row(label, m, sliders):
1176
  "clipping_ratio": m.clipping_ratio,
1177
  "f1_hz": m.f1_hz, "f2_hz": m.f2_hz, "f3_hz": m.f3_hz,
1178
  "f2_minus_f1_hz": m.f2_minus_f1_hz,
 
1179
  "formant_dispersion_hz": m.formant_dispersion_hz,
1180
  "vtl_cm": m.vtl_cm,
 
1181
  "resonance_combo": m.resonance_combo,
1182
  "h1_h2_db": m.h1_h2_db,
1183
  "h1_h2_corrected_db": m.h1_h2_corrected_db,
@@ -1210,13 +1337,14 @@ ANALYSIS_CACHE = {}
1210
  _CACHE_LOCK = threading.Lock()
1211
 
1212
 
1213
- def cached_analyze(y, sr, pf, pc, mf, cache_key, dists):
1214
- k = audio_hash(y, sr, extra="{}-{}-{}-{}".format(pf, pc, mf, cache_key))
 
1215
  with _CACHE_LOCK:
1216
  if k in ANALYSIS_CACHE:
1217
  return ANALYSIS_CACHE[k]
1218
  m = extract_metrics(y, sr, pf, pc, mf)
1219
- sliders = metrics_to_sliders(m, dists)
1220
  with _CACHE_LOCK:
1221
  ANALYSIS_CACHE[k] = (m, sliders)
1222
  if len(ANALYSIS_CACHE) > 128:
@@ -1306,6 +1434,99 @@ def _prep_audio(audio_like, window_choice, pitch_floor, pitch_ceil,
1306
  return y, sr, pf, pc, mf
1307
 
1308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1309
  # =============================================================================
1310
  # Coaching tips
1311
  # =============================================================================
@@ -1848,21 +2069,25 @@ def clear_target(history_state):
1848
  return None, history_state, "", "", z, z, z, z, z, render_slider_bars(None, None, context="target")
1849
 
1850
  def set_target(audio_like, window_choice, pitch_floor, pitch_ceil, max_formant,
1851
- use_preemph, use_loudnorm, mapping_mode, baseline_state, history_state):
 
1852
  z = gr.update(value=0)
1853
  empty_bars = render_slider_bars(None, None, context="target")
1854
  try:
1855
  y, sr, pf, pc, mf = _prep_audio(audio_like, window_choice, pitch_floor,
1856
  pitch_ceil, max_formant, use_preemph, use_loudnorm)
1857
  except ValueError as e:
1858
- return None, history_state, str(e), "", z, z, z, z, z, empty_bars
1859
 
1860
  dists = select_dists(mapping_mode, baseline_state)
 
1861
  cache_key = "target-{}-{}-{}-{}".format(window_choice, use_preemph, use_loudnorm, mapping_mode)
1862
- m, sliders = cached_analyze(y, sr, pf, pc, mf, cache_key=cache_key, dists=dists)
 
1863
  target_state = {"metrics": asdict(m), "sliders": sliders}
1864
  history_state = cap_history((history_state or []) + [history_row("Target", m, sliders)])
1865
  bar_html = render_slider_bars(sliders, None, context="target")
 
1866
  return (
1867
  target_state, history_state,
1868
  format_report(m), format_sliders(sliders),
@@ -1872,10 +2097,12 @@ def set_target(audio_like, window_choice, pitch_floor, pitch_ceil, max_formant,
1872
  gr.update(value=safe(sliders["Prosody"])),
1873
  gr.update(value=safe(sliders["Volume"])),
1874
  bar_html,
 
1875
  )
1876
 
1877
  def analyze_user(audio_like, label, window_choice, pitch_floor, pitch_ceil,
1878
  max_formant, use_preemph, use_loudnorm, mapping_mode,
 
1879
  baseline_state, target_state, history_state):
1880
  z = gr.update(value=0)
1881
  z0 = gr.update(value=0.0)
@@ -1888,7 +2115,7 @@ def analyze_user(audio_like, label, window_choice, pitch_floor, pitch_ceil,
1888
  None, None, None,
1889
  [],
1890
  render_slider_bars((target_state or {}).get("sliders"), None, context="user"),
1891
- "", "", "",
1892
  )
1893
  try:
1894
  y, sr, pf, pc, mf = _prep_audio(audio_like, window_choice, pitch_floor,
@@ -1896,10 +2123,12 @@ def analyze_user(audio_like, label, window_choice, pitch_floor, pitch_ceil,
1896
  except ValueError:
1897
  return empty_out
1898
 
 
1899
  dists = select_dists(mapping_mode, baseline_state)
1900
  cache_key = "user-{}-{}-{}-{}-{}".format(label, window_choice, use_preemph,
1901
  use_loudnorm, mapping_mode)
1902
- m, sliders = cached_analyze(y, sr, pf, pc, mf, cache_key=cache_key, dists=dists)
 
1903
 
1904
  warnings = list(m.warnings)
1905
  append_gain_warning(target_state, sliders, warnings)
@@ -1920,6 +2149,7 @@ def analyze_user(audio_like, label, window_choice, pitch_floor, pitch_ceil,
1920
  radar_html = render_radar_chart(t_sliders, sliders)
1921
  trend_html = render_trend_plot(history_state)
1922
  coaching = generate_coaching(delta, m)
 
1923
 
1924
  return (
1925
  history_state,
@@ -1943,10 +2173,12 @@ def analyze_user(audio_like, label, window_choice, pitch_floor, pitch_ceil,
1943
  radar_html,
1944
  trend_html,
1945
  coaching,
 
1946
  )
1947
 
1948
  def live_analyze(stream_like, buffer_state, pitch_floor, pitch_ceil, max_formant,
1949
- use_preemph, use_loudnorm, mapping_mode, baseline_state, target_state):
 
1950
  no_change = (buffer_state, *(gr.update() for _ in range(16)))
1951
 
1952
  normalized = normalize_audio_tuple(stream_like)
@@ -1988,9 +2220,10 @@ def live_analyze(stream_like, buffer_state, pitch_floor, pitch_ceil, max_formant
1988
  pf = min(pf, pc - 1)
1989
  mf = int(np.clip(int(round(max_formant)), 4500, 6500))
1990
 
 
1991
  m = extract_metrics(seg, sr, pf, pc, mf)
1992
  dists = select_dists(mapping_mode, baseline_state)
1993
- sliders = metrics_to_sliders(m, dists)
1994
 
1995
  warnings = list(m.warnings)
1996
  append_gain_warning(target_state, sliders, warnings)
@@ -2122,6 +2355,11 @@ with gr.Blocks(**_blocks_kw) as demo:
2122
  mapping_mode = gr.Dropdown(
2123
  ["Global", "Anchored (literature)", "Baseline (personal)"],
2124
  value="Global", label="Mapping mode")
 
 
 
 
 
2125
  with gr.Row():
2126
  pitch_floor = gr.Slider(40, 150, value=PITCH_FLOOR_DEFAULT, step=1, label="Pitch floor (Hz)")
2127
  pitch_ceil = gr.Slider(250, 800, value=PITCH_CEIL_DEFAULT, step=1, label="Pitch ceiling (Hz)")
@@ -2174,6 +2412,11 @@ with gr.Blocks(**_blocks_kw) as demo:
2174
  elem_classes=["report-box"])
2175
  target_slider_text = gr.Textbox(label="Slider values", lines=4,
2176
  elem_classes=["report-box"])
 
 
 
 
 
2177
 
2178
  # ════════════════════════════════════════════════════════
2179
  # TAB 2: YOUR VOICE
@@ -2210,6 +2453,12 @@ with gr.Blocks(**_blocks_kw) as demo:
2210
  radar_html = gr.HTML(value="", label="Radar comparison")
2211
  trend_html = gr.HTML(value="", label="Session trend")
2212
 
 
 
 
 
 
 
2213
  with gr.Accordion("Live mode", open=False):
2214
  gr.Markdown("Live mode streams from your microphone with EMA smoothing.")
2215
  live_audio = gr.Audio(sources=["microphone"], type="numpy",
@@ -2275,9 +2524,11 @@ with gr.Blocks(**_blocks_kw) as demo:
2275
  btn_clear_baseline.click(clear_baseline, [], baseline_outputs)
2276
 
2277
  target_inputs = [target_audio, analysis_window, pitch_floor, pitch_ceil, max_formant,
2278
- use_preemph, use_loudnorm, mapping_mode, baseline_state, history_state]
 
2279
  target_outputs = [target_state, history_state, target_report, target_slider_text,
2280
- t_pitch, t_weight, t_res, t_pros, t_vol, target_bars]
 
2281
  btn_set_target.click(set_target, target_inputs, target_outputs)
2282
  btn_clear_target.click(clear_target, [history_state],
2283
  [target_state, history_state, target_report, target_slider_text,
@@ -2290,9 +2541,11 @@ with gr.Blocks(**_blocks_kw) as demo:
2290
  user_report, user_slider_text, delta_text,
2291
  export_json, export_csv, export_baseline,
2292
  history_table,
2293
- user_bars, radar_html, trend_html, coaching_md]
 
2294
  user_common_inputs = [analysis_window, pitch_floor, pitch_ceil, max_formant,
2295
- use_preemph, use_loudnorm, mapping_mode, baseline_state,
 
2296
  target_state, history_state]
2297
  btn_analyze_mic.click(analyze_user, [user_audio_mic, label_recorded] + user_common_inputs, user_outputs)
2298
  btn_analyze_upload.click(analyze_user, [user_audio_up, label_uploaded] + user_common_inputs, user_outputs)
@@ -2305,8 +2558,9 @@ with gr.Blocks(**_blocks_kw) as demo:
2305
  user_bars, coaching_md]
2306
  live_audio.stream(live_analyze,
2307
  [live_audio, live_buffer_state, pitch_floor, pitch_ceil, max_formant,
2308
- use_preemph, use_loudnorm, mapping_mode, baseline_state, target_state],
 
2309
  live_outputs)
2310
  btn_reset_live.click(reset_live, [], [live_buffer_state])
2311
 
2312
- demo.queue(max_size=32).launch(**_launch_kw)
 
90
  "h1_h2_db": "h1_h2_corrected",
91
  }
92
 
93
+ # ── Resonance method definitions ──
94
+ # Each method maps a Metrics field to a distribution and polarity.
95
+ # "field": attribute name on the Metrics dataclass
96
+ # "dist": distribution dict (mu/sigma/lo/hi) for percentile mapping
97
+ # "invert": if True, lower raw value β†’ higher (brighter) slider position
98
+ # "unit": display unit for the report
99
+ # "source": brief literature reference
100
+ RESONANCE_METHODS = {
101
+ "F2 βˆ’ F1 (Hz)": {
102
+ "field": "f2_minus_f1_hz",
103
+ "dist": {"mu": 900.0, "sigma": 200.0, "lo": 300.0, "hi": 1600.0},
104
+ "invert": False, # higher = brighter
105
+ "unit": "Hz",
106
+ "source": "Hillenbrand 1995 β€” cis-M β‰ˆ 780, cis-F β‰ˆ 1020",
107
+ },
108
+ "Formant Dispersion Df": {
109
+ "field": "formant_dispersion_hz",
110
+ "dist": {"mu": 1100.0, "sigma": 150.0, "lo": 700.0, "hi": 1500.0},
111
+ "invert": False, # higher spacing = shorter VTL = brighter
112
+ "unit": "Hz",
113
+ "source": "Fitch 1997 β€” cis-M β‰ˆ 1050, cis-F β‰ˆ 1150",
114
+ },
115
+ "Est. VTL (cm)": {
116
+ "field": "vtl_cm",
117
+ "dist": {"mu": 16.0, "sigma": 1.8, "lo": 10.0, "hi": 22.0},
118
+ "invert": True, # shorter VTL = brighter β†’ invert
119
+ "unit": "cm",
120
+ "source": "Fitch 1997; VTL = c / (4 Γ— Df)",
121
+ },
122
+ "F2/F1 Ratio": {
123
+ "field": "f2_f1_ratio",
124
+ "dist": {"mu": 2.0, "sigma": 0.5, "lo": 1.0, "hi": 4.0},
125
+ "invert": False, # higher ratio = brighter
126
+ "unit": "",
127
+ "source": "Derived; normalizes vowel-height confound",
128
+ },
129
+ "Composite Z (default)": {
130
+ "field": "resonance_combo",
131
+ "dist": {"mu": 0.0, "sigma": 1.0, "lo": -3.0, "hi": 3.0},
132
+ "invert": False, # z-score: positive = brighter
133
+ "unit": "z",
134
+ "source": "Z-scored mean of F2βˆ’F1 + Df",
135
+ },
136
+ "Spectral Mean (Hz)": {
137
+ "field": "spectral_mean_hz",
138
+ "dist": {"mu": 1400.0, "sigma": 350.0, "lo": 600.0, "hi": 3000.0},
139
+ "invert": False, # higher CoG = brighter
140
+ "unit": "Hz",
141
+ "source": "Power-spectrum center of gravity above F0; formant-free",
142
+ },
143
+ "F3 (Hz)": {
144
+ "field": "f3_hz",
145
+ "dist": {"mu": 2800.0, "sigma": 250.0, "lo": 2000.0, "hi": 3800.0},
146
+ "invert": False, # higher F3 = shorter pharynx = brighter
147
+ "unit": "Hz",
148
+ "source": "Direct pharyngeal-space cue; Vorperian & Kent 2007",
149
+ },
150
+ }
151
+ RESONANCE_METHOD_DEFAULT = "Composite Z (default)"
152
+
153
  DEFAULT_SR = 22050
154
  FRAME_LEN = 0.040
155
  HOP_LEN = 0.010
 
566
  f2_minus_f1_hz: float
567
  formant_dispersion_hz: float # (F3 - F1) / 2
568
  vtl_cm: float # estimated vocal tract length
569
+ f2_f1_ratio: float # F2/F1, dimensionless resonance ratio
570
  resonance_combo: float # z-scored composite of F2-F1 + Df
571
+ spectral_mean_hz: float # center of gravity of power spectrum above F0
572
  # Voice quality / weight
573
  h1_h2_db: float # uncorrected H1-H2
574
  h1_h2_corrected_db: float # H1*-H2* with Iseli-Alwan formant correction
 
587
  nan, nan, nan, nan, # pitch
588
  nan, nan, nan, # prosody
589
  nan, nan, nan, # volume
590
+ nan, nan, nan, nan, nan, nan, nan, nan, nan, # formants/resonance (9)
591
  nan, nan, nan, nan, nan, nan, # voice quality
592
  0.0, warnings or [], # meta
593
  )
 
884
  if z_parts:
885
  resonance_combo = float(np.mean(z_parts))
886
 
887
+ # ── F2/F1 ratio (dimensionless resonance) ──
888
+ f2_f1_ratio = float("nan")
889
+ if f1_vals and f2_vals:
890
+ f1_arr_r = np.array(f1_vals, dtype=np.float32)
891
+ f2_arr_r = np.array(f2_vals, dtype=np.float32)
892
+ # Avoid division by zero; exclude extreme F1 values
893
+ valid = f1_arr_r > 200
894
+ if valid.sum() > 3:
895
+ ratios = f2_arr_r[valid] / f1_arr_r[valid]
896
+ ratios = robust_clip(ratios)
897
+ f2_f1_ratio = float(np.median(ratios))
898
+
899
+ # ── Spectral mean / center of gravity above F0 (formant-free resonance) ──
900
+ spectral_mean_hz = float("nan")
901
+ if np.isfinite(f0_med) and y.size >= frame:
902
+ # Compute average power spectrum across voiced frames, then find CoG
903
+ n_fft_cog = 4096
904
+ freqs_cog = np.fft.rfftfreq(n_fft_cog, d=1.0 / sr)
905
+ window_cog = np.hanning(frame).astype(np.float32)
906
+ sum_power = np.zeros(n_fft_cog // 2 + 1, dtype=np.float64)
907
+ n_frames_cog = 0
908
+ centers_cog = (times * sr).astype(int)
909
+ half_cog = frame // 2
910
+ for i, (c, v) in enumerate(zip(centers_cog, voiced)):
911
+ if not v:
912
+ continue
913
+ s0 = max(0, c - half_cog)
914
+ s1 = s0 + frame
915
+ if s1 > y.size:
916
+ continue
917
+ seg = y[s0:s1] * window_cog
918
+ power = np.abs(np.fft.rfft(seg, n=n_fft_cog)) ** 2
919
+ sum_power += power
920
+ n_frames_cog += 1
921
+ if n_frames_cog > 5:
922
+ avg_power = sum_power / n_frames_cog
923
+ # Mask below 1.5 Γ— F0 to exclude fundamental and first harmonic
924
+ f_cutoff = 1.5 * f0_med
925
+ mask_above_f0 = freqs_cog >= f_cutoff
926
+ if mask_above_f0.sum() > 10:
927
+ p_above = avg_power[mask_above_f0]
928
+ f_above = freqs_cog[mask_above_f0]
929
+ total = p_above.sum()
930
+ if total > 0:
931
+ spectral_mean_hz = float(np.sum(f_above * p_above) / total)
932
+
933
  # ── 9. H1*-H2* with Iseli-Alwan formant correction ──
934
  h1h2_raw_vals = []
935
  h1h2_corrected_vals = []
 
1069
  f2_minus_f1_hz=f2_minus_f1_med,
1070
  formant_dispersion_hz=formant_disp_med,
1071
  vtl_cm=vtl_cm,
1072
+ f2_f1_ratio=f2_f1_ratio,
1073
  resonance_combo=resonance_combo,
1074
+ spectral_mean_hz=spectral_mean_hz,
1075
  h1_h2_db=h1_h2_med,
1076
  h1_h2_corrected_db=h1_h2_corrected_med,
1077
  cpp_db=cpp_db_val,
 
1135
  return GLOBAL_DISTS
1136
 
1137
 
1138
+ def metrics_to_sliders(m, dists, resonance_method=None):
1139
  pitch_p = dist_to_percentile(m.f0_med_hz, dists["f0_med_hz"])
1140
  prosody_p = dist_to_percentile(m.prosody_composite, dists["f0_sd_st"])
1141
  volume_p = dist_to_percentile(m.rms_dbfs, dists["rms_dbfs"])
 
1142
  weight_p = dist_to_percentile(m.h1_h2_corrected_db, dists["h1_h2_corrected"])
1143
+
1144
+ # Resonance: use selected method's field and distribution
1145
+ method_name = resonance_method or RESONANCE_METHOD_DEFAULT
1146
+ method_cfg = RESONANCE_METHODS.get(method_name, RESONANCE_METHODS[RESONANCE_METHOD_DEFAULT])
1147
+ res_field = method_cfg["field"]
1148
+ res_val = getattr(m, res_field, float("nan"))
1149
+ # Use baseline dist for resonance_combo key if available, else method-specific dist
1150
+ res_dist = dists.get("resonance_combo", method_cfg["dist"])
1151
+ # If mapping mode is Global/Anchored, always use the method-specific dist
1152
+ if res_dist is dists.get("resonance_combo") and method_name != RESONANCE_METHOD_DEFAULT:
1153
+ res_dist = method_cfg["dist"]
1154
+ resonance_p = dist_to_percentile(res_val, res_dist)
1155
+ if method_cfg.get("invert") and np.isfinite(resonance_p):
1156
+ resonance_p = 1.0 - resonance_p
1157
+
1158
  # Volume: higher RMS -> louder -> 100 should be Soft -> invert.
1159
  if np.isfinite(volume_p):
1160
  volume_p = 1.0 - volume_p
 
1197
  " F2 (median): {}".format(_fmt(m.f2_hz, '.0f', ' Hz')),
1198
  " F3 (median): {}".format(_fmt(m.f3_hz, '.0f', ' Hz')),
1199
  " F2 βˆ’ F1: {}".format(_fmt(m.f2_minus_f1_hz, '.0f', ' Hz')),
1200
+ " F2/F1 ratio: {}".format(_fmt(m.f2_f1_ratio, '.2f')),
1201
  " Formant dispersion: {}".format(_fmt(m.formant_dispersion_hz, '.0f', ' Hz')),
1202
  " Est. VTL: {}".format(_fmt(m.vtl_cm, '.1f', ' cm')),
1203
+ " Spectral mean: {}".format(_fmt(m.spectral_mean_hz, '.0f', ' Hz')),
1204
  " Resonance composite:{}".format(_fmt(m.resonance_combo, '.2f', ' z')),
1205
  "",
1206
  "── Voice Quality / Weight ──",
 
1301
  "clipping_ratio": m.clipping_ratio,
1302
  "f1_hz": m.f1_hz, "f2_hz": m.f2_hz, "f3_hz": m.f3_hz,
1303
  "f2_minus_f1_hz": m.f2_minus_f1_hz,
1304
+ "f2_f1_ratio": m.f2_f1_ratio,
1305
  "formant_dispersion_hz": m.formant_dispersion_hz,
1306
  "vtl_cm": m.vtl_cm,
1307
+ "spectral_mean_hz": m.spectral_mean_hz,
1308
  "resonance_combo": m.resonance_combo,
1309
  "h1_h2_db": m.h1_h2_db,
1310
  "h1_h2_corrected_db": m.h1_h2_corrected_db,
 
1337
  _CACHE_LOCK = threading.Lock()
1338
 
1339
 
1340
+ def cached_analyze(y, sr, pf, pc, mf, cache_key, dists, resonance_method=None):
1341
+ rm = resonance_method or RESONANCE_METHOD_DEFAULT
1342
+ k = audio_hash(y, sr, extra="{}-{}-{}-{}-rm:{}".format(pf, pc, mf, cache_key, rm))
1343
  with _CACHE_LOCK:
1344
  if k in ANALYSIS_CACHE:
1345
  return ANALYSIS_CACHE[k]
1346
  m = extract_metrics(y, sr, pf, pc, mf)
1347
+ sliders = metrics_to_sliders(m, dists, resonance_method=rm)
1348
  with _CACHE_LOCK:
1349
  ANALYSIS_CACHE[k] = (m, sliders)
1350
  if len(ANALYSIS_CACHE) > 128:
 
1434
  return y, sr, pf, pc, mf
1435
 
1436
 
1437
+ # =============================================================================
1438
+ # Resonance method comparison
1439
+ # =============================================================================
1440
+
1441
+ def resonance_comparison_table(m, dists, active_method=None):
1442
+ """Generate an HTML comparison table showing all 7 resonance methods side-by-side.
1443
+
1444
+ For each method, shows: raw value, unit, slider position (0-100), and
1445
+ whether it's the currently active method.
1446
+ """
1447
+ active = active_method or RESONANCE_METHOD_DEFAULT
1448
+ rows = []
1449
+ for name, cfg in RESONANCE_METHODS.items():
1450
+ field = cfg["field"]
1451
+ raw_val = getattr(m, field, float("nan"))
1452
+ unit = cfg["unit"]
1453
+ is_active = (name == active)
1454
+
1455
+ # Compute slider position using method-specific dist
1456
+ d = cfg["dist"]
1457
+ pct = dist_to_percentile(raw_val, d)
1458
+ if cfg.get("invert") and np.isfinite(pct):
1459
+ pct = 1.0 - pct
1460
+ slider_val = pct_to_slider(pct) if np.isfinite(pct) else float("nan")
1461
+
1462
+ # Format values
1463
+ if np.isfinite(raw_val):
1464
+ if unit == "cm":
1465
+ raw_str = "{:.1f}".format(raw_val)
1466
+ elif unit == "z":
1467
+ raw_str = "{:+.2f}".format(raw_val)
1468
+ elif unit == "":
1469
+ raw_str = "{:.2f}".format(raw_val)
1470
+ else:
1471
+ raw_str = "{:.0f}".format(raw_val)
1472
+ else:
1473
+ raw_str = "β€”"
1474
+
1475
+ slider_str = "{:.0f}".format(slider_val) if np.isfinite(slider_val) else "β€”"
1476
+
1477
+ # Color-code slider value: gradient from dark (0) to bright (100)
1478
+ if np.isfinite(slider_val):
1479
+ hue = 25 + (slider_val / 100.0) * 10 # orange range 25-35
1480
+ sat = 70
1481
+ light = 30 + (slider_val / 100.0) * 35 # 30% to 65%
1482
+ bar_pct = max(3, slider_val)
1483
+ bar_color = "hsl({}, {}%, {}%)".format(hue, sat, light)
1484
+ else:
1485
+ bar_pct = 0
1486
+ bar_color = "#555"
1487
+
1488
+ active_marker = " ✦" if is_active else ""
1489
+ row_class = "res-row-active" if is_active else "res-row"
1490
+
1491
+ rows.append(
1492
+ '<tr class="{cls}">'
1493
+ '<td style="font-weight:{fw}; white-space:nowrap">{name}{mark}</td>'
1494
+ '<td style="text-align:right; font-variant-numeric:tabular-nums">{raw} {u}</td>'
1495
+ '<td style="width:45%">'
1496
+ ' <div style="background:#333;border-radius:4px;height:18px;position:relative;overflow:hidden">'
1497
+ ' <div style="background:{bc};height:100%;width:{bp}%;border-radius:4px;'
1498
+ ' transition:width 0.4s ease"></div>'
1499
+ ' <span style="position:absolute;right:6px;top:0;line-height:18px;'
1500
+ ' font-size:11px;color:#eee">{sv}</span>'
1501
+ ' </div>'
1502
+ '</td>'
1503
+ '<td style="text-align:center;font-size:11px;color:#999">{src}</td>'
1504
+ '</tr>'.format(
1505
+ cls=row_class,
1506
+ fw="700" if is_active else "400",
1507
+ name=name, mark=active_marker,
1508
+ raw=raw_str, u=unit,
1509
+ bc=bar_color, bp=bar_pct, sv=slider_str,
1510
+ src=cfg["source"].split("β€”")[0].strip() if "β€”" in cfg["source"] else cfg["source"][:25],
1511
+ )
1512
+ )
1513
+
1514
+ html = (
1515
+ '<div style="margin:8px 0">'
1516
+ '<table style="width:100%;border-collapse:collapse;font-size:13px;color:#ddd">'
1517
+ '<thead><tr style="border-bottom:1px solid #555">'
1518
+ '<th style="text-align:left;padding:4px 6px;color:#e8956e">Method</th>'
1519
+ '<th style="text-align:right;padding:4px 6px;color:#e8956e">Value</th>'
1520
+ '<th style="text-align:left;padding:4px 6px;color:#e8956e">Slider (0–100)</th>'
1521
+ '<th style="text-align:center;padding:4px 6px;color:#e8956e">Source</th>'
1522
+ '</tr></thead><tbody>'
1523
+ )
1524
+ for r in rows:
1525
+ html += r
1526
+ html += '</tbody></table></div>'
1527
+ return html
1528
+
1529
+
1530
  # =============================================================================
1531
  # Coaching tips
1532
  # =============================================================================
 
2069
  return None, history_state, "", "", z, z, z, z, z, render_slider_bars(None, None, context="target")
2070
 
2071
  def set_target(audio_like, window_choice, pitch_floor, pitch_ceil, max_formant,
2072
+ use_preemph, use_loudnorm, mapping_mode, resonance_method,
2073
+ baseline_state, history_state):
2074
  z = gr.update(value=0)
2075
  empty_bars = render_slider_bars(None, None, context="target")
2076
  try:
2077
  y, sr, pf, pc, mf = _prep_audio(audio_like, window_choice, pitch_floor,
2078
  pitch_ceil, max_formant, use_preemph, use_loudnorm)
2079
  except ValueError as e:
2080
+ return None, history_state, str(e), "", z, z, z, z, z, empty_bars, ""
2081
 
2082
  dists = select_dists(mapping_mode, baseline_state)
2083
+ rm = resonance_method or RESONANCE_METHOD_DEFAULT
2084
  cache_key = "target-{}-{}-{}-{}".format(window_choice, use_preemph, use_loudnorm, mapping_mode)
2085
+ m, sliders = cached_analyze(y, sr, pf, pc, mf, cache_key=cache_key, dists=dists,
2086
+ resonance_method=rm)
2087
  target_state = {"metrics": asdict(m), "sliders": sliders}
2088
  history_state = cap_history((history_state or []) + [history_row("Target", m, sliders)])
2089
  bar_html = render_slider_bars(sliders, None, context="target")
2090
+ res_compare = resonance_comparison_table(m, dists, active_method=rm)
2091
  return (
2092
  target_state, history_state,
2093
  format_report(m), format_sliders(sliders),
 
2097
  gr.update(value=safe(sliders["Prosody"])),
2098
  gr.update(value=safe(sliders["Volume"])),
2099
  bar_html,
2100
+ res_compare,
2101
  )
2102
 
2103
  def analyze_user(audio_like, label, window_choice, pitch_floor, pitch_ceil,
2104
  max_formant, use_preemph, use_loudnorm, mapping_mode,
2105
+ resonance_method,
2106
  baseline_state, target_state, history_state):
2107
  z = gr.update(value=0)
2108
  z0 = gr.update(value=0.0)
 
2115
  None, None, None,
2116
  [],
2117
  render_slider_bars((target_state or {}).get("sliders"), None, context="user"),
2118
+ "", "", "", "",
2119
  )
2120
  try:
2121
  y, sr, pf, pc, mf = _prep_audio(audio_like, window_choice, pitch_floor,
 
2123
  except ValueError:
2124
  return empty_out
2125
 
2126
+ rm = resonance_method or RESONANCE_METHOD_DEFAULT
2127
  dists = select_dists(mapping_mode, baseline_state)
2128
  cache_key = "user-{}-{}-{}-{}-{}".format(label, window_choice, use_preemph,
2129
  use_loudnorm, mapping_mode)
2130
+ m, sliders = cached_analyze(y, sr, pf, pc, mf, cache_key=cache_key, dists=dists,
2131
+ resonance_method=rm)
2132
 
2133
  warnings = list(m.warnings)
2134
  append_gain_warning(target_state, sliders, warnings)
 
2149
  radar_html = render_radar_chart(t_sliders, sliders)
2150
  trend_html = render_trend_plot(history_state)
2151
  coaching = generate_coaching(delta, m)
2152
+ res_compare = resonance_comparison_table(m, dists, active_method=rm)
2153
 
2154
  return (
2155
  history_state,
 
2173
  radar_html,
2174
  trend_html,
2175
  coaching,
2176
+ res_compare,
2177
  )
2178
 
2179
  def live_analyze(stream_like, buffer_state, pitch_floor, pitch_ceil, max_formant,
2180
+ use_preemph, use_loudnorm, mapping_mode, resonance_method,
2181
+ baseline_state, target_state):
2182
  no_change = (buffer_state, *(gr.update() for _ in range(16)))
2183
 
2184
  normalized = normalize_audio_tuple(stream_like)
 
2220
  pf = min(pf, pc - 1)
2221
  mf = int(np.clip(int(round(max_formant)), 4500, 6500))
2222
 
2223
+ rm = resonance_method or RESONANCE_METHOD_DEFAULT
2224
  m = extract_metrics(seg, sr, pf, pc, mf)
2225
  dists = select_dists(mapping_mode, baseline_state)
2226
+ sliders = metrics_to_sliders(m, dists, resonance_method=rm)
2227
 
2228
  warnings = list(m.warnings)
2229
  append_gain_warning(target_state, sliders, warnings)
 
2355
  mapping_mode = gr.Dropdown(
2356
  ["Global", "Anchored (literature)", "Baseline (personal)"],
2357
  value="Global", label="Mapping mode")
2358
+ resonance_method = gr.Dropdown(
2359
+ list(RESONANCE_METHODS.keys()),
2360
+ value=RESONANCE_METHOD_DEFAULT,
2361
+ label="Resonance method",
2362
+ info="Which acoustic measure drives the Resonance slider")
2363
  with gr.Row():
2364
  pitch_floor = gr.Slider(40, 150, value=PITCH_FLOOR_DEFAULT, step=1, label="Pitch floor (Hz)")
2365
  pitch_ceil = gr.Slider(250, 800, value=PITCH_CEIL_DEFAULT, step=1, label="Pitch ceiling (Hz)")
 
2412
  elem_classes=["report-box"])
2413
  target_slider_text = gr.Textbox(label="Slider values", lines=4,
2414
  elem_classes=["report-box"])
2415
+ with gr.Accordion("Resonance Methods Comparison", open=False):
2416
+ gr.Markdown("All 7 resonance measurement methods computed for this audio. "
2417
+ "The **active method** (✦) drives the Resonance slider above. "
2418
+ "Change the method in Analysis Settings to compare.")
2419
+ target_res_compare = gr.HTML(value="")
2420
 
2421
  # ════════════════════════════════════════════════════════
2422
  # TAB 2: YOUR VOICE
 
2453
  radar_html = gr.HTML(value="", label="Radar comparison")
2454
  trend_html = gr.HTML(value="", label="Session trend")
2455
 
2456
+ with gr.Accordion("Resonance Methods Comparison", open=True):
2457
+ gr.Markdown("All 7 resonance metrics computed simultaneously. "
2458
+ "The **active method** (✦) drives the slider. "
2459
+ "Compare to find which tracks your perceived resonance best.")
2460
+ user_res_compare = gr.HTML(value="")
2461
+
2462
  with gr.Accordion("Live mode", open=False):
2463
  gr.Markdown("Live mode streams from your microphone with EMA smoothing.")
2464
  live_audio = gr.Audio(sources=["microphone"], type="numpy",
 
2524
  btn_clear_baseline.click(clear_baseline, [], baseline_outputs)
2525
 
2526
  target_inputs = [target_audio, analysis_window, pitch_floor, pitch_ceil, max_formant,
2527
+ use_preemph, use_loudnorm, mapping_mode, resonance_method,
2528
+ baseline_state, history_state]
2529
  target_outputs = [target_state, history_state, target_report, target_slider_text,
2530
+ t_pitch, t_weight, t_res, t_pros, t_vol, target_bars,
2531
+ target_res_compare]
2532
  btn_set_target.click(set_target, target_inputs, target_outputs)
2533
  btn_clear_target.click(clear_target, [history_state],
2534
  [target_state, history_state, target_report, target_slider_text,
 
2541
  user_report, user_slider_text, delta_text,
2542
  export_json, export_csv, export_baseline,
2543
  history_table,
2544
+ user_bars, radar_html, trend_html, coaching_md,
2545
+ user_res_compare]
2546
  user_common_inputs = [analysis_window, pitch_floor, pitch_ceil, max_formant,
2547
+ use_preemph, use_loudnorm, mapping_mode, resonance_method,
2548
+ baseline_state,
2549
  target_state, history_state]
2550
  btn_analyze_mic.click(analyze_user, [user_audio_mic, label_recorded] + user_common_inputs, user_outputs)
2551
  btn_analyze_upload.click(analyze_user, [user_audio_up, label_uploaded] + user_common_inputs, user_outputs)
 
2558
  user_bars, coaching_md]
2559
  live_audio.stream(live_analyze,
2560
  [live_audio, live_buffer_state, pitch_floor, pitch_ceil, max_formant,
2561
+ use_preemph, use_loudnorm, mapping_mode, resonance_method,
2562
+ baseline_state, target_state],
2563
  live_outputs)
2564
  btn_reset_live.click(reset_live, [], [live_buffer_state])
2565
 
2566
+ demo.queue(max_size=32).launch(**_launch_kw)