hetchyy Claude Opus 4.6 commited on
Commit
881307e
·
1 Parent(s): 0d6804f

Add Dev tab for browsing usage logs and disable verbose debug flags

Browse files

- Add in-app Dev tab (local only, hidden on HF Space) to browse
hetchyy/quran-aligner-logs dataset with filtering, sorting, and
segment rendering using the same pipeline as the main app
- Disable ANCHOR_DEBUG and PHONEME_ALIGNMENT_DEBUG in production config
- Suppress HF Hub download progress bars on cold start

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (5) hide show
  1. app.py +4 -0
  2. config.py +3 -2
  3. src/ui/dev_tools.py +512 -0
  4. src/ui/event_wiring.py +47 -0
  5. src/ui/interface.py +49 -30
app.py CHANGED
@@ -1,7 +1,11 @@
1
  """Quran Aligner — Automatic Quran recitation segmentation and alignment."""
 
2
  import sys
3
  from pathlib import Path
4
 
 
 
 
5
  # Add paths for imports BEFORE importing anything else
6
  _app_path = Path(__file__).parent.resolve()
7
  sys.path.insert(0, str(_app_path))
 
1
  """Quran Aligner — Automatic Quran recitation segmentation and alignment."""
2
+ import os
3
  import sys
4
  from pathlib import Path
5
 
6
+ # Suppress HF model download progress bars (hundreds of lines on cold start)
7
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
8
+
9
  # Add paths for imports BEFORE importing anything else
10
  _app_path = Path(__file__).parent.resolve()
11
  sys.path.insert(0, str(_app_path))
config.py CHANGED
@@ -6,6 +6,7 @@ from pathlib import Path
6
 
7
  # HF Spaces detection
8
  IS_HF_SPACE = os.environ.get("SPACE_ID") is not None
 
9
 
10
  # Get project root directory
11
  PROJECT_ROOT = Path(__file__).parent.absolute()
@@ -145,8 +146,8 @@ MAX_EDIT_DISTANCE_RELAXED = 0.4 # Relaxed threshold for retry tier 2
145
  MAX_CONSECUTIVE_FAILURES = 2 # Re-anchor within surah after this many DP failures
146
 
147
  # Debug output
148
- ANCHOR_DEBUG = True # Show detailed n-gram voting info (votes, top candidates)
149
- PHONEME_ALIGNMENT_DEBUG = True # Show detailed alignment info (R, P, edit costs)
150
  PHONEME_ALIGNMENT_PROFILING = True # Track and log timing breakdown (DP, window setup, etc.)
151
 
152
  # =============================================================================
 
6
 
7
  # HF Spaces detection
8
  IS_HF_SPACE = os.environ.get("SPACE_ID") is not None
9
+ DEV_TAB_VISIBLE = not IS_HF_SPACE
10
 
11
  # Get project root directory
12
  PROJECT_ROOT = Path(__file__).parent.absolute()
 
146
  MAX_CONSECUTIVE_FAILURES = 2 # Re-anchor within surah after this many DP failures
147
 
148
  # Debug output
149
+ ANCHOR_DEBUG = False # Show detailed n-gram voting info (votes, top candidates)
150
+ PHONEME_ALIGNMENT_DEBUG = False # Show detailed alignment info (R, P, edit costs)
151
  PHONEME_ALIGNMENT_PROFILING = True # Track and log timing breakdown (DP, window setup, etc.)
152
 
153
  # =============================================================================
