Marcel0123 commited on
Commit
7be3259
·
verified ·
1 Parent(s): 2388930

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +123 -150
app.py CHANGED
@@ -6,7 +6,7 @@ import librosa
6
  import matplotlib.pyplot as plt
7
 
8
  from dataclasses import dataclass
9
- from typing import Dict, Any, Tuple, List
10
 
11
  # =========================================================
12
  # Config
@@ -15,19 +15,6 @@ TARGET_SR = 16000
15
  APP_DIR = os.path.dirname(os.path.abspath(__file__))
16
 
17
 
18
- # =========================================================
19
- # Bundled audio (repo root / same folder as app.py)
20
- # =========================================================
21
- def list_bundled_audio() -> List[str]:
22
- exts = (".mp3", ".wav", ".m4a", ".flac", ".ogg")
23
- try:
24
- files = [fn for fn in os.listdir(APP_DIR) if fn.lower().endswith(exts)]
25
- except Exception:
26
- files = []
27
- files.sort()
28
- return files
29
-
30
-
31
  # =========================================================
32
  # Helpers
33
  # =========================================================
@@ -46,6 +33,29 @@ def safe_pct(x: float) -> str:
46
  return f"{x*100:.1f}%"
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  # =========================================================
50
  # Features
51
  # =========================================================
@@ -64,10 +74,6 @@ class Features:
64
 
65
 
66
  def compute_features(y: np.ndarray, sr: int) -> Tuple[Features, Dict[str, Any]]:
67
- """
68
- Explainable acoustic features + artifacts for plotting.
69
- (No medical claims; only measurable signals.)
70
- """
71
  if y is None or len(y) == 0:
72
  f = Features(
73
  duration_s=float("nan"),
@@ -81,7 +87,7 @@ def compute_features(y: np.ndarray, sr: int) -> Tuple[Features, Dict[str, Any]]:
81
  pause_total_s=0.0,
82
  active_ratio=float("nan"),
83
  )
84
- return f, {"y": np.array([]), "sr": sr}
85
 
86
  # Resample to stable SR
87
  if sr != TARGET_SR:
@@ -90,14 +96,14 @@ def compute_features(y: np.ndarray, sr: int) -> Tuple[Features, Dict[str, Any]]:
90
  else:
91
  y = y.astype(np.float32)
92
 
93
- # Normalize [-1, 1] for stable plots/features
94
  mx = float(np.max(np.abs(y))) + 1e-9
95
  y = y / mx
96
 
97
  duration = float(len(y) / sr)
98
 
99
- hop = 160 # 10ms @ 16k
100
- frame = 400 # 25ms @ 16k
101
 
102
  rms = librosa.feature.rms(y=y, frame_length=frame, hop_length=hop)[0]
103
  zcr = librosa.feature.zero_crossing_rate(y, frame_length=frame, hop_length=hop)[0]
@@ -106,7 +112,7 @@ def compute_features(y: np.ndarray, sr: int) -> Tuple[Features, Dict[str, Any]]:
106
  rms_std = float(np.std(rms)) if rms.size else float("nan")
107
  zcr_mean = float(np.mean(zcr)) if zcr.size else float("nan")
108
 
109
- # Pitch via pyin (can fail on noise/short clips)
110
  try:
111
  f0, _, _ = librosa.pyin(
112
  y,
@@ -139,12 +145,10 @@ def compute_features(y: np.ndarray, sr: int) -> Tuple[Features, Dict[str, Any]]:
139
  pitch_median = float("nan")
140
  pitch_iqr = float("nan")
141
 
142
- # Pause detection: low-RMS frames as silence
143
  if rms.size:
144
  thr = float(np.percentile(rms, 20)) * 0.8
145
  silent = rms < thr
146
-
147
- # pauses >= 0.2s
148
  min_pause_frames = int(0.2 / (hop / sr))
149
 
150
  pauses = []
@@ -166,7 +170,6 @@ def compute_features(y: np.ndarray, sr: int) -> Tuple[Features, Dict[str, Any]]:
166
  pause_total_s = float(sum((e - s) * (hop / sr) for s, e in pauses))
167
  active_ratio = float(1.0 - np.mean(silent))
168
  else:
169
- thr = None
170
  pauses = []
171
  n_pauses = 0
172
  pause_total_s = 0.0
@@ -189,13 +192,11 @@ def compute_features(y: np.ndarray, sr: int) -> Tuple[Features, Dict[str, Any]]:
189
  "y": y,
190
  "sr": sr,
191
  "hop": hop,
192
- "frame": frame,
193
  "rms": rms,
194
  "zcr": zcr,
195
- "times": times,
196
  "pitch": pitch,
 
197
  "pauses": pauses,
198
- "rms_thr": thr,
199
  }