src/ui/dev_tools.py ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dev tab — browse and inspect usage logs from HF dataset (local only)."""
2
+
3
+ import json
4
+ import os
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ import gradio as gr
10
+ import numpy as np
11
+
12
+ from config import SEGMENT_AUDIO_DIR, SURAH_INFO_PATH
13
+
14
+
15
+ # ── Surah names cache ──────────────────────────────────────────────────
16
+
17
+ _surah_names: dict[int, str] | None = None
18
+
19
+
20
+ def _load_surah_names() -> dict[int, str]:
21
+ global _surah_names
22
+ if _surah_names is not None:
23
+ return _surah_names
24
+ if not SURAH_INFO_PATH.exists():
25
+ _surah_names = {}
26
+ return _surah_names
27
+ with open(SURAH_INFO_PATH) as f:
28
+ data = json.load(f)
29
+ _surah_names = {int(k): v["name_en"] for k, v in data.items()}
30
+ return _surah_names
31
+
32
+
33
+ # ── HF token loading (same pattern as scripts/analyze_logs.py) ─────────
34
+
35
+ def _load_token() -> str | None:
36
+ token = os.environ.get("HF_TOKEN")
37
+ if token:
38
+ return token
39
+ env_path = Path(__file__).parent.parent.parent / ".env"
40
+ if env_path.exists():
41
+ for line in env_path.read_text().splitlines():
42
+ line = line.strip()
43
+ if line.startswith("HF_TOKEN="):
44
+ return line.split("=", 1)[1]
45
+ return None
46
+
47
+
48
+ # ── Dataset helpers ────────────────────────────────────────────────────
49
+
50
+ def _has_valid_segments(segments_str) -> bool:
51
+ if not segments_str:
52
+ return False
53
+ try:
54
+ runs = json.loads(segments_str)
55
+ if isinstance(runs, list) and runs:
56
+ return any(isinstance(run, dict) and run.get("segments") for run in runs)
57
+ except (json.JSONDecodeError, TypeError):
58
+ pass
59
+ return False
60
+
61
+
62
+ def _fmt_duration(seconds) -> str:
63
+ if seconds is None:
64
+ return "N/A"
65
+ m, s = divmod(int(seconds), 60)
66
+ h, m = divmod(m, 60)
67
+ if h > 0:
68
+ return f"{h}h {m}m"
69
+ return f"{m}m {int(s)}s"
70
+
71
+
72
+ def _fmt_pct(val) -> str:
73
+ if val is None:
74
+ return "N/A"
75
+ return f"{val * 100:.1f}%"
76
+
77
+
78
+ def _fmt_time(val) -> str:
79
+ if val is None:
80
+ return "N/A"
81
+ return f"{val:.1f}s"
82
+
83
+
84
+ # ── UI builder ─────────────────────────────────────────────────────────
85
+
86
+ def build_dev_tab_ui(c):
87
+ """Build the Dev tab UI components and attach them to the namespace."""
88
+ with gr.Row():
89
+ c.dev_load_btn = gr.Button("Load Logs", variant="primary", size="sm")
90
+ c.dev_refresh_btn = gr.Button("Refresh", size="sm")
91
+ c.dev_status = gr.Markdown("Click **Load Logs** to stream metadata from HF dataset.")
92
+
93
+ with gr.Row():
94
+ c.dev_filter_device = gr.Dropdown(
95
+ choices=["All", "GPU", "CPU"], value="All", label="Device", scale=1,
96
+ )
97
+ c.dev_filter_model = gr.Dropdown(
98
+ choices=["All", "Base", "Large"], value="All", label="Model", scale=1,
99
+ )
100
+ c.dev_filter_status = gr.Dropdown(
101
+ choices=["All", "All Passed", "Has Failures"], value="All", label="Status", scale=1,
102
+ )
103
+ c.dev_sort = gr.Dropdown(
104
+ choices=["Newest", "Duration", "Failures"], value="Newest", label="Sort", scale=1,
105
+ )
106
+
107
+ c.dev_table = gr.Dataframe(
108
+ headers=["#", "Time", "Surah", "Duration", "Segs", "Model", "Device",
109
+ "Passed", "Failed", "Conf", "T1", "T2"],
110
+ datatype=["number", "str", "str", "str", "number", "str", "str",
111
+ "number", "number", "str", "number", "number"],
112
+ interactive=False,
113
+ label="Usage Logs",
114
+ wrap=True,
115
+ )
116
+
117
+ c.dev_detail_html = gr.HTML(value="", label="Log Detail")
118
+
119
+ # State
120
+ c.dev_all_rows = gr.State(value=[])
121
+ c.dev_filtered_indices = gr.State(value=[])
122
+
123
+
124
+ # ── Row extraction ─────────────────────────────────────────────────────
125
+
126
+ def _row_to_dict(row) -> dict:
127
+ """Extract the fields we care about from a dataset row."""
128
+ return {
129
+ "audio_id": row.get("audio_id", ""),
130
+ "timestamp": row.get("timestamp", ""),
131
+ "surah": row.get("surah"),
132
+ "audio_duration_s": row.get("audio_duration_s"),
133
+ "num_segments": row.get("num_segments"),
134
+ "asr_model": row.get("asr_model", ""),
135
+ "device": row.get("device", ""),
136
+ "segments_passed": row.get("segments_passed"),
137
+ "segments_failed": row.get("segments_failed"),
138
+ "mean_confidence": row.get("mean_confidence"),
139
+ "tier1_retries": row.get("tier1_retries", 0) or 0,
140
+ "tier1_passed": row.get("tier1_passed", 0) or 0,
141
+ "tier2_retries": row.get("tier2_retries", 0) or 0,
142
+ "tier2_passed": row.get("tier2_passed", 0) or 0,
143
+ "reanchors": row.get("reanchors", 0) or 0,
144
+ "special_merges": row.get("special_merges", 0) or 0,
145
+ "total_time": row.get("total_time"),
146
+ "vad_queue_time": row.get("vad_queue_time"),
147
+ "vad_gpu_time": row.get("vad_gpu_time"),
148
+ "asr_gpu_time": row.get("asr_gpu_time"),
149
+ "dp_total_time": row.get("dp_total_time"),
150
+ "min_silence_ms": row.get("min_silence_ms"),
151
+ "min_speech_ms": row.get("min_speech_ms"),
152
+ "pad_ms": row.get("pad_ms"),
153
+ "segments": row.get("segments"),
154
+ "resegmented": row.get("resegmented"),
155
+ "retranscribed": row.get("retranscribed"),
156
+ "error": row.get("error"),
157
+ }
158
+
159
+
160
+ # ── Table building ─────────────────────────────────────────────────────
161
+
162
+ def _build_table_row(row_dict, index, surah_names):
163
+ """Build a single table row list from a row dict."""
164
+ ts = row_dict.get("timestamp", "")
165
+ try:
166
+ dt = datetime.fromisoformat(ts)
167
+ time_display = dt.strftime("%m-%d %H:%M")
168
+ except (ValueError, TypeError):
169
+ time_display = str(ts)[:16] if ts else "N/A"
170
+
171
+ surah = row_dict.get("surah")
172
+ name = surah_names.get(surah, "") if surah else ""
173
+ surah_display = f"{surah} {name}" if name else str(surah or "?")
174
+
175
+ return [
176
+ index + 1,
177
+ time_display,
178
+ surah_display,
179
+ _fmt_duration(row_dict.get("audio_duration_s")),
180
+ row_dict.get("num_segments") or 0,
181
+ row_dict.get("asr_model", "?"),
182
+ row_dict.get("device", "?"),
183
+ row_dict.get("segments_passed") or 0,
184
+ row_dict.get("segments_failed") or 0,
185
+ _fmt_pct(row_dict.get("mean_confidence")),
186
+ row_dict.get("tier1_retries", 0) or 0,
187
+ row_dict.get("tier2_retries", 0) or 0,
188
+ ]
189
+
190
+
191
+ def _build_table(rows, indices, surah_names):
192
+ """Build table data from rows and their display indices."""
193
+ return [_build_table_row(rows[i], display_idx, surah_names)
194
+ for display_idx, i in enumerate(indices)]
195
+
196
+
197
+ # ── Handlers ───────────────────────────────────────────────────────────
198
+
199
+ def load_logs_handler():
200
+ """Stream dataset (no audio) and return rows + table."""
201
+ token = _load_token()
202
+ if not token:
203
+ gr.Warning("HF_TOKEN not found in .env or environment.")
204
+ return [], [], "HF_TOKEN not found.", gr.update()
205
+
206
+ try:
207
+ from datasets import load_dataset
208
+ except ImportError:
209
+ gr.Warning("'datasets' package not installed.")
210
+ return [], [], "'datasets' package not installed.", gr.update()
211
+
212
+ surah_names = _load_surah_names()
213
+
214
+ try:
215
+ ds = load_dataset("hetchyy/quran-aligner-logs", token=token,
216
+ split="train", streaming=True)
217
+ ds = ds.remove_columns("audio")
218
+ except Exception as e:
219
+ gr.Warning(f"Failed to load dataset: {e}")
220
+ return [], [], f"Error: {e}", gr.update()
221
+
222
+ rows = []
223
+ total = 0
224
+ for row in ds:
225
+ total += 1
226
+ if _has_valid_segments(row.get("segments")):
227
+ rows.append(_row_to_dict(row))
228
+
229
+ # Sort newest first
230
+ rows.sort(key=lambda r: r.get("timestamp") or "", reverse=True)
231
+
232
+ indices = list(range(len(rows)))
233
+ table_data = _build_table(rows, indices, surah_names)
234
+ status = f"Loaded {len(rows)} rows with segments (out of {total} total)."
235
+
236
+ return rows, indices, status, table_data
237
+
238
+
239
+ def filter_and_sort_handler(all_rows, device, model, status_filter, sort_by):
240
+ """Filter and sort cached rows, return new table + index mapping."""
241
+ if not all_rows:
242
+ return [], gr.update()
243
+
244
+ surah_names = _load_surah_names()
245
+ indices = []
246
+
247
+ for i, row in enumerate(all_rows):
248
+ # Device filter
249
+ if device != "All":
250
+ row_device = (row.get("device") or "").lower()
251
+ if device == "GPU" and row_device not in ("cuda", "gpu"):
252
+ continue
253
+ if device == "CPU" and row_device not in ("cpu",):
254
+ continue
255
+
256
+ # Model filter
257
+ if model != "All":
258
+ row_model = row.get("asr_model", "")
259
+ if model == "Base" and row_model != "Base":
260
+ continue
261
+ if model == "Large" and row_model != "Large":
262
+ continue
263
+
264
+ # Status filter
265
+ if status_filter == "All Passed":
266
+ if (row.get("segments_failed") or 0) > 0:
267
+ continue
268
+ elif status_filter == "Has Failures":
269
+ if (row.get("segments_failed") or 0) == 0:
270
+ continue
271
+
272
+ indices.append(i)
273
+
274
+ # Sort
275
+ if sort_by == "Duration":
276
+ indices.sort(key=lambda i: all_rows[i].get("audio_duration_s") or 0, reverse=True)
277
+ elif sort_by == "Failures":
278
+ indices.sort(key=lambda i: all_rows[i].get("segments_failed") or 0, reverse=True)
279
+ # else "Newest" — already sorted by timestamp from load
280
+
281
+ table_data = _build_table(all_rows, indices, surah_names)
282
+ return indices, table_data
283
+
284
+
285
+ def select_log_row_handler(all_rows, filtered_indices, evt: gr.SelectData):
286
+ """When a table row is clicked, download audio and render segments."""
287
+ if not all_rows or not filtered_indices:
288
+ return ""
289
+
290
+ display_idx = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index
291
+ if display_idx < 0 or display_idx >= len(filtered_indices):
292
+ return ""
293
+
294
+ row_idx = filtered_indices[display_idx]
295
+ row = all_rows[row_idx]
296
+
297
+ audio_id = row.get("audio_id", "")
298
+ surah_names = _load_surah_names()
299
+
300
+ # Build summary HTML
301
+ summary_html = _build_summary_html(row, surah_names)
302
+
303
+ # Try to reconstruct and render segments
304
+ segments_html = _build_segments_from_log(row, audio_id)
305
+
306
+ return summary_html + segments_html
307
+
308
+
309
+ # ── Summary HTML builder ───────────────────────────────────────────────
310
+
311
+ def _build_summary_html(row, surah_names) -> str:
312
+ """Build the 4-section summary HTML for a log row."""
313
+ surah = row.get("surah")
314
+ name = surah_names.get(surah, "") if surah else ""
315
+ surah_display = f"{surah} ({name})" if name else str(surah or "N/A")
316
+
317
+ sections = []
318
+
319
+ # 1. Summary
320
+ sections.append(f"""
321
+ <div style="margin-bottom: 12px; padding: 10px; background: #f8f9fa; border-radius: 6px; border-left: 3px solid #4a9eff;">
322
+ <strong>Summary</strong><br>
323
+ <span>Surah: {surah_display}</span> &nbsp;|&nbsp;
324
+ <span>Duration: {_fmt_duration(row.get('audio_duration_s'))}</span> &nbsp;|&nbsp;
325
+ <span>Segments: {row.get('num_segments', 'N/A')}</span> &nbsp;|&nbsp;
326
+ <span>Audio ID: <code style="font-size: 0.85em;">{row.get('audio_id', 'N/A')}</code></span>
327
+ </div>
328
+ """)
329
+
330
+ # 2. Settings
331
+ sections.append(f"""
332
+ <div style="margin-bottom: 12px; padding: 10px; background: #f8f9fa; border-radius: 6px; border-left: 3px solid #f0ad4e;">
333
+ <strong>Settings</strong><br>
334
+ <span>Min Silence: {row.get('min_silence_ms', 'N/A')} ms</span> &nbsp;|&nbsp;
335
+ <span>Min Speech: {row.get('min_speech_ms', 'N/A')} ms</span> &nbsp;|&nbsp;
336
+ <span>Pad: {row.get('pad_ms', 'N/A')} ms</span> &nbsp;|&nbsp;
337
+ <span>Model: {row.get('asr_model', 'N/A')}</span> &nbsp;|&nbsp;
338
+ <span>Device: {row.get('device', 'N/A')}</span>
339
+ </div>
340
+ """)
341
+
342
+ # 3. Profiling
343
+ sections.append(f"""
344
+ <div style="margin-bottom: 12px; padding: 10px; background: #f8f9fa; border-radius: 6px; border-left: 3px solid #5cb85c;">
345
+ <strong>Profiling</strong><br>
346
+ <span>Total: {_fmt_time(row.get('total_time'))}</span> &nbsp;|&nbsp;
347
+ <span>VAD Queue: {_fmt_time(row.get('vad_queue_time'))}</span> &nbsp;|&nbsp;
348
+ <span>VAD GPU: {_fmt_time(row.get('vad_gpu_time'))}</span> &nbsp;|&nbsp;
349
+ <span>ASR GPU: {_fmt_time(row.get('asr_gpu_time'))}</span> &nbsp;|&nbsp;
350
+ <span>DP: {_fmt_time(row.get('dp_total_time'))}</span>
351
+ </div>
352
+ """)
353
+
354
+ # 4. Quality
355
+ passed = row.get("segments_passed") or 0
356
+ failed = row.get("segments_failed") or 0
357
+ total_segs = passed + failed
358
+ pass_rate = f"{passed}/{total_segs}" if total_segs else "N/A"
359
+ t1 = f"{row.get('tier1_passed', 0) or 0}/{row.get('tier1_retries', 0) or 0}"
360
+ t2 = f"{row.get('tier2_passed', 0) or 0}/{row.get('tier2_retries', 0) or 0}"
361
+
362
+ flags = []
363
+ if row.get("resegmented"):
364
+ flags.append("Resegmented")
365
+ if row.get("retranscribed"):
366
+ flags.append("Retranscribed")
367
+ if row.get("error"):
368
+ flags.append(f"Error: {str(row['error'])[:60]}")
369
+ flags_html = f" &nbsp;|&nbsp; <span>Flags: {', '.join(flags)}</span>" if flags else ""
370
+
371
+ sections.append(f"""
372
+ <div style="margin-bottom: 12px; padding: 10px; background: #f8f9fa; border-radius: 6px; border-left: 3px solid #d9534f;">
373
+ <strong>Quality</strong><br>
374
+ <span>Passed: {pass_rate}</span> &nbsp;|&nbsp;
375
+ <span>Confidence: {_fmt_pct(row.get('mean_confidence'))}</span> &nbsp;|&nbsp;
376
+ <span>T1 retries: {t1}</span> &nbsp;|&nbsp;
377
+ <span>T2 retries: {t2}</span> &nbsp;|&nbsp;
378
+ <span>Reanchors: {row.get('reanchors', 0) or 0}</span>
379
+ {flags_html}
380
+ </div>
381
+ """)
382
+
383
+ return "\n".join(sections)
384
+
385
+
386
+ # ── Segment reconstruction from log ───────────────────────────────────
387
+
388
+ def _build_segments_from_log(row, audio_id) -> str:
389
+ """Build segment cards from the log's segments JSON, downloading audio on demand."""
390
+ segments_str = row.get("segments")
391
+ if not segments_str:
392
+ return '<div style="color: #999; padding: 20px;">No segment data in this log row.</div>'
393
+
394
+ try:
395
+ runs = json.loads(segments_str)
396
+ except (json.JSONDecodeError, TypeError):
397
+ return '<div style="color: #999; padding: 20px;">Could not parse segments JSON.</div>'
398
+
399
+ if not runs or not isinstance(runs, list):
400
+ return '<div style="color: #999; padding: 20px;">Empty segment runs.</div>'
401
+
402
+ # Use the last run (most recent alignment pass)
403
+ last_run = runs[-1]
404
+ seg_list = last_run.get("segments", [])
405
+ if not seg_list:
406
+ return '<div style="color: #999; padding: 20px;">No segments in last run.</div>'
407
+
408
+ # Try to download audio for this specific row
409
+ audio_int16 = None
410
+ sample_rate = 16000
411
+ segment_dir = None
412
+
413
+ try:
414
+ audio_int16, sample_rate, segment_dir = _download_audio_for_row(audio_id)
415
+ except Exception as e:
416
+ print(f"[dev_tools] Audio download failed: {e}")
417
+
418
+ # Build SegmentInfo objects and render
419
+ from src.core.segment_types import SegmentInfo
420
+ from src.alignment.special_segments import ALL_SPECIAL_REFS, SPECIAL_TEXT
421
+ from src.ui.segments import render_segments, get_text_with_markers, check_undersegmented
422
+
423
+ segments = []
424
+ for seg_data in seg_list:
425
+ ref = seg_data.get("ref", "")
426
+ confidence = seg_data.get("confidence", 0.0) or 0.0
427
+ start = seg_data.get("start", 0.0) or 0.0
428
+ end = seg_data.get("end", 0.0) or 0.0
429
+ error = seg_data.get("error")
430
+ special_type = seg_data.get("special_type", "")
431
+ duration = end - start
432
+
433
+ # Reconstruct matched_text
434
+ matched_text = ""
435
+ if ref in ALL_SPECIAL_REFS:
436
+ # For known specials, use the constant text
437
+ if ref in SPECIAL_TEXT:
438
+ matched_text = SPECIAL_TEXT[ref]
439
+ elif ref == "Isti'adha+Basmala":
440
+ matched_text = SPECIAL_TEXT["Isti'adha"] + " \u06dd " + SPECIAL_TEXT["Basmala"]
441
+ elif ref:
442
+ matched_text = get_text_with_markers(ref) or ""
443
+
444
+ # Check for undersegmentation
445
+ underseg = False
446
+ if ref and ref not in ALL_SPECIAL_REFS:
447
+ underseg = check_undersegmented(ref, duration)
448
+
449
+ # Check for missing words
450
+ has_missing = seg_data.get("missing_words", False) or False
451
+
452
+ seg_info = SegmentInfo(
453
+ start_time=start,
454
+ end_time=end,
455
+ transcribed_text="",
456
+ matched_text=matched_text,
457
+ matched_ref=ref,
458
+ match_score=confidence,
459
+ error=error,
460
+ has_missing_words=has_missing,
461
+ potentially_undersegmented=underseg,
462
+ )
463
+ segments.append(seg_info)
464
+
465
+ if not segments:
466
+ return '<div style="color: #999; padding: 20px;">No valid segments to display.</div>'
467
+
468
+ return render_segments(segments, audio_int16=audio_int16, sample_rate=sample_rate,
469
+ segment_dir=segment_dir)
470
+
471
+
472
+ def _download_audio_for_row(audio_id: str):
473
+ """Download audio for a specific row by streaming until audio_id matches.
474
+
475
+ Returns (audio_int16, sample_rate, segment_dir) or raises on failure.
476
+ """
477
+ token = _load_token()
478
+ if not token:
479
+ raise ValueError("No HF token")
480
+
481
+ from datasets import load_dataset
482
+ import librosa
483
+
484
+ ds = load_dataset("hetchyy/quran-aligner-logs", token=token,
485
+ split="train", streaming=True)
486
+
487
+ for row in ds:
488
+ if row.get("audio_id") == audio_id:
489
+ audio_data = row.get("audio")
490
+ if audio_data is None:
491
+ raise ValueError("Row found but audio is None")
492
+
493
+ # HF Audio column returns {"path": ..., "array": np.array, "sampling_rate": int}
494
+ audio_array = audio_data["array"]
495
+ sr = audio_data["sampling_rate"]
496
+
497
+ # Resample to 16kHz if needed
498
+ if sr != 16000:
499
+ audio_array = librosa.resample(audio_array, orig_sr=sr, target_sr=16000)
500
+ sr = 16000
501
+
502
+ # Convert to int16
503
+ audio_float = np.clip(audio_array, -1.0, 1.0)
504
+ audio_int16 = (audio_float * 32767).astype(np.int16)
505
+
506
+ # Create segment directory
507
+ segment_dir = SEGMENT_AUDIO_DIR / f"dev_{uuid.uuid4().hex[:8]}"
508
+ segment_dir.mkdir(parents=True, exist_ok=True)
509
+
510
+ return audio_int16, sr, segment_dir
511
+
512
+ raise ValueError(f"Audio ID '{audio_id}' not found in dataset")
src/ui/event_wiring.py CHANGED
@@ -1,6 +1,7 @@
1
  """Event wiring — connects all Gradio component events."""
2
  import gradio as gr
3
 
 
4
  from src.core.zero_gpu import QuotaExhaustedError
5
  from src.pipeline import (
6
  process_audio, resegment_audio,
@@ -34,6 +35,8 @@ def wire_events(app, c):
34
  _wire_animation_settings(c)
35
  _wire_settings_restoration(app, c)
36
  _wire_api_endpoint(c)
 
 
37
 
38
 
39
  def _wire_preset_buttons(c):
@@ -496,3 +499,47 @@ def _wire_api_endpoint(c):
496
  outputs=[c.api_result],
497
  api_name="mfa_timestamps_direct",
498
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Event wiring — connects all Gradio component events."""
2
  import gradio as gr
3
 
4
+ from config import DEV_TAB_VISIBLE
5
  from src.core.zero_gpu import QuotaExhaustedError
6
  from src.pipeline import (
7
  process_audio, resegment_audio,
 
35
  _wire_animation_settings(c)
36
  _wire_settings_restoration(app, c)
37
  _wire_api_endpoint(c)
38
+ if DEV_TAB_VISIBLE:
39
+ _wire_dev_tab(c)
40
 
41
 
42
  def _wire_preset_buttons(c):
 
499
  outputs=[c.api_result],
500
  api_name="mfa_timestamps_direct",
501
  )
502
+
503
+
504
+ def _wire_dev_tab(c):
505
+ """Wire dev tab event handlers."""
506
+ from src.ui.dev_tools import (
507
+ load_logs_handler, filter_and_sort_handler, select_log_row_handler,
508
+ )
509
+
510
+ # Load / Refresh buttons
511
+ _load_outputs = [c.dev_all_rows, c.dev_filtered_indices, c.dev_status, c.dev_table]
512
+
513
+ c.dev_load_btn.click(
514
+ fn=load_logs_handler,
515
+ inputs=[],
516
+ outputs=_load_outputs,
517
+ api_name=False, show_progress="minimal",
518
+ )
519
+ c.dev_refresh_btn.click(
520
+ fn=load_logs_handler,
521
+ inputs=[],
522
+ outputs=_load_outputs,
523
+ api_name=False, show_progress="minimal",
524
+ )
525
+
526
+ # Filter / Sort changes
527
+ _filter_inputs = [c.dev_all_rows, c.dev_filter_device, c.dev_filter_model,
528
+ c.dev_filter_status, c.dev_sort]
529
+ _filter_outputs = [c.dev_filtered_indices, c.dev_table]
530
+
531
+ for component in [c.dev_filter_device, c.dev_filter_model, c.dev_filter_status, c.dev_sort]:
532
+ component.change(
533
+ fn=filter_and_sort_handler,
534
+ inputs=_filter_inputs,
535
+ outputs=_filter_outputs,
536
+ api_name=False, show_progress="hidden",
537
+ )
538
+
539
+ # Table row selection
540
+ c.dev_table.select(
541
+ fn=select_log_row_handler,
542
+ inputs=[c.dev_all_rows, c.dev_filtered_indices],
543
+ outputs=[c.dev_detail_html],
544
+ api_name=False, show_progress="minimal",
545
+ )
src/ui/interface.py CHANGED
@@ -7,6 +7,7 @@ import gradio as gr
7
 
8
  from config import (
9
  DELETE_CACHE_FREQUENCY, DELETE_CACHE_AGE,
 
10
  ANIM_WORD_COLOR, ANIM_STYLE_ROW_SCALES,
11
  ANIM_DISPLAY_MODES, ANIM_DISPLAY_MODE_DEFAULT,
12
  ANIM_OPACITY_PREV_DEFAULT, ANIM_OPACITY_AFTER_DEFAULT, ANIM_OPACITY_STEP,
@@ -205,36 +206,54 @@ def _build_animation_settings(c):
205
 
206
 
207
  def _build_right_column(c):
208
- """Build the right output column."""
209
  with gr.Column(scale=RIGHT_COLUMN_SCALE):
210
- c.extract_btn = gr.Button("Extract Segments", variant="primary", size="lg")
211
- with gr.Row(elem_id="action-btns-row"):
212
- c.resegment_toggle_btn = gr.Button(
213
- "Resegment with New Settings", variant="primary", size="lg", visible=False
214
- )
215
- c.retranscribe_btn = gr.Button(
216
- "Retranscribe with Large Model", variant="primary", size="lg", visible=False
217
- )
218
- with gr.Row(elem_id="ts-row"):
219
- c.compute_ts_btn = gr.Button(
220
- "Compute Timestamps", variant="secondary", size="lg", interactive=False, visible=False
221
- )
222
- c.compute_ts_progress = gr.HTML(value="", visible=False)
223
- c.animate_all_html = gr.HTML(value="", visible=False)
224
-
225
- with gr.Column(visible=False) as c.resegment_panel:
226
- gr.Markdown(
227
- "Uses cached data, skipping the heavy computation, "
228
- "so it's much faster. Useful if results are over-segmented "
229
- "or under-segmented"
230
- )
231
- c.rs_silence, c.rs_speech, c.rs_pad, \
232
- c.rs_btn_muj, c.rs_btn_mur, c.rs_btn_fast = create_segmentation_settings(id_suffix="-rs")
233
- c.resegment_btn = gr.Button("Resegment", variant="primary", size="lg")
 
 
234
 
235
- c.output_html = gr.HTML(
236
- value='<div style="text-align: center; color: #666; padding: 60px;">Upload audio and click "Extract Segments" to begin</div>',
237
- elem_classes=["output-html"]
 
 
238
  )
239
- # Hidden JSON output for API consumers
240
- c.output_json = gr.JSON(visible=False, label="JSON Output")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  from config import (
9
  DELETE_CACHE_FREQUENCY, DELETE_CACHE_AGE,
10
+ DEV_TAB_VISIBLE,
11
  ANIM_WORD_COLOR, ANIM_STYLE_ROW_SCALES,
12
  ANIM_DISPLAY_MODES, ANIM_DISPLAY_MODE_DEFAULT,
13
  ANIM_OPACITY_PREV_DEFAULT, ANIM_OPACITY_AFTER_DEFAULT, ANIM_OPACITY_STEP,
 
206
 
207
 
208
  def _build_right_column(c):
209
+ """Build the right output column, with optional Dev tab."""
210
  with gr.Column(scale=RIGHT_COLUMN_SCALE):
211
+ if DEV_TAB_VISIBLE:
212
+ with gr.Tabs():
213
+ with gr.Tab("Results"):
214
+ _build_results_content(c)
215
+ with gr.Tab("Dev"):
216
+ _build_dev_tab(c)
217
+ else:
218
+ _build_results_content(c)
219
+
220
+
221
+ def _build_results_content(c):
222
+ """Build the main results content (extract/resegment/output)."""
223
+ c.extract_btn = gr.Button("Extract Segments", variant="primary", size="lg")
224
+ with gr.Row(elem_id="action-btns-row"):
225
+ c.resegment_toggle_btn = gr.Button(
226
+ "Resegment with New Settings", variant="primary", size="lg", visible=False
227
+ )
228
+ c.retranscribe_btn = gr.Button(
229
+ "Retranscribe with Large Model", variant="primary", size="lg", visible=False
230
+ )
231
+ with gr.Row(elem_id="ts-row"):
232
+ c.compute_ts_btn = gr.Button(
233
+ "Compute Timestamps", variant="secondary", size="lg", interactive=False, visible=False
234
+ )
235
+ c.compute_ts_progress = gr.HTML(value="", visible=False)
236
+ c.animate_all_html = gr.HTML(value="", visible=False)
237
 
238
+ with gr.Column(visible=False) as c.resegment_panel:
239
+ gr.Markdown(
240
+ "Uses cached data, skipping the heavy computation, "
241
+ "so it's much faster. Useful if results are over-segmented "
242
+ "or under-segmented"
243
  )
244
+ c.rs_silence, c.rs_speech, c.rs_pad, \
245
+ c.rs_btn_muj, c.rs_btn_mur, c.rs_btn_fast = create_segmentation_settings(id_suffix="-rs")
246
+ c.resegment_btn = gr.Button("Resegment", variant="primary", size="lg")
247
+
248
+ c.output_html = gr.HTML(
249
+ value='<div style="text-align: center; color: #666; padding: 60px;">Upload audio and click "Extract Segments" to begin</div>',
250
+ elem_classes=["output-html"]
251
+ )
252
+ # Hidden JSON output for API consumers
253
+ c.output_json = gr.JSON(visible=False, label="JSON Output")
254
+
255
+
256
+ def _build_dev_tab(c):
257
+ """Build the Dev tab UI (delegates to dev_tools module)."""
258
+ from src.ui.dev_tools import build_dev_tab_ui
259
+ build_dev_tab_ui(c)