200
  return feats, artifacts
201
 
@@ -216,9 +217,7 @@ def plot_waveform_with_pauses(art: Dict[str, Any]) -> plt.Figure:
216
  t = np.arange(len(y)) / sr
217
  ax.plot(t, y, linewidth=0.8)
218
  for (s, e) in pauses:
219
- ts = s * (hop / sr)
220
- te = e * (hop / sr)
221
- ax.axvspan(ts, te, alpha=0.2)
222
  ax.set_title("Waveform (with detected pauses)")
223
  ax.set_xlabel("Time (s)")
224
  ax.set_ylabel("Amplitude")
@@ -250,9 +249,6 @@ def plot_pitch(art: Dict[str, Any]) -> plt.Figure:
250
  return fig
251
 
252
 
253
- # =========================================================
254
- # UI formatting
255
- # =========================================================
256
  def features_table(feats: Features) -> List[List[str]]:
257
  def f3(x):
258
  return "—" if (x is None or not math.isfinite(x)) else f"{float(x):.3f}"
@@ -285,57 +281,44 @@ def explain_text_single(feats: Features) -> str:
285
  "This is an **explainability demo**: it shows **measurable speech signals** (not *why* they change).\n\n"
286
  + "\n".join(bullets)
287
  + "\n\n"
288
- "**Important:** this is **not a diagnosis** and **not a medical device**. "
289
- "Use it as an **educational visualization** or a conversation starter."
290
  )
291
 
292
 
293
  def explain_text_timeline() -> str:
294
  return (
295
  "### Timeline: how to use this\n"
296
- "- Upload or select **multiple recordings of the same person** (e.g., different days/weeks).\n"
297
- "- The key principle is **within-person change over time** relative to the person’s own baseline.\n"
298
- "- We show **signals** (pauses, pitch, energy), not a clinical label.\n\n"
299
- "**Tip:** select/upload files in **chronological order** (old → new) to make the trend meaningful."
300
  )
301
 
302
 
303
  # =========================================================
304
  # Callbacks
305
  # =========================================================
306
- def analyze_one(audio: Tuple[int, np.ndarray]):
307
- if audio is None:
 
308
  return [], None, None, "### Upload or record audio to start."
309
 
310
- sr, y = audio
311
  feats, art = compute_features(y, sr)
312
- table = features_table(feats)
313
- wf = plot_waveform_with_pauses(art)
314
- pc = plot_pitch(art)
315
- expl = explain_text_single(feats)
316
 
317
- return table, wf, pc, expl
318
 
319
 
320
- def analyze_many(files):
321
- """
322
- Analyze multiple audio files (same person over time).
323
- `files` are Gradio file objects (each has .name) OR objects with a .name path.
324
- """
325
- if not files or len(files) < 2:
326
  rows = [[1, "—", "Upload at least 2 audio files to see a trend.", "", "", "", "", ""]]
327
  return rows, None, "### Upload at least 2 recordings."
328
 
329
  rows = []
330
- pause_series = []
331
- pitch_series = []
332
- rms_series = []
333
 
334
- for idx, f in enumerate(files, start=1):
335
- path = getattr(f, "name", None) or str(f)
336
  name = os.path.basename(path)
337
-
338
- y, sr = librosa.load(path, sr=None, mono=True)
339
  feats, _ = compute_features(y, sr)
340
 
341
  pause_s = feats.pause_total_s if math.isfinite(feats.pause_total_s) else np.nan
@@ -361,11 +344,9 @@ def analyze_many(files):
361
  fig = plt.figure(figsize=(10, 3.4))
362
  ax = fig.add_subplot(111)
363
  x = np.arange(1, len(rows) + 1)
364
-
365
  ax.plot(x, pause_series, marker="o", linewidth=1.2, label="Total pause time (s)")
366
  ax.plot(x, pitch_series, marker="o", linewidth=1.2, label="Median pitch (Hz)")
367
  ax.plot(x, rms_series, marker="o", linewidth=1.2, label="RMS mean")
368
-
369
  ax.set_title("Trend across recordings (same person: baseline → change)")
370
  ax.set_xlabel("Recording # (order)")
371
  ax.set_ylabel("Value (different scales)")
@@ -375,42 +356,66 @@ def analyze_many(files):
375
  return rows, fig, explain_text_timeline()
376
 
377
 
 
 
 
 
 
 
 
 
 
 
 
378
  def analyze_many_bundled(selected_filenames: List[str]):
379
- """
380
- Analyze files that are bundled with the Space (repo root / app directory).
381
- """
382
- if not selected_filenames or len(selected_filenames) < 2:
383
- rows = [[1, "—", "Select at least 2 bundled files.", "", "", "", "", ""]]
384
- return rows, None, "### Select at least 2 bundled recordings."
385
 
386
- class _F:
387
- def __init__(self, name: str):
388
- self.name = name
389
 
390
- files = [_F(os.path.join(APP_DIR, fn)) for fn in selected_filenames]
391
- return analyze_many(files)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
 
393
 
394
  # =========================================================
395
- # UI (polished + readable cards)
396
  # =========================================================
397
  CSS = """
398
  :root{
399
  --bg: #0b0f19;
400
- --text: rgba(255,255,255,0.92);
401
  --shadow: 0 12px 30px rgba(0,0,0,0.35);
402
  }
403
-
404
  .gradio-container{
405
  background:
406
  radial-gradient(1200px 700px at 10% 10%, rgba(124,58,237,0.25), transparent 55%),
407
  radial-gradient(900px 600px at 90% 20%, rgba(34,197,94,0.18), transparent 55%),
408
  radial-gradient(1100px 800px at 40% 100%, rgba(59,130,246,0.15), transparent 60%),
409
  var(--bg) !important;
410
- color: var(--text) !important;
411
  }
412
-
413
- /* Header: force readable (light background + dark text) */
414
  #header{
415
  background: rgba(255,255,255,0.92) !important;
416
  color: #0b0f19 !important;
@@ -420,35 +425,17 @@ CSS = """
420
  box-shadow: var(--shadow);
421
  }
422
  #header *{ color: #0b0f19 !important; }
423
-
424
- #title{
425
- font-size: 28px;
426
- font-weight: 780;
427
- letter-spacing: -0.02em;
428
- margin: 0;
429
- }
430
- #subtitle{
431
- margin-top: 8px;
432
- color: rgba(0,0,0,0.72) !important;
433
- font-size: 14px;
434
- line-height: 1.45;
435
- }
436
  .badge{
437
- display: inline-flex;
438
- align-items: center;
439
- gap: 8px;
440
- padding: 6px 10px;
441
- border-radius: 999px;
442
  border: 1px solid rgba(0,0,0,0.12);
443
  background: rgba(0,0,0,0.04);
444
  color: rgba(0,0,0,0.72) !important;
445
- font-size: 12px;
446
- margin-right: 10px;
447
- margin-bottom: 8px;
448
  }
449
  .badge b{ color: #0b0f19 !important; font-weight: 720; }
450
-
451
- /* Explanation blocks: force readable (light card) */
452
  .card{
453
  background: rgba(255,255,255,0.92) !important;
454
  color: #0b0f19 !important;
@@ -463,6 +450,7 @@ CSS = """
463
 
464
  def build_ui():
465
  bundled = list_bundled_audio()
 
466
 
467
  with gr.Blocks(
468
  css=CSS,
@@ -477,10 +465,10 @@ def build_ui():
477
  <div id="subtitle">
478
  <span class="badge"><b>Goal</b> show measurable speech signals</span>
479
  <span class="badge"><b>No diagnosis</b> not a medical device</span>
480
- <span class="badge"><b>Anti–black box</b> we show signals, not labels</span>
481
  <p style="margin-top:10px">
482
- Use “Timeline” to demonstrate the key principle: <b>within-person change over time</b>.
483
- Your bundled files (e.g. <code>sample_a.mp3</code>, <code>sample_b.mp3</code>) can be selected below.
484
  </p>
485
  </div>
486
  </div>
@@ -491,25 +479,13 @@ def build_ui():
491
  with gr.TabItem("Single recording"):
492
  with gr.Row():
493
  with gr.Column(scale=5):
494
- audio = gr.Audio(label="Audio", sources=["upload", "microphone"], type="numpy")
 
495
  run = gr.Button("Analyze", variant="primary")
496
- with gr.Accordion("What happens technically?", open=False):
497
- gr.Markdown(
498
- """
499
- - Extract **acoustic features** (RMS energy, ZCR), estimate **pitch** with *pyin*,
500
- and detect **pauses** using an adaptive energy threshold.
501
- - Output is **explainable by design**: we show the measured signals.
502
- """
503
- )
504
 
505
  with gr.Column(scale=7):
506
- feats_df = gr.Dataframe(
507
- headers=["Feature", "Value"],
508
- datatype=["str", "str"],
509
- interactive=False,
510
- wrap=True,
511
- label="Measurable features",
512
- )
513
  wf_plot = gr.Plot(label="Waveform + pauses")
514
  pitch_plot = gr.Plot(label="Pitch")
515
  explanation = gr.Markdown("### Upload or record audio to start.", elem_classes=["card"])
@@ -520,30 +496,20 @@ def build_ui():
520
  with gr.Row():
521
  with gr.Column(scale=5):
522
  gr.Markdown("#### Option A — Upload from your computer")
523
- files = gr.Files(
524
- label="Upload multiple audio files (same person)",
525
- file_count="multiple",
526
- file_types=["audio"],
527
- )
528
  run_many = gr.Button("Analyze uploaded timeline", variant="primary")
529
 
530
- gr.Markdown("#### Option B — Select bundled samples (from repo root)")
531
- if bundled:
532
- bundled_select = gr.CheckboxGroup(
533
- choices=bundled,
534
- label="Bundled audio files",
535
- )
536
  run_bundled = gr.Button("Analyze selected bundled samples", variant="secondary")
537
- gr.Markdown("Tip: select/upload in **chronological order** (old → new). MP3 is fine.")
538
- else:
539
- bundled_select = gr.CheckboxGroup(choices=[], label="Bundled audio files")
540
- run_bundled = gr.Button("No bundled audio found", variant="secondary", interactive=False)
541
- gr.Markdown("No bundled audio files were found next to app.py.")
542
 
543
  with gr.Column(scale=7):
544
  timeline_df = gr.Dataframe(
545
  headers=["#", "File", "Duration", "Pauses", "Pause(s)", "Pitch(Hz)", "RMS", "Active %"],
546
- datatype=["number", "str", "str", "number", "str", "str", "str", "str"],
547
  interactive=False,
548
  wrap=True,
549
  label="Per-file overview",
@@ -551,20 +517,27 @@ def build_ui():
551
  timeline_plot = gr.Plot(label="Trend plot")
552
  timeline_expl = gr.Markdown("### Upload or select at least 2 recordings.", elem_classes=["card"])
553
 
554
- run_many.click(analyze_many, inputs=[files], outputs=[timeline_df, timeline_plot, timeline_expl])
555
- run_bundled.click(
556
- analyze_many_bundled,
557
- inputs=[bundled_select],
558
- outputs=[timeline_df, timeline_plot, timeline_expl],
559
- )
 
 
 
 
 
 
 
560
 
561
  with gr.Accordion("Ethics & transparency", open=False):
562
  gr.Markdown(
563
  """
564
  - This demo makes **no clinical claim** and provides **no diagnosis**.
565
  - Output is intended as **observable signals** to support discussion and understanding.
566
- - In care settings, interpretation must always include **context + conversation + clinical judgment**.
567
- """
568
  )
569
 
570
  return demo
@@ -574,6 +547,6 @@ if __name__ == "__main__":
574
  demo = build_ui()
575
  demo.queue(max_size=32)
576
 
577
- # HF Spaces-proof: use platform-provided port (avoids "Cannot find empty port 7860")
578
  port = int(os.environ.get("PORT", os.environ.get("GRADIO_SERVER_PORT", "7860")))
579
  demo.launch(server_name="0.0.0.0", server_port=port)
 
6
  import matplotlib.pyplot as plt
7
 
8
  from dataclasses import dataclass
9
+ from typing import Dict, Any, Tuple, List, Optional
10
 
11
  # =========================================================
12
  # Config
 
15
  APP_DIR = os.path.dirname(os.path.abspath(__file__))
16
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  # =========================================================
19
  # Helpers
20
  # =========================================================
 
33
  return f"{x*100:.1f}%"
34
 
35
 
36
+ def list_bundled_audio() -> List[str]:
37
+ exts = (".mp3", ".wav", ".m4a", ".flac", ".ogg")
38
+ try:
39
+ items = os.listdir(APP_DIR)
40
+ except Exception:
41
+ return []
42
+ files = [fn for fn in items if fn.lower().endswith(exts)]
43
+ files.sort()
44
+ return files
45
+
46
+
47
+ def load_audio_file(path: str) -> Tuple[np.ndarray, int]:
48
+ """
49
+ Robust loader for both uploads and bundled files.
50
+ Returns mono float32 waveform and sample rate.
51
+ """
52
+ y, sr = librosa.load(path, sr=None, mono=True)
53
+ if y is None or len(y) == 0:
54
+ return np.array([], dtype=np.float32), int(sr) if sr else TARGET_SR
55
+ y = y.astype(np.float32)
56
+ return y, int(sr)
57
+
58
+
59
  # =========================================================
60
  # Features
61
  # =========================================================
 
74
 
75
 
76
  def compute_features(y: np.ndarray, sr: int) -> Tuple[Features, Dict[str, Any]]:
 
 
 
 
77
  if y is None or len(y) == 0:
78
  f = Features(
79
  duration_s=float("nan"),
 
87
  pause_total_s=0.0,
88
  active_ratio=float("nan"),
89
  )
90
+ return f, {"y": np.array([]), "sr": sr, "hop": 160, "pauses": [], "pitch": np.array([]), "times": np.array([])}
91
 
92
  # Resample to stable SR
93
  if sr != TARGET_SR:
 
96
  else:
97
  y = y.astype(np.float32)
98
 
99
+ # Normalize [-1, 1]
100
  mx = float(np.max(np.abs(y))) + 1e-9
101
  y = y / mx
102
 
103
  duration = float(len(y) / sr)
104
 
105
+ hop = 160
106
+ frame = 400
107
 
108
  rms = librosa.feature.rms(y=y, frame_length=frame, hop_length=hop)[0]
109
  zcr = librosa.feature.zero_crossing_rate(y, frame_length=frame, hop_length=hop)[0]
 
112
  rms_std = float(np.std(rms)) if rms.size else float("nan")
113
  zcr_mean = float(np.mean(zcr)) if zcr.size else float("nan")
114
 
115
+ # Pitch via pyin
116
  try:
117
  f0, _, _ = librosa.pyin(
118
  y,
 
145
  pitch_median = float("nan")
146
  pitch_iqr = float("nan")
147
 
148
+ # Pause detection
149
  if rms.size:
150
  thr = float(np.percentile(rms, 20)) * 0.8
151
  silent = rms < thr
 
 
152
  min_pause_frames = int(0.2 / (hop / sr))
153
 
154
  pauses = []
 
170
  pause_total_s = float(sum((e - s) * (hop / sr) for s, e in pauses))
171
  active_ratio = float(1.0 - np.mean(silent))
172
  else:
 
173
  pauses = []
174
  n_pauses = 0
175
  pause_total_s = 0.0
 
192
  "y": y,
193
  "sr": sr,
194
  "hop": hop,
 
195
  "rms": rms,
196
  "zcr": zcr,
 
197
  "pitch": pitch,
198
+ "times": times,
199
  "pauses": pauses,
 
200
  }
201
  return feats, artifacts
202
 
 
217
  t = np.arange(len(y)) / sr
218
  ax.plot(t, y, linewidth=0.8)
219
  for (s, e) in pauses:
220
+ ax.axvspan(s * (hop / sr), e * (hop / sr), alpha=0.2)
 
 
221
  ax.set_title("Waveform (with detected pauses)")
222
  ax.set_xlabel("Time (s)")
223
  ax.set_ylabel("Amplitude")
 
249
  return fig
250
 
251
 
 
 
 
252
  def features_table(feats: Features) -> List[List[str]]:
253
  def f3(x):
254
  return "—" if (x is None or not math.isfinite(x)) else f"{float(x):.3f}"
 
281
  "This is an **explainability demo**: it shows **measurable speech signals** (not *why* they change).\n\n"
282
  + "\n".join(bullets)
283
  + "\n\n"
284
+ "**Important:** this is **not a diagnosis** and **not a medical device**."
 
285
  )
286
 
287
 
288
  def explain_text_timeline() -> str:
289
  return (
290
  "### Timeline: how to use this\n"
291
+ "- Use **multiple recordings of the same person** (e.g., days/weeks).\n"
292
+ "- The key principle is **within-person change over time** relative to baseline.\n"
293
+ "- We show **signals** (pauses, pitch, energy), not a clinical label.\n"
 
294
  )
295
 
296
 
297
  # =========================================================
298
  # Callbacks
299
  # =========================================================
300
+ def analyze_one(audio_path: Optional[str]):
301
+ # audio_path comes from gr.Audio(type="filepath")
302
+ if not audio_path:
303
  return [], None, None, "### Upload or record audio to start."
304
 
305
+ y, sr = load_audio_file(audio_path)
306
  feats, art = compute_features(y, sr)
 
 
 
 
307
 
308
+ return features_table(feats), plot_waveform_with_pauses(art), plot_pitch(art), explain_text_single(feats)
309
 
310
 
311
+ def analyze_many_filepaths(paths: List[str]):
312
+ if not paths or len(paths) < 2:
 
 
 
 
313
  rows = [[1, "—", "Upload at least 2 audio files to see a trend.", "", "", "", "", ""]]
314
  return rows, None, "### Upload at least 2 recordings."
315
 
316
  rows = []
317
+ pause_series, pitch_series, rms_series = [], [], []
 
 
318
 
319
+ for idx, path in enumerate(paths, start=1):
 
320
  name = os.path.basename(path)
321
+ y, sr = load_audio_file(path)
 
322
  feats, _ = compute_features(y, sr)
323
 
324
  pause_s = feats.pause_total_s if math.isfinite(feats.pause_total_s) else np.nan
 
344
  fig = plt.figure(figsize=(10, 3.4))
345
  ax = fig.add_subplot(111)
346
  x = np.arange(1, len(rows) + 1)
 
347
  ax.plot(x, pause_series, marker="o", linewidth=1.2, label="Total pause time (s)")
348
  ax.plot(x, pitch_series, marker="o", linewidth=1.2, label="Median pitch (Hz)")
349
  ax.plot(x, rms_series, marker="o", linewidth=1.2, label="RMS mean")
 
350
  ax.set_title("Trend across recordings (same person: baseline → change)")
351
  ax.set_xlabel("Recording # (order)")
352
  ax.set_ylabel("Value (different scales)")
 
356
  return rows, fig, explain_text_timeline()
357
 
358
 
359
+ def analyze_many_uploaded(files):
360
+ # gr.Files gives file objects; map to filepaths
361
+ if not files:
362
+ return analyze_many_filepaths([])
363
+ paths = []
364
+ for f in files:
365
+ p = getattr(f, "name", None) or str(f)
366
+ paths.append(p)
367
+ return analyze_many_filepaths(paths)
368
+
369
+
370
  def analyze_many_bundled(selected_filenames: List[str]):
371
+ if not selected_filenames:
372
+ return analyze_many_filepaths([])
373
+ paths = [os.path.join(APP_DIR, fn) for fn in selected_filenames]
374
+ return analyze_many_filepaths(paths)
 
 
375
 
 
 
 
376
 
377
+ def refresh_bundled_choices():
378
+ bundled = list_bundled_audio()
379
+ diag = diagnostics_text(bundled)
380
+ return gr.CheckboxGroup(choices=bundled, value=[]), diag
381
+
382
+
383
+ def diagnostics_text(bundled: List[str]) -> str:
384
+ lines = []
385
+ lines.append(f"**APP_DIR:** `{APP_DIR}`")
386
+ lines.append(f"**CWD:** `{os.getcwd()}`")
387
+ lines.append(f"**Found bundled audio files:** {len(bundled)}")
388
+ if bundled:
389
+ for fn in bundled:
390
+ full = os.path.join(APP_DIR, fn)
391
+ try:
392
+ size = os.path.getsize(full)
393
+ lines.append(f"- `{fn}` ({size} bytes)")
394
+ except Exception:
395
+ lines.append(f"- `{fn}` (size unknown)")
396
+ else:
397
+ lines.append("- *(none found)*")
398
+ lines.append("")
399
+ lines.append("**Microphone note:** recording can be blocked by browser/iframe policies.")
400
+ lines.append("Try opening the Space in a new tab and allow microphone permissions.")
401
+ return "\n".join(lines)
402
 
403
 
404
  # =========================================================
405
+ # UI (readable cards + tabs + diagnostics)
406
  # =========================================================
407
  CSS = """
408
  :root{
409
  --bg: #0b0f19;
 
410
  --shadow: 0 12px 30px rgba(0,0,0,0.35);
411
  }
 
412
  .gradio-container{
413
  background:
414
  radial-gradient(1200px 700px at 10% 10%, rgba(124,58,237,0.25), transparent 55%),
415
  radial-gradient(900px 600px at 90% 20%, rgba(34,197,94,0.18), transparent 55%),
416
  radial-gradient(1100px 800px at 40% 100%, rgba(59,130,246,0.15), transparent 60%),
417
  var(--bg) !important;
 
418
  }
 
 
419
  #header{
420
  background: rgba(255,255,255,0.92) !important;
421
  color: #0b0f19 !important;
 
425
  box-shadow: var(--shadow);
426
  }
427
  #header *{ color: #0b0f19 !important; }
428
+ #title{ font-size: 28px; font-weight: 780; margin: 0; letter-spacing: -0.02em; }
429
+ #subtitle{ margin-top: 8px; color: rgba(0,0,0,0.72) !important; font-size: 14px; line-height: 1.45; }
 
 
 
 
 
 
 
 
 
 
 
430
  .badge{
431
+ display: inline-flex; align-items: center; gap: 8px;
432
+ padding: 6px 10px; border-radius: 999px;
 
 
 
433
  border: 1px solid rgba(0,0,0,0.12);
434
  background: rgba(0,0,0,0.04);
435
  color: rgba(0,0,0,0.72) !important;
436
+ font-size: 12px; margin-right: 10px; margin-bottom: 8px;
 
 
437
  }
438
  .badge b{ color: #0b0f19 !important; font-weight: 720; }
 
 
439
  .card{
440
  background: rgba(255,255,255,0.92) !important;
441
  color: #0b0f19 !important;
 
450
 
451
  def build_ui():
452
  bundled = list_bundled_audio()
453
+ diag0 = diagnostics_text(bundled)
454
 
455
  with gr.Blocks(
456
  css=CSS,
 
465
  <div id="subtitle">
466
  <span class="badge"><b>Goal</b> show measurable speech signals</span>
467
  <span class="badge"><b>No diagnosis</b> not a medical device</span>
468
+ <span class="badge"><b>Anti–black box</b> show signals, not labels</span>
469
  <p style="margin-top:10px">
470
+ If you committed <code>sample_a.mp3</code> and <code>sample_b.mp3</code> to the repo root,
471
+ they should appear under “Bundled samples”. Use “Diagnostics” to verify what the container sees.
472
  </p>
473
  </div>
474
  </div>
 
479
  with gr.TabItem("Single recording"):
480
  with gr.Row():
481
  with gr.Column(scale=5):
482
+ # filepath is more robust on Spaces for uploads + mic
483
+ audio = gr.Audio(label="Audio", sources=["upload", "microphone"], type="filepath")
484
  run = gr.Button("Analyze", variant="primary")
485
+ gr.Markdown("Tip: if microphone doesn’t work, try upload first. Then check Diagnostics.", elem_classes=["card"])
 
 
 
 
 
 
 
486
 
487
  with gr.Column(scale=7):
488
+ feats_df = gr.Dataframe(headers=["Feature", "Value"], interactive=False, wrap=True, label="Measurable features")
 
 
 
 
 
 
489
  wf_plot = gr.Plot(label="Waveform + pauses")
490
  pitch_plot = gr.Plot(label="Pitch")
491
  explanation = gr.Markdown("### Upload or record audio to start.", elem_classes=["card"])
 
496
  with gr.Row():
497
  with gr.Column(scale=5):
498
  gr.Markdown("#### Option A — Upload from your computer")
499
+ files = gr.Files(label="Upload multiple audio files (same person)", file_count="multiple", file_types=["audio"])
 
 
 
 
500
  run_many = gr.Button("Analyze uploaded timeline", variant="primary")
501
 
502
+ gr.Markdown("#### Option B — Bundled samples (from repo root)")
503
+ bundled_select = gr.CheckboxGroup(choices=bundled, label="Bundled audio files")
504
+ with gr.Row():
505
+ refresh_btn = gr.Button("Refresh bundled list", variant="secondary")
 
 
506
  run_bundled = gr.Button("Analyze selected bundled samples", variant="secondary")
507
+
508
+ gr.Markdown("Select/upload at least **2** recordings. MP3 is fine.", elem_classes=["card"])
 
 
 
509
 
510
  with gr.Column(scale=7):
511
  timeline_df = gr.Dataframe(
512
  headers=["#", "File", "Duration", "Pauses", "Pause(s)", "Pitch(Hz)", "RMS", "Active %"],
 
513
  interactive=False,
514
  wrap=True,
515
  label="Per-file overview",
 
517
  timeline_plot = gr.Plot(label="Trend plot")
518
  timeline_expl = gr.Markdown("### Upload or select at least 2 recordings.", elem_classes=["card"])
519
 
520
+ run_many.click(analyze_many_uploaded, inputs=[files], outputs=[timeline_df, timeline_plot, timeline_expl])
521
+ run_bundled.click(analyze_many_bundled, inputs=[bundled_select], outputs=[timeline_df, timeline_plot, timeline_expl])
522
+ refresh_btn.click(refresh_bundled_choices, inputs=None, outputs=[bundled_select, gr.Markdown(value=diag0)])
523
+
524
+ with gr.TabItem("Diagnostics"):
525
+ diag = gr.Markdown(diag0, elem_classes=["card"])
526
+ diag_refresh = gr.Button("Refresh diagnostics", variant="secondary")
527
+
528
+ def _refresh_diag():
529
+ b = list_bundled_audio()
530
+ return diagnostics_text(b)
531
+
532
+ diag_refresh.click(_refresh_diag, inputs=None, outputs=[diag])
533
 
534
  with gr.Accordion("Ethics & transparency", open=False):
535
  gr.Markdown(
536
  """
537
  - This demo makes **no clinical claim** and provides **no diagnosis**.
538
  - Output is intended as **observable signals** to support discussion and understanding.
539
+ """,
540
+ elem_classes=["card"],
541
  )
542
 
543
  return demo
 
547
  demo = build_ui()
548
  demo.queue(max_size=32)
549
 
550
+ # HF Spaces-proof port binding
551
  port = int(os.environ.get("PORT", os.environ.get("GRADIO_SERVER_PORT", "7860")))
552
  demo.launch(server_name="0.0.0.0", server_port=port)