vumichien commited on
Commit
893d6fd
·
1 Parent(s): 1eb601c

refactor: overhaul Gradio app UI and enhance functionality

Browse files

- Update app.py to implement a neobrutalism design for the Gradio interface.
- Introduce a two-pane layout with a stepper and results dock for improved user experience.
- Enhance interactive features including a progress log and editable transcripts.
- Add new functions for handling speaker label mapping in formatters.py.
- Update README.md to reflect UI changes and new features.
- Modify YouTube cookies in cookies.txt for session management consistency.

README.md CHANGED
@@ -16,6 +16,13 @@ Whisper speaker diarization on T4 using `faster-whisper` (large-v3, fp16) and `p
16
 
17
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
18
 
 
 
 
 
 
 
 
19
  ## Required Setup
20
 
21
  This Space depends on the gated `pyannote/speaker-diarization-3.1` model. Before the app can run you must:
 
16
 
17
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
18
 
19
+ ## UI Highlights
20
+
21
+ - **Two-pane brutalist workspace** — 4-step stepper (Source · Configure · Process · Review) on the left, tabbed results dock (Transcript · Audio · Downloads · Diagnostics) on the right.
22
+ - **Stage-aware progress** — single PROCESS button auto-chains YouTube download → convert → transcribe → diarize → align → format, with a live monospace log card (first feedback under 2s).
23
+ - **Interactive transcript** — per-speaker color chips with a deterministic palette, click-row-to-seek audio playback, and editable speaker rename that rewrites CSV + SRT in place without re-running the pipeline.
24
+ - **Inline error card** — known failure modes (missing HF_TOKEN, GPU OOM, YouTube auth) render an actionable remediation card next to the progress log.
25
+
26
  ## Required Setup
27
 
28
  This Space depends on the gated `pyannote/speaker-diarization-3.1` model. Before the app can run you must:
app.py CHANGED
@@ -1,11 +1,7 @@
1
- """Whisper Speaker Diarization — Gradio app (single tab, faster-whisper + pyannote)."""
2
 
3
  import logging
4
  import os
5
- import threading
6
- import time
7
- import traceback
8
- import uuid
9
  from pathlib import Path
10
 
11
 
@@ -21,7 +17,6 @@ def load_env_file(env_path=".env"):
21
  os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
22
 
23
 
24
- # Resolve cache dir + export HF/torch env BEFORE importing torch / hf libs (M2).
25
  load_env_file()
26
  from core import cache_manager # noqa: E402
27
 
@@ -31,177 +26,126 @@ cache_manager.cleanup_old_videos()
31
 
32
  import gradio as gr # noqa: E402
33
  import gradio_client.utils as _gcu # noqa: E402
34
- import pandas as pd # noqa: E402
35
- import psutil # noqa: E402
36
- import torch # noqa: E402
37
 
38
- # gradio_client 1.5–1.7 raises TypeError when a JSON schema node is a plain bool
39
- # (JSON Schema allows `additionalProperties: true|false`). Short-circuit on bool.
40
  _orig_get_type = _gcu.get_type
41
  _orig_to_py = _gcu._json_schema_to_python_type
 
 
42
 
 
 
 
43
 
44
- def _safe_get_type(schema):
45
- if isinstance(schema, bool):
46
- return "Any"
47
- return _orig_get_type(schema)
48
-
49
-
50
- def _safe_to_py(schema, defs=None):
51
- if isinstance(schema, bool):
52
- return "Any" if schema else "None"
53
- return _orig_to_py(schema, defs)
54
-
55
-
56
- _gcu.get_type = _safe_get_type
57
- _gcu._json_schema_to_python_type = _safe_to_py
58
-
59
- from core import aligner, audio_io, diarizer, formatters, transcriber # noqa: E402
60
- from core.youtube_loader import YouTubeAuthRequiredError, download_video # noqa: E402
61
-
62
- logger = logging.getLogger("whisper-diarization")
63
  logging.basicConfig(level=logging.INFO)
64
 
65
- # Serialize GPU work lru_cache'd model singletons are not thread-safe (C2).
66
- _PIPELINE_LOCK = threading.Lock()
67
-
68
- OUTPUT_DIR = cache_manager.get_output_dir()
69
- YOUTUBE_DIR = cache_manager.get_youtube_output_dir()
70
-
71
- LANGUAGES = [
72
- ("Auto-detect", None), ("English", "en"), ("Japanese", "ja"), ("Chinese", "zh"),
73
- ("Spanish", "es"), ("French", "fr"), ("German", "de"), ("Korean", "ko"),
74
- ("Portuguese", "pt"), ("Russian", "ru"), ("Italian", "it"), ("Vietnamese", "vi"),
75
- ("Arabic", "ar"), ("Hindi", "hi"), ("Indonesian", "id"), ("Dutch", "nl"),
76
- ("Polish", "pl"), ("Turkish", "tr"), ("Thai", "th"), ("Ukrainian", "uk"),
77
- ]
78
-
79
- EXAMPLE_URLS = [
80
- "https://www.youtube.com/watch?v=j7BfEzAFuYc&t=32s",
81
- "https://www.youtube.com/watch?v=-UX0X45sYe4",
82
- "https://www.youtube.com/watch?v=7minSgqi-Gw",
83
- ]
84
-
85
-
86
- def get_system_info(elapsed):
87
- mem = psutil.virtual_memory()
88
- parts = [
89
- f"*RAM: {mem.total / 1024**3:.2f} GB total, {mem.percent}% used.*",
90
- f"*Processing time: {elapsed:.2f} s.*",
91
- ]
92
- if torch.cuda.is_available():
93
- free, total = torch.cuda.mem_get_info()
94
- used_gb = (total - free) / 1024**3
95
- total_gb = total / 1024**3
96
- parts.append(f"*GPU: {used_gb:.2f} / {total_gb:.2f} GB used.*")
97
- return "\n".join(parts)
98
-
99
-
100
- def download_youtube_for_ui(url):
101
- if not url:
102
- raise gr.Error("Please enter a YouTube URL first.")
103
-
104
- yield (
105
- gr.update(value=None, label="Downloading YouTube video..."),
106
- "Downloading YouTube video. This may take a few minutes for long videos.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  )
108
 
109
- try:
110
- video_path = download_video(url, YOUTUBE_DIR)
111
- except YouTubeAuthRequiredError as exc:
112
- yield (gr.update(value=None, label="Video / Audio file"), str(exc))
113
- return
114
- except Exception as exc: # noqa: BLE001
115
- logger.error("YouTube download failed: %s", traceback.format_exc())
116
- yield (gr.update(value=None, label="Video / Audio file"), f"Download failed: {type(exc).__name__}.")
117
- return
118
-
119
- cache_manager.cleanup_old_videos()
120
- yield (
121
- gr.update(value=video_path, label="Video / Audio file"),
122
- f"Downloaded video: `{video_path}`",
123
  )
124
 
125
-
126
- def run_pipeline(video_path, language, num_speakers):
127
- if not video_path:
128
- raise gr.Error("Please upload a file or download a YouTube video first.")
129
-
130
- t0 = time.time()
131
- job_id = uuid.uuid4().hex[:8]
132
-
133
- try:
134
- with _PIPELINE_LOCK:
135
- wav_path = audio_io.convert_to_wav(video_path, OUTPUT_DIR)
136
- segments = transcriber.transcribe(wav_path, language=language or None)
137
- if not segments:
138
- raise gr.Error("No speech detected in the input audio.")
139
- turns = diarizer.diarize(wav_path, num_speakers=num_speakers)
140
- if torch.cuda.is_available():
141
- torch.cuda.empty_cache()
142
- merged = aligner.assign_speakers(segments, turns)
143
- except gr.Error:
144
- raise
145
- except torch.cuda.OutOfMemoryError as exc: # type: ignore[attr-defined]
146
- if torch.cuda.is_available():
147
- torch.cuda.empty_cache()
148
- logger.error("GPU OOM: %s", exc)
149
- raise gr.Error("GPU out of memory. Try a shorter clip or set fewer speakers.") from exc
150
- except Exception as exc: # noqa: BLE001
151
- logger.error("Pipeline failure (job %s):\n%s", job_id, traceback.format_exc())
152
- raise gr.Error(f"Pipeline failed: {type(exc).__name__}. See server logs for details.") from exc
153
-
154
- rows = formatters.build_grouped_rows(merged)
155
- csv_path = formatters.write_csv(rows, OUTPUT_DIR / f"transcript_{job_id}.csv")
156
- srt_path = formatters.write_srt(merged, OUTPUT_DIR / f"transcript_{job_id}.srt")
157
-
158
- df = pd.DataFrame(rows)
159
- return df, csv_path, srt_path, get_system_info(time.time() - t0)
160
-
161
-
162
- with gr.Blocks(title="Whisper Speaker Diarization") as demo:
163
- gr.Markdown(
164
- "# Whisper Speaker Diarization\n"
165
- "Upload a video/audio file or download a YouTube video, then transcribe "
166
- "with `faster-whisper` (large-v3) and diarize with `pyannote.audio` 3.1. "
167
- "Outputs CSV + SRT."
168
  )
169
-
170
- with gr.Row():
171
- with gr.Column():
172
- yt_url = gr.Textbox(label="YouTube URL", lines=1)
173
- yt_btn = gr.Button("Download YouTube video")
174
- yt_status = gr.Markdown()
175
- video_in = gr.Video(label="Video / Audio file")
176
- language = gr.Dropdown(
177
- choices=LANGUAGES, value=None,
178
- label="Spoken language (auto-detect if blank)",
179
- )
180
- num_speakers = gr.Number(
181
- value=0, precision=0,
182
- label="Number of speakers (0 = auto-detect via pyannote)",
183
- )
184
- run_btn = gr.Button("Transcribe & Diarize", variant="primary")
185
- gr.Examples(examples=EXAMPLE_URLS, inputs=[yt_url], label="YouTube examples")
186
-
187
- with gr.Column():
188
- df_out = gr.DataFrame(
189
- value=pd.DataFrame(columns=["Start", "End", "Speaker", "Text"]),
190
- label="Transcript", row_count=(0, "dynamic"), wrap=True,
191
- )
192
- csv_out = gr.File(label="Download CSV")
193
- srt_out = gr.File(label="Download SRT")
194
- sysinfo = gr.Markdown()
195
-
196
- yt_btn.click(download_youtube_for_ui, [yt_url], [video_in, yt_status])
197
- run_btn.click(
198
- run_pipeline,
199
- [video_in, language, num_speakers],
200
- [df_out, csv_out, srt_out, sysinfo],
201
  )
202
 
203
 
204
  demo.queue(default_concurrency_limit=1)
205
 
206
  if __name__ == "__main__":
207
- demo.launch(debug=False, ssr_mode=False)
 
1
+ """Whisper Speaker Diarization — Neobrutalism Gradio app (event wiring only)."""
2
 
3
  import logging
4
  import os
 
 
 
 
5
  from pathlib import Path
6
 
7
 
 
17
  os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
18
 
19
 
 
20
  load_env_file()
21
  from core import cache_manager # noqa: E402
22
 
 
26
 
27
  import gradio as gr # noqa: E402
28
  import gradio_client.utils as _gcu # noqa: E402
 
 
 
29
 
30
+ # gradio_client 1.5–1.7 raises TypeError on plain bool JSON-schema nodes.
 
31
  _orig_get_type = _gcu.get_type
32
  _orig_to_py = _gcu._json_schema_to_python_type
33
+ _gcu.get_type = lambda s: "Any" if isinstance(s, bool) else _orig_get_type(s)
34
+ _gcu._json_schema_to_python_type = lambda s, d=None: ("Any" if s else "None") if isinstance(s, bool) else _orig_to_py(s, d)
35
 
36
+ from core.pipeline_runner import OUTPUT_DIR # noqa: E402
37
+ from core.ui import handlers # noqa: E402
38
+ from core.ui.layout import build_results_dock, build_stepper # noqa: E402
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  logging.basicConfig(level=logging.INFO)
41
 
42
+ _STYLES_DIR = Path(__file__).parent / "core" / "styles"
43
+ _THEME_CSS = (_STYLES_DIR / "neobrutalism.css").read_text(encoding="utf-8")
44
+ _SEEK_JS = (_STYLES_DIR / "seek.js").read_text(encoding="utf-8")
45
+
46
+ _HEAD = (
47
+ '<link rel="preconnect" href="https://fonts.googleapis.com">'
48
+ '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
49
+ '<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=JetBrains+Mono&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">'
50
+ f'<script>{_SEEK_JS}</script>'
51
+ )
52
+
53
+ _THEME = gr.themes.Base(primary_hue="yellow", neutral_hue="stone").set(
54
+ body_background_fill="#FFF8E7",
55
+ body_background_fill_dark="#FFF8E7",
56
+ body_text_color="#000000",
57
+ body_text_color_dark="#000000",
58
+ button_primary_background_fill="#FFD23F",
59
+ button_primary_background_fill_dark="#FFD23F",
60
+ button_primary_text_color="#000000",
61
+ button_primary_text_color_dark="#000000",
62
+ button_secondary_background_fill="#FFFFFF",
63
+ button_secondary_background_fill_dark="#FFFFFF",
64
+ button_secondary_text_color="#000000",
65
+ button_secondary_text_color_dark="#000000",
66
+ background_fill_primary="#FFFFFF",
67
+ background_fill_primary_dark="#FFFFFF",
68
+ background_fill_secondary="#FFF8E7",
69
+ background_fill_secondary_dark="#FFF8E7",
70
+ block_background_fill="#FFFFFF",
71
+ block_background_fill_dark="#FFFFFF",
72
+ block_label_text_color="#000000",
73
+ block_label_text_color_dark="#000000",
74
+ block_title_text_color="#000000",
75
+ block_title_text_color_dark="#000000",
76
+ input_background_fill="#FFFFFF",
77
+ input_background_fill_dark="#FFFFFF",
78
+ panel_background_fill="#FFFFFF",
79
+ panel_background_fill_dark="#FFFFFF",
80
+ border_color_primary="#000000",
81
+ border_color_primary_dark="#000000",
82
+ )
83
+
84
+ # Force light variant always — drop .dark class on root + add .neobrut for CSS scoping
85
+ _NEOBRUT_BOOTSTRAP = (
86
+ "<script>"
87
+ "(function(){"
88
+ "function fix(){"
89
+ "var roots=[document.documentElement, document.body, document.querySelector('.gradio-container')];"
90
+ "roots.forEach(function(el){if(el){el.classList.remove('dark');el.classList.add('neobrut');}});"
91
+ "}"
92
+ "fix();"
93
+ "document.addEventListener('DOMContentLoaded',fix);"
94
+ "var mo=new MutationObserver(fix);"
95
+ "mo.observe(document.documentElement,{attributes:true,attributeFilter:['class']});"
96
+ "})();"
97
+ "</script>"
98
+ )
99
+
100
+
101
+ with gr.Blocks(title="Whisper Speaker Diarization", css=_THEME_CSS, head=_HEAD, theme=_THEME) as demo:
102
+ gr.HTML(_NEOBRUT_BOOTSTRAP)
103
+ gr.HTML('<div class="neobrut-brand">WHISPER × DIARIZE <span class="neobrut-brand-tag">v3.1</span></div>')
104
+
105
+ with gr.Row(elem_classes="brut-workspace"):
106
+ with gr.Column(scale=1):
107
+ stepper = build_stepper()
108
+ with gr.Column(scale=2):
109
+ dock = build_results_dock()
110
+
111
+ stepper["yt_btn"].click(
112
+ handlers.download_youtube_for_ui, [stepper["yt_url"]],
113
+ [stepper["video_in"], stepper["yt_status"]],
114
  )
115
 
116
+ stepper["run_btn"].click(
117
+ handlers.run_unified,
118
+ [stepper["yt_url"], stepper["video_in"], stepper["language"], stepper["num_speakers"]],
119
+ [
120
+ dock["df_out"], dock["csv_out"], dock["srt_out"], dock["sysinfo"],
121
+ stepper["progress_log"], stepper["error_card"],
122
+ dock["merged_state"], dock["wav_state"],
123
+ dock["audio_player"], dock["seek_bus"], stepper["video_in"],
124
+ ],
125
+ show_progress="minimal",
126
+ show_progress_on=[stepper["progress_log"]],
 
 
 
127
  )
128
 
129
+ dock["edit_btn"].click(
130
+ handlers.enter_edit_mode, [dock["merged_state"]],
131
+ [dock["df_out"], dock["edit_btn"], dock["apply_btn"], dock["cancel_btn"], dock["edit_mode_state"]],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  )
133
+ dock["cancel_btn"].click(
134
+ handlers.cancel_edit, [dock["merged_state"]],
135
+ [dock["df_out"], dock["edit_btn"], dock["apply_btn"], dock["cancel_btn"], dock["edit_mode_state"]],
136
+ )
137
+ dock["apply_btn"].click(
138
+ handlers.apply_renames, [dock["df_out"], dock["merged_state"]],
139
+ [dock["df_out"], dock["csv_out"], dock["srt_out"],
140
+ dock["edit_btn"], dock["apply_btn"], dock["cancel_btn"],
141
+ dock["edit_mode_state"], dock["merged_state"]],
142
+ )
143
+ dock["df_out"].select(
144
+ handlers.on_row_select, [dock["merged_state"], dock["edit_mode_state"]], [dock["seek_bus"]],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  )
146
 
147
 
148
  demo.queue(default_concurrency_limit=1)
149
 
150
  if __name__ == "__main__":
151
+ demo.launch(debug=False, ssr_mode=False, allowed_paths=[str(OUTPUT_DIR)])
cookies.txt CHANGED
@@ -73,8 +73,8 @@
73
  .www.linkedin.com TRUE / TRUE 13376020845000000 li_theme_set app
74
  .www.linkedin.com TRUE / TRUE 13361678445000000 timezone Etc/GMT-9
75
  .youtube.com TRUE / FALSE 0 PREF hl=en&tz=UTC
76
- .youtube.com TRUE / TRUE 1793752536 VISITOR_INFO1_LIVE Vz7XlgEER4M
77
- .youtube.com TRUE / TRUE 1793752536 VISITOR_PRIVACY_METADATA CgJKUBIEGgAgDA%3D%3D
78
  .youtube.com TRUE / TRUE 13392004842930348 __Secure-1PSIDTS sidts-CjIBLwcBXHTZZ4VlgjO9d0X9960k-m3xB6nUHCU0ovveIk9bhdMwuI1M5U6sCFpH69WGyxAA
79
  .youtube.com TRUE / TRUE 13395028842930468 __Secure-3PAPISID rXfXaV6elrnuLRG2/ATiQ5qo4qP3esXWRf
80
  .youtube.com TRUE / TRUE 13395028842930396 __Secure-3PSID g.a000jwgUbTicY-c3T2zFcPSBSs9JcjhaZuSGyJ_QzqtQAsI829wiF4sYm8c8WRawxgyeTMLvGgACgYKAdwSAQASFQHGX2MiAtagbvetYeXJFcm11E34gBoVAUF8yKovqs11VovRjv2YDBvIDwSY0076
@@ -83,8 +83,8 @@
83
  .youtube.com TRUE / TRUE 1793696513 __Secure-YNID 18.YT=pClxLbLt2ndTOFz5TjTNNAVDqO4rdY2bPBZgXXEDTx1Vu4kIMxueXBGt8aI1fxCrPAood0t9wFaYccU9eEuMaqRF-NF4Y4vizaoFkbmU129yihrUKBHAou0NF9B71i2Fnado_fUjbeRAgE2hp5i17_ATo0B373qzKI9Gl1vBCw3Qc4_fB4uevKv0_mAuKbWTeBe0VhDmLiL9bXaajYiLqu8F4nh1b6IeaAaNBa38-C_6MlWQcBe6Wy0FgGlcH-1Rbskojho9yRnjYCLYCTX_A1eEYqHDX-X0lWZVQBM8RTjIEabEql-EQGmVhqkOyGT5zzB7e5WXJxfsqA7M-vKE1Q
84
  .youtube.com TRUE / TRUE 1793697765 __Secure-ROLLOUT_TOKEN COfp1qDEr8aDsgEQ8bvOwOimlAMY9v3Ole2mlAM%3D
85
  .youtube.com TRUE / TRUE 0 SOCS CAI
86
- .youtube.com TRUE / TRUE 1778202336 GPS 1
87
- .youtube.com TRUE / TRUE 0 YSC -wZ94Xwu_3Q
88
  accounts.google.com FALSE / TRUE 13395028842810568 ACCOUNT_CHOOSER AFx_qI7mKNWJOzlVhiBG_7ePk9G1aurgq_vRbUrMT4vUpZIBEglJRds2dMuP4xyKSU31IrM6EQDzANxN9beScMQqM7W9i35ZREssDLIsMAbV3iAWW-daDL0bL0uccjENF9AYSTq0Q_xNNNianZfPYldxwjVVv3DgQEPRQ5-vJ-FK3cN6OZT-Xr0aN8m19h5rFR1-HfqEXbJlBCSA8KaoPLdRbr0d8xIm0w
89
  accounts.google.com FALSE / TRUE 13395155885030990 LSID o.drive.fife.usercontent.google.com|o.drive.google.com|s.JP|s.youtube:g.a000jwgUbVr8DCZQNAUc_81ez4p7muwo5wbEV58bNPuohR3K4oQecCW3cJBMWTcPoXpV4ezWIwACgYKAdESAQASFQHGX2MiQp7teYLYhQb_QY1etv0AmxoVAUF8yKoT2rc29PMCNKIpy9Qze0xd0076
90
  accounts.google.com FALSE / TRUE 13395028842810594 SMSV ADHTe-DtGKOMdPXjuNGHm29fqrNj6gxDE1al9AaonriIQgaa6Ng8ZBhEIFU3_1GyD_D4xJwDzRPvyEWFivLcjm92Et4gqOnss6uY9LQEPAkVSycJctnf5cw
 
73
  .www.linkedin.com TRUE / TRUE 13376020845000000 li_theme_set app
74
  .www.linkedin.com TRUE / TRUE 13361678445000000 timezone Etc/GMT-9
75
  .youtube.com TRUE / FALSE 0 PREF hl=en&tz=UTC
76
+ .youtube.com TRUE / TRUE 1793759290 VISITOR_INFO1_LIVE Vz7XlgEER4M
77
+ .youtube.com TRUE / TRUE 1793759290 VISITOR_PRIVACY_METADATA CgJKUBIEGgAgDA%3D%3D
78
  .youtube.com TRUE / TRUE 13392004842930348 __Secure-1PSIDTS sidts-CjIBLwcBXHTZZ4VlgjO9d0X9960k-m3xB6nUHCU0ovveIk9bhdMwuI1M5U6sCFpH69WGyxAA
79
  .youtube.com TRUE / TRUE 13395028842930468 __Secure-3PAPISID rXfXaV6elrnuLRG2/ATiQ5qo4qP3esXWRf
80
  .youtube.com TRUE / TRUE 13395028842930396 __Secure-3PSID g.a000jwgUbTicY-c3T2zFcPSBSs9JcjhaZuSGyJ_QzqtQAsI829wiF4sYm8c8WRawxgyeTMLvGgACgYKAdwSAQASFQHGX2MiAtagbvetYeXJFcm11E34gBoVAUF8yKovqs11VovRjv2YDBvIDwSY0076
 
83
  .youtube.com TRUE / TRUE 1793696513 __Secure-YNID 18.YT=pClxLbLt2ndTOFz5TjTNNAVDqO4rdY2bPBZgXXEDTx1Vu4kIMxueXBGt8aI1fxCrPAood0t9wFaYccU9eEuMaqRF-NF4Y4vizaoFkbmU129yihrUKBHAou0NF9B71i2Fnado_fUjbeRAgE2hp5i17_ATo0B373qzKI9Gl1vBCw3Qc4_fB4uevKv0_mAuKbWTeBe0VhDmLiL9bXaajYiLqu8F4nh1b6IeaAaNBa38-C_6MlWQcBe6Wy0FgGlcH-1Rbskojho9yRnjYCLYCTX_A1eEYqHDX-X0lWZVQBM8RTjIEabEql-EQGmVhqkOyGT5zzB7e5WXJxfsqA7M-vKE1Q
84
  .youtube.com TRUE / TRUE 1793697765 __Secure-ROLLOUT_TOKEN COfp1qDEr8aDsgEQ8bvOwOimlAMY9v3Ole2mlAM%3D
85
  .youtube.com TRUE / TRUE 0 SOCS CAI
86
+ .youtube.com TRUE / TRUE 1778207818 GPS 1
87
+ .youtube.com TRUE / TRUE 0 YSC ED98F99fkiE
88
  accounts.google.com FALSE / TRUE 13395028842810568 ACCOUNT_CHOOSER AFx_qI7mKNWJOzlVhiBG_7ePk9G1aurgq_vRbUrMT4vUpZIBEglJRds2dMuP4xyKSU31IrM6EQDzANxN9beScMQqM7W9i35ZREssDLIsMAbV3iAWW-daDL0bL0uccjENF9AYSTq0Q_xNNNianZfPYldxwjVVv3DgQEPRQ5-vJ-FK3cN6OZT-Xr0aN8m19h5rFR1-HfqEXbJlBCSA8KaoPLdRbr0d8xIm0w
89
  accounts.google.com FALSE / TRUE 13395155885030990 LSID o.drive.fife.usercontent.google.com|o.drive.google.com|s.JP|s.youtube:g.a000jwgUbVr8DCZQNAUc_81ez4p7muwo5wbEV58bNPuohR3K4oQecCW3cJBMWTcPoXpV4ezWIwACgYKAdESAQASFQHGX2MiQp7teYLYhQb_QY1etv0AmxoVAUF8yKoT2rc29PMCNKIpy9Qze0xd0076
90
  accounts.google.com FALSE / TRUE 13395028842810594 SMSV ADHTe-DtGKOMdPXjuNGHm29fqrNj6gxDE1al9AaonriIQgaa6Ng8ZBhEIFU3_1GyD_D4xJwDzRPvyEWFivLcjm92Et4gqOnss6uY9LQEPAkVSycJctnf5cw
core/formatters.py CHANGED
@@ -64,3 +64,17 @@ def write_srt(segments, path):
64
  lines.append(f"{i}\n{start} --> {end}\n{prefix}{text}\n")
65
  Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8")
66
  return str(Path(path).resolve())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  lines.append(f"{i}\n{start} --> {end}\n{prefix}{text}\n")
65
  Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8")
66
  return str(Path(path).resolve())
67
+
68
+
69
+ def rewrite_with_label_map(segments, label_map):
70
+ """Apply {orig_label → new_label} to merged segments, returning a new list (does not mutate input)."""
71
+ if not label_map:
72
+ return list(segments) if segments else []
73
+ out = []
74
+ for seg in segments:
75
+ new_seg = dict(seg)
76
+ spk = new_seg.get("speaker")
77
+ if spk in label_map:
78
+ new_seg["speaker"] = label_map[spk]
79
+ out.append(new_seg)
80
+ return out
core/pipeline_runner.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage-aware pipeline generator. Yields (kind, payload) for UI consumption.
2
+
3
+ kind ∈ {"log", "video", "result", "error"}:
4
+ - log: payload is the cumulative log string
5
+ - video: payload is a video path (set after auto YT download)
6
+ - result: payload is (df, csv_path, srt_path, sysinfo, merged, wav_path)
7
+ - error: payload is rendered error-card HTML; generator then re-raises
8
+ """
9
+
10
+ import logging
11
+ import threading
12
+ import time
13
+ import traceback
14
+ import uuid
15
+ from typing import Iterator, Tuple, Any
16
+
17
+ import gradio as gr
18
+ import pandas as pd
19
+ import psutil
20
+ import torch
21
+
22
+ from core import aligner, audio_io, cache_manager, diarizer, formatters, transcriber
23
+ from core.ui.error_card import render_error
24
+ from core.youtube_loader import YouTubeAuthRequiredError, download_video
25
+
26
+
27
+ logger = logging.getLogger("whisper-diarization.runner")
28
+
29
+ _PIPELINE_LOCK = threading.Lock()
30
+
31
+ OUTPUT_DIR = cache_manager.get_output_dir()
32
+ YOUTUBE_DIR = cache_manager.get_youtube_output_dir()
33
+
34
+
35
+ STAGES = [
36
+ ("convert", 0.05, "Converting audio"),
37
+ ("transcribe", 0.20, "Transcribing"),
38
+ ("diarize", 0.55, "Diarizing speakers"),
39
+ ("align", 0.85, "Aligning"),
40
+ ("format", 0.95, "Writing CSV/SRT"),
41
+ ]
42
+
43
+
44
+ def _sysinfo(elapsed: float) -> str:
45
+ mem = psutil.virtual_memory()
46
+ parts = [
47
+ f"*RAM: {mem.total / 1024**3:.2f} GB total, {mem.percent}% used.*",
48
+ f"*Processing time: {elapsed:.2f} s.*",
49
+ ]
50
+ if torch.cuda.is_available():
51
+ free, total = torch.cuda.mem_get_info()
52
+ used_gb = (total - free) / 1024**3
53
+ total_gb = total / 1024**3
54
+ parts.append(f"*GPU: {used_gb:.2f} / {total_gb:.2f} GB used.*")
55
+ return "\n".join(parts)
56
+
57
+
58
+ def _stage_line(idx: int, total: int, label: str) -> str:
59
+ return f"[{idx}/{total}] {label}…"
60
+
61
+
62
+ def run_stages(
63
+ video_path: str,
64
+ language: str,
65
+ num_speakers: int,
66
+ progress: gr.Progress,
67
+ ) -> Iterator[Tuple[str, Any]]:
68
+ """Run conversion → transcribe → diarize → align → format. Yield logs and final result."""
69
+ if not video_path:
70
+ raise gr.Error("Please upload a file or download a YouTube video first.")
71
+
72
+ t0 = time.time()
73
+ job_id = uuid.uuid4().hex[:8]
74
+ log_lines: list = []
75
+ total = len(STAGES)
76
+
77
+ def push(idx: int, label: str, weight: float):
78
+ log_lines.append(_stage_line(idx, total, label))
79
+ try:
80
+ progress(weight, desc=label)
81
+ except Exception:
82
+ pass
83
+ return "\n".join(log_lines)
84
+
85
+ acquired = _PIPELINE_LOCK.acquire(blocking=True)
86
+ try:
87
+ # Stage 1 — convert
88
+ yield "log", push(1, STAGES[0][2], STAGES[0][1])
89
+ wav_path = audio_io.convert_to_wav(video_path, OUTPUT_DIR)
90
+
91
+ # Stage 2 — transcribe
92
+ yield "log", push(2, STAGES[1][2], STAGES[1][1])
93
+ segments = transcriber.transcribe(wav_path, language=language or None)
94
+ if not segments:
95
+ raise gr.Error("No speech detected in the input audio.")
96
+
97
+ # Stage 3 — diarize
98
+ yield "log", push(3, STAGES[2][2], STAGES[2][1])
99
+ turns = diarizer.diarize(wav_path, num_speakers=num_speakers)
100
+ if torch.cuda.is_available():
101
+ torch.cuda.empty_cache()
102
+
103
+ # Stage 4 — align
104
+ yield "log", push(4, STAGES[3][2], STAGES[3][1])
105
+ merged = aligner.assign_speakers(segments, turns)
106
+
107
+ # Stage 5 — format
108
+ yield "log", push(5, STAGES[4][2], STAGES[4][1])
109
+ rows = formatters.build_grouped_rows(merged)
110
+ csv_path = formatters.write_csv(rows, OUTPUT_DIR / f"transcript_{job_id}.csv")
111
+ srt_path = formatters.write_srt(merged, OUTPUT_DIR / f"transcript_{job_id}.srt")
112
+
113
+ df = pd.DataFrame(rows)
114
+ sysinfo = _sysinfo(time.time() - t0)
115
+ log_lines.append("[done]")
116
+ yield "log", "\n".join(log_lines)
117
+ yield "result", (df, csv_path, srt_path, sysinfo, merged, str(wav_path))
118
+ except gr.Error as exc:
119
+ yield "error", render_error(exc)
120
+ raise
121
+ except torch.cuda.OutOfMemoryError as exc:
122
+ if torch.cuda.is_available():
123
+ torch.cuda.empty_cache()
124
+ logger.error("GPU OOM: %s", exc)
125
+ yield "error", render_error(exc)
126
+ raise gr.Error("GPU out of memory. Try a shorter clip or set fewer speakers.") from exc
127
+ except Exception as exc:
128
+ logger.error("Pipeline failure (job %s):\n%s", job_id, traceback.format_exc())
129
+ yield "error", render_error(exc)
130
+ raise gr.Error(f"Pipeline failed: {type(exc).__name__}. See server logs for details.") from exc
131
+ finally:
132
+ if acquired:
133
+ _PIPELINE_LOCK.release()
134
+
135
+
136
+ def process_unified(
137
+ yt_url: str,
138
+ video_in: str,
139
+ language: str,
140
+ num_speakers: int,
141
+ progress: gr.Progress,
142
+ ) -> Iterator[Tuple[str, Any]]:
143
+ """Unified flow: optionally download YouTube first, then run stages."""
144
+ log_lines: list = []
145
+
146
+ if not video_in and not yt_url:
147
+ raise gr.Error("Provide a YouTube URL or upload a file.")
148
+
149
+ if not video_in and yt_url:
150
+ log_lines.append("[0/N] Downloading YouTube video…")
151
+ try:
152
+ progress(0.02, desc="Downloading YouTube")
153
+ except Exception:
154
+ pass
155
+ yield "log", "\n".join(log_lines)
156
+ try:
157
+ video_in = download_video(yt_url, YOUTUBE_DIR)
158
+ except YouTubeAuthRequiredError as exc:
159
+ yield "error", render_error(exc)
160
+ raise gr.Error(str(exc)) from exc
161
+ except Exception as exc:
162
+ logger.error("YouTube download failed: %s", traceback.format_exc())
163
+ yield "error", render_error(exc)
164
+ raise gr.Error(f"YouTube download failed: {type(exc).__name__}.") from exc
165
+ cache_manager.cleanup_old_videos()
166
+ log_lines.append(f"[0/N] Downloaded: {video_in}")
167
+ yield "log", "\n".join(log_lines)
168
+ yield "video", video_in
169
+ elif video_in and yt_url:
170
+ log_lines.append("[note] Both URL and upload provided — using uploaded file.")
171
+ yield "log", "\n".join(log_lines)
172
+
173
+ # Chain into stages, prefixing existing log lines
174
+ saw_log = False
175
+ for kind, payload in run_stages(video_in, language, num_speakers, progress):
176
+ if kind == "log":
177
+ saw_log = True
178
+ combined = "\n".join(log_lines + [payload]) if log_lines else payload
179
+ yield "log", combined
180
+ else:
181
+ yield kind, payload
182
+ if not saw_log:
183
+ yield "log", "\n".join(log_lines)
core/styles/__init__.py ADDED
File without changes
core/styles/neobrutalism.css ADDED
@@ -0,0 +1,662 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Neobrutalism design system — strict tokens, no radius, hard offset shadows */
2
+
3
+ /* Force light theme — override Gradio's auto dark-mode regardless of system pref */
4
+ html, body,
5
+ .gradio-container,
6
+ .gradio-container.dark,
7
+ .dark .gradio-container,
8
+ body.dark,
9
+ body[class*="dark"] {
10
+ background: #FFF8E7 !important;
11
+ color: #000000 !important;
12
+ color-scheme: light !important;
13
+ }
14
+
15
+ .dark, .gradio-container.dark {
16
+ --background-fill-primary: #FFFFFF !important;
17
+ --background-fill-secondary: #FFF8E7 !important;
18
+ --body-background-fill: #FFF8E7 !important;
19
+ --body-text-color: #000000 !important;
20
+ }
21
+
22
+ /* Kill Gradio's per-component progress overlay on transcript / audio / file outputs —
23
+ we have our own brutalist log card in the Review step */
24
+ .brut-df .progress-bar-wrap,
25
+ .brut-df .progress-text,
26
+ .brut-df [data-testid="block-info"],
27
+ .brut-df .meta-text,
28
+ #brut-audio-mount + .progress-bar-wrap,
29
+ #brut-audio-mount .progress-bar-wrap,
30
+ .gr-html .progress-bar-wrap,
31
+ .gr-html .progress-text,
32
+ .gr-file .progress-bar-wrap,
33
+ .gr-file .progress-text {
34
+ display: none !important;
35
+ }
36
+
37
+ /* Hide global timer text leaking into output blocks */
38
+ .brut-df .timer,
39
+ .gr-html .timer,
40
+ .gr-file .timer {
41
+ display: none !important;
42
+ }
43
+
44
+ :root {
45
+ --brut-bg: #FFF8E7;
46
+ --brut-fg: #000000;
47
+ --brut-yellow: #FFD23F;
48
+ --brut-coral: #FF6B6B;
49
+ --brut-cyan: #5CE1E6;
50
+ --brut-lime: #A4F47A;
51
+ --brut-magenta: #FF6FC0;
52
+ --brut-orange: #FF9F1C;
53
+ --brut-white: #FFFFFF;
54
+ --brut-border: 3px solid #000;
55
+ --brut-border-thin: 2px solid #000;
56
+ --brut-shadow: 4px 4px 0 #000;
57
+ --brut-shadow-hover: 2px 2px 0 #000;
58
+ --brut-shadow-press: 0 0 0 #000;
59
+ --brut-radius: 0;
60
+ --brut-font-heading: 'Space Grotesk', system-ui, sans-serif;
61
+ --brut-font-brand: 'Archivo Black', system-ui, sans-serif;
62
+ --brut-font-mono: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
63
+ }
64
+
65
+ /* Base reset — apply globally (no .neobrut required) since this is a single-purpose app */
66
+ .gradio-container {
67
+ background: var(--brut-bg) !important;
68
+ color: var(--brut-fg) !important;
69
+ font-family: var(--brut-font-heading) !important;
70
+ }
71
+
72
+ .gradio-container * {
73
+ border-radius: var(--brut-radius) !important;
74
+ box-sizing: border-box;
75
+ }
76
+
77
+ .gradio-container .gr-button,
78
+ .gradio-container button,
79
+ .gradio-container .gr-input,
80
+ .gradio-container input,
81
+ .gradio-container textarea,
82
+ .gradio-container select,
83
+ .gradio-container .gr-box,
84
+ .gradio-container .block,
85
+ .gradio-container .form,
86
+ .gradio-container .wrap {
87
+ border-radius: 0 !important;
88
+ font-family: var(--brut-font-heading);
89
+ }
90
+
91
+ .gradio-container h1,
92
+ .gradio-container h2,
93
+ .gradio-container h3,
94
+ .gradio-container h4 {
95
+ font-family: var(--brut-font-heading);
96
+ font-weight: 700;
97
+ color: var(--brut-fg) !important;
98
+ letter-spacing: -0.01em;
99
+ }
100
+
101
+ /* Brand bar */
102
+ .neobrut-brand {
103
+ background: var(--brut-yellow);
104
+ color: var(--brut-fg);
105
+ border-bottom: var(--brut-border);
106
+ box-shadow: var(--brut-shadow);
107
+ font-family: var(--brut-font-brand);
108
+ font-size: 28px;
109
+ letter-spacing: 0.06em;
110
+ padding: 16px 24px;
111
+ margin: 0 0 20px 0;
112
+ text-transform: uppercase;
113
+ display: flex;
114
+ align-items: center;
115
+ gap: 16px;
116
+ }
117
+
118
+ .neobrut-brand-tag {
119
+ font-family: var(--brut-font-mono);
120
+ font-size: 12px;
121
+ background: var(--brut-fg);
122
+ color: var(--brut-yellow);
123
+ padding: 4px 8px;
124
+ letter-spacing: 0.1em;
125
+ font-weight: 400;
126
+ }
127
+
128
+ /* Buttons — chunky, hard shadow, hover translates */
129
+ .gradio-container button,
130
+ .gradio-container .gr-button {
131
+ background: var(--brut-white);
132
+ color: var(--brut-fg);
133
+ border: var(--brut-border) !important;
134
+ box-shadow: var(--brut-shadow);
135
+ font-family: var(--brut-font-heading);
136
+ font-weight: 700;
137
+ text-transform: uppercase;
138
+ letter-spacing: 0.04em;
139
+ padding: 10px 18px;
140
+ transition: transform 0.05s ease, box-shadow 0.05s ease;
141
+ cursor: pointer;
142
+ }
143
+
144
+ .gradio-container button:hover,
145
+ .gradio-container .gr-button:hover {
146
+ transform: translate(2px, 2px);
147
+ box-shadow: var(--brut-shadow-hover);
148
+ }
149
+
150
+ .gradio-container button:active,
151
+ .gradio-container .gr-button:active {
152
+ transform: translate(4px, 4px);
153
+ box-shadow: var(--brut-shadow-press);
154
+ }
155
+
156
+ .gradio-container button:disabled,
157
+ .gradio-container .gr-button:disabled {
158
+ opacity: 0.5;
159
+ background: #ddd;
160
+ cursor: not-allowed;
161
+ }
162
+
163
+ .gradio-container .brut-primary,
164
+ .gradio-container button.brut-primary {
165
+ background: var(--brut-yellow);
166
+ font-family: var(--brut-font-brand);
167
+ font-size: 18px;
168
+ padding: 16px 28px;
169
+ letter-spacing: 0.08em;
170
+ }
171
+
172
+ /* Inputs */
173
+ .gradio-container input[type="text"],
174
+ .gradio-container input[type="number"],
175
+ .gradio-container textarea,
176
+ .gradio-container select,
177
+ .gradio-container .gr-input,
178
+ .gradio-container .gr-textbox,
179
+ .gradio-container .gr-dropdown {
180
+ background: var(--brut-white) !important;
181
+ border: var(--brut-border) !important;
182
+ box-shadow: var(--brut-shadow);
183
+ font-family: var(--brut-font-mono);
184
+ padding: 8px 12px;
185
+ color: var(--brut-fg);
186
+ }
187
+
188
+ .gradio-container input:focus,
189
+ .gradio-container textarea:focus,
190
+ .gradio-container select:focus {
191
+ outline: 3px solid var(--brut-cyan);
192
+ outline-offset: 2px;
193
+ }
194
+
195
+ /* Focus visible across the board */
196
+ .gradio-container *:focus-visible {
197
+ outline: 3px solid var(--brut-fg);
198
+ outline-offset: 2px;
199
+ }
200
+
201
+ /* Step cards */
202
+ .brut-step-card {
203
+ background: var(--brut-white);
204
+ border: var(--brut-border) !important;
205
+ box-shadow: var(--brut-shadow);
206
+ padding: 18px;
207
+ margin-bottom: 18px;
208
+ }
209
+
210
+ /* Step header — black ribbon inside the card, with breathing room on all sides */
211
+ .brut-step-header {
212
+ display: flex !important;
213
+ align-items: center;
214
+ gap: 14px;
215
+ margin: 4px 0 20px 0;
216
+ padding: 12px 16px;
217
+ background: var(--brut-fg) !important;
218
+ border: var(--brut-border-thin) !important;
219
+ box-shadow: 3px 3px 0 #000;
220
+ color: var(--brut-yellow) !important;
221
+ }
222
+
223
+ .brut-step-num {
224
+ display: inline-flex;
225
+ align-items: center;
226
+ justify-content: center;
227
+ min-width: 44px;
228
+ height: 36px;
229
+ background: var(--brut-yellow);
230
+ color: var(--brut-fg);
231
+ border: var(--brut-border-thin);
232
+ font-family: var(--brut-font-brand);
233
+ font-size: 20px;
234
+ padding: 0 10px;
235
+ letter-spacing: 0.05em;
236
+ line-height: 1;
237
+ flex-shrink: 0;
238
+ }
239
+
240
+ /* Per-step accent colors so steps feel sequential and distinct */
241
+ .brut-step-card.step-1 .brut-step-num { background: var(--brut-yellow); }
242
+ .brut-step-card.step-2 .brut-step-num { background: var(--brut-cyan); }
243
+ .brut-step-card.step-3 .brut-step-num { background: var(--brut-lime); }
244
+ .brut-step-card.step-4 .brut-step-num { background: var(--brut-magenta); }
245
+
246
+ .brut-step-title {
247
+ font-family: var(--brut-font-brand) !important;
248
+ font-size: 20px !important;
249
+ letter-spacing: 0.1em !important;
250
+ text-transform: uppercase !important;
251
+ margin: 0 !important;
252
+ color: var(--brut-yellow) !important;
253
+ background: transparent !important;
254
+ line-height: 1.1 !important;
255
+ flex: 1;
256
+ }
257
+
258
+ /* Ensure Gradio's gr.HTML wrapper inside the step-card doesn't break the ribbon */
259
+ .brut-step-card .html-container,
260
+ .brut-step-card .prose,
261
+ .brut-step-card [class*="html-container"] {
262
+ background: transparent !important;
263
+ padding: 0 !important;
264
+ margin: 0 !important;
265
+ border: none !important;
266
+ box-shadow: none !important;
267
+ }
268
+
269
+ /* Tabs — flat saturated, black underline on active */
270
+ .gradio-container .brut-tabs .tab-nav,
271
+ .gradio-container .brut-tabs .tab-nav button {
272
+ border-radius: 0 !important;
273
+ font-family: var(--brut-font-heading);
274
+ font-weight: 700;
275
+ text-transform: uppercase;
276
+ letter-spacing: 0.05em;
277
+ }
278
+
279
+ .gradio-container .brut-tabs .tab-nav {
280
+ border-bottom: var(--brut-border);
281
+ background: var(--brut-bg);
282
+ }
283
+
284
+ .gradio-container .brut-tabs .tab-nav button {
285
+ background: var(--brut-white);
286
+ border: var(--brut-border-thin) !important;
287
+ border-bottom: none !important;
288
+ margin-right: 6px;
289
+ padding: 8px 16px;
290
+ box-shadow: none;
291
+ }
292
+
293
+ .gradio-container .brut-tabs .tab-nav button.selected {
294
+ background: var(--brut-yellow);
295
+ border: var(--brut-border) !important;
296
+ border-bottom: 3px solid var(--brut-yellow) !important;
297
+ box-shadow: var(--brut-shadow-hover);
298
+ }
299
+
300
+ .gradio-container .brut-tabs .tab-nav button:hover:not(.selected) {
301
+ background: var(--brut-bg);
302
+ transform: translate(0, -1px);
303
+ }
304
+
305
+ /* Tab content panel — keep separation from background */
306
+ .gradio-container .brut-tabs > .tabitem {
307
+ background: var(--brut-white) !important;
308
+ border: var(--brut-border) !important;
309
+ border-top: none !important;
310
+ box-shadow: var(--brut-shadow);
311
+ padding: 16px !important;
312
+ }
313
+
314
+ /* Block labels everywhere — dark on cream */
315
+ .gradio-container .gr-block-label,
316
+ .gradio-container [data-testid="block-label"],
317
+ .gradio-container .label {
318
+ color: var(--brut-fg) !important;
319
+ font-family: var(--brut-font-heading) !important;
320
+ font-weight: 700 !important;
321
+ }
322
+
323
+ /* Dock workspace */
324
+ .brut-workspace {
325
+ gap: 18px;
326
+ }
327
+
328
+ /* Speaker chips */
329
+ .brut-chip {
330
+ display: inline-block;
331
+ padding: 2px 8px;
332
+ border: var(--brut-border-thin);
333
+ font-family: var(--brut-font-brand);
334
+ font-size: 12px;
335
+ letter-spacing: 0.04em;
336
+ color: var(--brut-fg);
337
+ white-space: nowrap;
338
+ }
339
+
340
+ .brut-df td:first-child {
341
+ white-space: nowrap;
342
+ }
343
+
344
+ /* Progress log card — no inner scroll; outer container handles overflow */
345
+ .brut-log {
346
+ background: #000;
347
+ color: var(--brut-lime);
348
+ border: var(--brut-border) !important;
349
+ box-shadow: var(--brut-shadow);
350
+ font-family: var(--brut-font-mono);
351
+ font-size: 13px;
352
+ padding: 12px;
353
+ white-space: pre-wrap;
354
+ word-break: break-word;
355
+ overflow-wrap: anywhere;
356
+ overflow: visible;
357
+ margin-top: 8px;
358
+ }
359
+
360
+ .brut-log,
361
+ .brut-log .prose,
362
+ .brut-log .markdown,
363
+ .brut-log [class*="container"] {
364
+ max-height: none !important;
365
+ overflow: visible !important;
366
+ }
367
+
368
+ .brut-log * {
369
+ background: transparent !important;
370
+ color: var(--brut-lime) !important;
371
+ font-family: var(--brut-font-mono) !important;
372
+ word-break: break-word;
373
+ overflow-wrap: anywhere;
374
+ }
375
+
376
+ /* Audio player */
377
+ .brut-audio-player {
378
+ width: 100%;
379
+ border: var(--brut-border);
380
+ box-shadow: var(--brut-shadow);
381
+ }
382
+
383
+ .brut-audio-empty {
384
+ font-family: var(--brut-font-mono);
385
+ border: 3px dashed var(--brut-fg);
386
+ padding: 24px;
387
+ text-align: center;
388
+ color: var(--brut-fg);
389
+ background: var(--brut-white);
390
+ }
391
+
392
+ /* Error card */
393
+ .brut-error-card-content {
394
+ background: var(--brut-coral);
395
+ border: var(--brut-border);
396
+ box-shadow: var(--brut-shadow);
397
+ padding: 16px;
398
+ margin-top: 12px;
399
+ color: var(--brut-fg);
400
+ }
401
+
402
+ .brut-error-card-content h4 {
403
+ font-family: var(--brut-font-brand);
404
+ font-size: 18px;
405
+ margin: 0 0 8px 0;
406
+ text-transform: uppercase;
407
+ letter-spacing: 0.04em;
408
+ }
409
+
410
+ .brut-error-card-content p {
411
+ margin: 6px 0;
412
+ font-family: var(--brut-font-heading);
413
+ }
414
+
415
+ .brut-error-card-content ol {
416
+ font-family: var(--brut-font-mono);
417
+ font-size: 13px;
418
+ padding-left: 24px;
419
+ margin: 8px 0 0 0;
420
+ }
421
+
422
+ .brut-error-card-content li {
423
+ margin-bottom: 4px;
424
+ }
425
+
426
+ /* ---------- DataFrame (Transcript) — readable cells ---------- */
427
+
428
+ /* Outer frame */
429
+ .gradio-container .brut-df,
430
+ .gradio-container .gr-dataframe {
431
+ border: var(--brut-border) !important;
432
+ box-shadow: var(--brut-shadow);
433
+ background: var(--brut-white) !important;
434
+ }
435
+
436
+ /* Reset any inherited dark background on inner wrappers */
437
+ .gradio-container .brut-df,
438
+ .gradio-container .brut-df > div,
439
+ .gradio-container .brut-df .table-wrap,
440
+ .gradio-container .brut-df [class*="table-wrap"],
441
+ .gradio-container .brut-df [class*="tableWrap"],
442
+ .gradio-container .brut-df [class*="dataframe"],
443
+ .gradio-container .gr-dataframe > div {
444
+ background: var(--brut-white) !important;
445
+ color: var(--brut-fg) !important;
446
+ }
447
+
448
+ /* Table — fixed layout so columns stay inside card width */
449
+ .gradio-container .brut-df table,
450
+ .gradio-container .gr-dataframe table {
451
+ background: var(--brut-white) !important;
452
+ color: var(--brut-fg) !important;
453
+ border-collapse: collapse !important;
454
+ width: 100% !important;
455
+ table-layout: fixed !important;
456
+ max-width: 100% !important;
457
+ }
458
+
459
+ /* Column widths — applied via th since Gradio 5 may not render <col> elements */
460
+ .gradio-container .brut-df th:nth-child(1),
461
+ .gradio-container .gr-dataframe th:nth-child(1),
462
+ .gradio-container .brut-df td:nth-child(1),
463
+ .gradio-container .gr-dataframe td:nth-child(1) { width: 90px !important; min-width: 90px !important; max-width: 90px !important; }
464
+
465
+ .gradio-container .brut-df th:nth-child(2),
466
+ .gradio-container .gr-dataframe th:nth-child(2),
467
+ .gradio-container .brut-df td:nth-child(2),
468
+ .gradio-container .gr-dataframe td:nth-child(2) { width: 90px !important; min-width: 90px !important; max-width: 90px !important; }
469
+
470
+ .gradio-container .brut-df th:nth-child(3),
471
+ .gradio-container .gr-dataframe th:nth-child(3),
472
+ .gradio-container .brut-df td:nth-child(3),
473
+ .gradio-container .gr-dataframe td:nth-child(3) { width: 130px !important; min-width: 130px !important; max-width: 130px !important; }
474
+
475
+ /* Text column flexes to fill remaining space */
476
+ .gradio-container .brut-df th:nth-child(4),
477
+ .gradio-container .gr-dataframe th:nth-child(4),
478
+ .gradio-container .brut-df td:nth-child(4),
479
+ .gradio-container .gr-dataframe td:nth-child(4) { width: auto !important; }
480
+
481
+ /* Outer scroll container — keep within card, no horizontal overflow, remove inner vertical cap */
482
+ .gradio-container .brut-df,
483
+ .gradio-container .brut-df > div,
484
+ .gradio-container .brut-df .table-wrap,
485
+ .gradio-container .brut-df [class*="table-wrap"] {
486
+ max-width: 100% !important;
487
+ max-height: none !important;
488
+ overflow-x: hidden !important;
489
+ overflow-y: visible !important;
490
+ box-sizing: border-box !important;
491
+ }
492
+
493
+ /* Header row — yellow on black, compact so labels don't wrap */
494
+ .gradio-container .brut-df th,
495
+ .gradio-container .brut-df thead th,
496
+ .gradio-container .brut-df [role="columnheader"],
497
+ .gradio-container .gr-dataframe th,
498
+ .gradio-container .gr-dataframe thead th {
499
+ background: var(--brut-fg) !important;
500
+ color: var(--brut-yellow) !important;
501
+ font-family: var(--brut-font-brand) !important;
502
+ font-size: 12px !important;
503
+ text-transform: uppercase !important;
504
+ letter-spacing: 0.04em !important;
505
+ padding: 10px 8px !important;
506
+ border: var(--brut-border-thin) !important;
507
+ border-bottom: var(--brut-border) !important;
508
+ text-align: left !important;
509
+ white-space: nowrap !important;
510
+ overflow: hidden !important;
511
+ text-overflow: ellipsis !important;
512
+ word-break: keep-all !important;
513
+ }
514
+
515
+ /* Force nowrap on inner spans/divs Gradio nests inside headers */
516
+ .gradio-container .brut-df th *,
517
+ .gradio-container .brut-df thead th *,
518
+ .gradio-container .brut-df [role="columnheader"] *,
519
+ .gradio-container .gr-dataframe th *,
520
+ .gradio-container .gr-dataframe thead th * {
521
+ white-space: nowrap !important;
522
+ word-break: keep-all !important;
523
+ font-size: inherit !important;
524
+ font-family: inherit !important;
525
+ letter-spacing: inherit !important;
526
+ }
527
+
528
+ /* Body cells — white bg, black text, wraps inside fixed-width columns */
529
+ .gradio-container .brut-df td,
530
+ .gradio-container .brut-df tbody td,
531
+ .gradio-container .brut-df [role="cell"],
532
+ .gradio-container .brut-df [role="gridcell"],
533
+ .gradio-container .gr-dataframe td,
534
+ .gradio-container .gr-dataframe tbody td {
535
+ background: var(--brut-white) !important;
536
+ color: var(--brut-fg) !important;
537
+ font-family: var(--brut-font-heading) !important;
538
+ font-size: 14px !important;
539
+ line-height: 1.55 !important;
540
+ padding: 10px 14px !important;
541
+ border-top: var(--brut-border-thin) !important;
542
+ border-right: 2px solid #000 !important;
543
+ vertical-align: top !important;
544
+ white-space: normal !important;
545
+ word-break: break-word !important;
546
+ overflow-wrap: anywhere !important;
547
+ }
548
+
549
+ .gradio-container .brut-df td:last-child,
550
+ .gradio-container .gr-dataframe td:last-child {
551
+ border-right: none !important;
552
+ }
553
+
554
+ /* Zebra striping for scan-ability */
555
+ .gradio-container .brut-df tbody tr:nth-child(even) td,
556
+ .gradio-container .gr-dataframe tbody tr:nth-child(even) td {
557
+ background: #FFFCF0 !important;
558
+ }
559
+
560
+ /* Hover affordance — selectable rows */
561
+ .gradio-container .brut-df tbody tr:hover td,
562
+ .gradio-container .gr-dataframe tbody tr:hover td {
563
+ background: var(--brut-yellow) !important;
564
+ cursor: pointer;
565
+ }
566
+
567
+ /* First column (Start time) — monospace + slight emphasis */
568
+ .gradio-container .brut-df tbody td:first-child,
569
+ .gradio-container .gr-dataframe tbody td:first-child {
570
+ font-family: var(--brut-font-mono) !important;
571
+ font-size: 12px !important;
572
+ white-space: nowrap !important;
573
+ font-weight: 700 !important;
574
+ }
575
+
576
+ .gradio-container .brut-df tbody td:nth-child(2),
577
+ .gradio-container .gr-dataframe tbody td:nth-child(2) {
578
+ font-family: var(--brut-font-mono) !important;
579
+ font-size: 12px !important;
580
+ white-space: nowrap !important;
581
+ }
582
+
583
+ /* Speaker chip column — keep palette bg from styler but ensure readable text */
584
+ .gradio-container .brut-df tbody td:nth-child(3),
585
+ .gradio-container .gr-dataframe tbody td:nth-child(3) {
586
+ font-family: var(--brut-font-brand) !important;
587
+ font-size: 12px !important;
588
+ text-transform: uppercase !important;
589
+ letter-spacing: 0.04em !important;
590
+ white-space: nowrap !important;
591
+ text-align: center !important;
592
+ }
593
+
594
+ /* Editable mode — show input affordance */
595
+ .gradio-container .brut-df td input,
596
+ .gradio-container .gr-dataframe td input {
597
+ background: #FFFCF0 !important;
598
+ color: var(--brut-fg) !important;
599
+ border: 2px solid var(--brut-fg) !important;
600
+ font-family: var(--brut-font-heading) !important;
601
+ }
602
+
603
+ /* Scrollbar inside dataframe */
604
+ .gradio-container .brut-df *::-webkit-scrollbar {
605
+ height: 12px;
606
+ width: 12px;
607
+ }
608
+ .gradio-container .brut-df *::-webkit-scrollbar-track {
609
+ background: var(--brut-bg);
610
+ border-top: var(--brut-border-thin);
611
+ }
612
+ .gradio-container .brut-df *::-webkit-scrollbar-thumb {
613
+ background: var(--brut-fg);
614
+ border: 2px solid var(--brut-bg);
615
+ }
616
+
617
+ /* Hide any leftover Gradio label that might still render inside the dataframe */
618
+ .gradio-container .brut-df > label,
619
+ .gradio-container .brut-df > .label-wrap,
620
+ .gradio-container .brut-df > [data-testid="block-label"] {
621
+ display: none !important;
622
+ }
623
+
624
+ /* Manual "Transcript" badge — matches Video / Audio file block-label badge style */
625
+ .brut-df-badge {
626
+ display: inline-flex !important;
627
+ align-items: center;
628
+ gap: 4px;
629
+ background: var(--brut-white) !important;
630
+ color: var(--brut-fg) !important;
631
+ border: 1px solid #000 !important;
632
+ box-shadow: 2px 2px 0 #000 !important;
633
+ padding: 3px 8px !important;
634
+ font-family: var(--brut-font-heading) !important;
635
+ font-weight: 700 !important;
636
+ font-size: 11px !important;
637
+ line-height: 1.3 !important;
638
+ margin: 0 0 6px 0 !important;
639
+ width: auto !important;
640
+ max-width: max-content !important;
641
+ }
642
+
643
+ .brut-df-badge svg {
644
+ flex-shrink: 0;
645
+ }
646
+
647
+ /* Container of the manual badge gr.HTML — strip wrapper bg/padding so only badge shows */
648
+ .gradio-container .brut-tabs > .tabitem .html-container:has(> .brut-df-badge) {
649
+ background: transparent !important;
650
+ border: none !important;
651
+ box-shadow: none !important;
652
+ padding: 0 !important;
653
+ margin: 0 !important;
654
+ }
655
+
656
+ /* File outputs */
657
+ .gradio-container .gr-file,
658
+ .gradio-container [data-testid="file"] {
659
+ border: var(--brut-border) !important;
660
+ box-shadow: var(--brut-shadow);
661
+ background: var(--brut-white);
662
+ }
core/styles/seek.js ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ function findAudio() {
3
+ var mount = document.getElementById('brut-audio-mount');
4
+ if (mount) {
5
+ var a = mount.querySelector('audio');
6
+ if (a) return a;
7
+ }
8
+ return document.querySelector('audio');
9
+ }
10
+ window.brutSeek = function (t) {
11
+ var a = findAudio();
12
+ if (!a) return;
13
+ a.currentTime = Number(t) || 0;
14
+ var p = a.play();
15
+ if (p && typeof p.catch === 'function') p.catch(function () {});
16
+ };
17
+ })();
core/ui/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UI builders for Neobrutalism Gradio app."""
2
+
3
+ from core.ui.layout import build_stepper, build_results_dock, LANGUAGES, EXAMPLE_URLS
4
+ from core.ui.transcript_view import (
5
+ SPEAKER_PALETTE,
6
+ color_for_speaker,
7
+ style_transcript,
8
+ grouped_row_start_seconds,
9
+ )
10
+ from core.ui.error_card import render_error, empty_card
11
+
12
+ __all__ = [
13
+ "build_stepper",
14
+ "build_results_dock",
15
+ "LANGUAGES",
16
+ "EXAMPLE_URLS",
17
+ "SPEAKER_PALETTE",
18
+ "color_for_speaker",
19
+ "style_transcript",
20
+ "grouped_row_start_seconds",
21
+ "render_error",
22
+ "empty_card",
23
+ ]
core/ui/error_card.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Map known exceptions to actionable remediation cards rendered as inline HTML."""
2
+
3
+ import torch
4
+
5
+ from core.youtube_loader import YouTubeAuthRequiredError
6
+
7
+
8
+ REMEDIATION = [
9
+ (YouTubeAuthRequiredError, {
10
+ "title": "YOUTUBE AUTH REQUIRED",
11
+ "cause": "This video requires authenticated cookies for download.",
12
+ "steps": [
13
+ "Export YouTube cookies in Netscape format (cookies.txt)",
14
+ "Add as Space secret YTDLP_COOKIES_CONTENT or set YTDLP_COOKIES_FILE",
15
+ "Restart the Space and retry",
16
+ ],
17
+ }),
18
+ (torch.cuda.OutOfMemoryError, {
19
+ "title": "GPU OUT OF MEMORY",
20
+ "cause": "Pipeline exceeded available VRAM.",
21
+ "steps": [
22
+ "Try a shorter clip (< 30 min)",
23
+ "Set Number of speakers to a known value instead of 0",
24
+ "Restart the Space to clear GPU state",
25
+ ],
26
+ }),
27
+ ]
28
+
29
+
30
+ SUBSTRING_REMEDIATION = [
31
+ ("HF_TOKEN", {
32
+ "title": "HUGGING FACE TOKEN MISSING / INVALID",
33
+ "cause": "pyannote diarization requires a valid HF_TOKEN with accepted user agreement.",
34
+ "steps": [
35
+ "Visit huggingface.co/pyannote/speaker-diarization-3.1 and accept the agreement",
36
+ "Also accept pyannote/segmentation-3.0",
37
+ "Create a token at huggingface.co/settings/tokens (read scope)",
38
+ "Add as Space secret HF_TOKEN and restart",
39
+ ],
40
+ }),
41
+ ("401", {
42
+ "title": "AUTHORIZATION DENIED (401)",
43
+ "cause": "Server rejected authentication credentials.",
44
+ "steps": [
45
+ "Verify HF_TOKEN is current and has read scope",
46
+ "Confirm pyannote model agreements are accepted",
47
+ "Restart the Space to pick up new secrets",
48
+ ],
49
+ }),
50
+ ("Unauthorized", {
51
+ "title": "UNAUTHORIZED",
52
+ "cause": "Authentication token rejected or missing.",
53
+ "steps": [
54
+ "Re-issue HF_TOKEN at huggingface.co/settings/tokens",
55
+ "Update Space secret and restart",
56
+ ],
57
+ }),
58
+ ("No speech detected", {
59
+ "title": "NO SPEECH DETECTED",
60
+ "cause": "Whisper found no speech in the input audio.",
61
+ "steps": [
62
+ "Confirm the file actually contains audible speech",
63
+ "Try setting language explicitly instead of auto-detect",
64
+ "Check the audio level — silence-only clips fail VAD",
65
+ ],
66
+ }),
67
+ ]
68
+
69
+
70
+ def _render_html(title: str, cause: str, steps: list) -> str:
71
+ li = "".join(f"<li>{s}</li>" for s in steps)
72
+ return (
73
+ f'<div class="brut-error-card-content">'
74
+ f'<h4>{title}</h4>'
75
+ f'<p><strong>Cause:</strong> {cause}</p>'
76
+ f'<ol>{li}</ol>'
77
+ f'</div>'
78
+ )
79
+
80
+
81
+ def render_error(exc: BaseException) -> str:
82
+ """Match exception → remediation; fall back to generic card with type name."""
83
+ for exc_type, payload in REMEDIATION:
84
+ if isinstance(exc, exc_type):
85
+ return _render_html(payload["title"], payload["cause"], payload["steps"])
86
+
87
+ msg = str(exc)
88
+ for marker, payload in SUBSTRING_REMEDIATION:
89
+ if marker.lower() in msg.lower():
90
+ return _render_html(payload["title"], payload["cause"], payload["steps"])
91
+
92
+ return _render_html(
93
+ title=f"PIPELINE ERROR — {type(exc).__name__}",
94
+ cause=msg or "Unhandled exception in pipeline.",
95
+ steps=[
96
+ "Check server logs for the full traceback",
97
+ "Verify input file is valid audio/video",
98
+ "Restart the Space if the error persists",
99
+ ],
100
+ )
101
+
102
+
103
+ def empty_card() -> str:
104
+ return ""
core/ui/handlers.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Event handlers for the Neobrutalism Gradio app — keeps app.py thin."""
2
+
3
+ import logging
4
+ import os
5
+ import traceback
6
+
7
+ import gradio as gr
8
+ import pandas as pd
9
+
10
+ from core import cache_manager, formatters
11
+ from core.pipeline_runner import OUTPUT_DIR, YOUTUBE_DIR, process_unified
12
+ from core.ui.transcript_view import build_label_map, grouped_row_start_seconds
13
+ from core.youtube_loader import YouTubeAuthRequiredError, download_video
14
+
15
+
16
+ logger = logging.getLogger("whisper-diarization.handlers")
17
+
18
+
19
+ _OUTPUT_KEYS = (
20
+ "df_out", "csv_out", "srt_out", "sysinfo",
21
+ "progress_log", "error_card",
22
+ "merged_state", "wav_state",
23
+ "audio_player", "seek_bus", "video_in",
24
+ )
25
+
26
+
27
+ def _empty_update_dict():
28
+ return {k: gr.update() for k in _OUTPUT_KEYS}
29
+
30
+
31
+ def _to_tuple(d: dict) -> tuple:
32
+ return tuple(d[k] for k in _OUTPUT_KEYS)
33
+
34
+
35
+ def run_unified(yt_url, video_in, language, num_speakers, progress=gr.Progress()):
36
+ """Drive PROCESS button: download (if needed) → stages → final result. Yields tuples in _OUTPUT_KEYS order."""
37
+ log_str = ""
38
+ error_html = ""
39
+
40
+ def emit(**overrides):
41
+ d = _empty_update_dict()
42
+ d["progress_log"] = log_str
43
+ d["error_card"] = error_html
44
+ d.update(overrides)
45
+ return _to_tuple(d)
46
+
47
+ try:
48
+ for kind, payload in process_unified(yt_url, video_in, language, num_speakers, progress):
49
+ if kind == "log":
50
+ log_str = payload
51
+ yield emit()
52
+ elif kind == "video":
53
+ yield emit(video_in=payload)
54
+ elif kind == "error":
55
+ error_html = payload
56
+ yield emit()
57
+ elif kind == "result":
58
+ df, csv, srt, sysinfo, merged, wav_path = payload
59
+ error_html = ""
60
+ yield emit(
61
+ df_out=df, csv_out=csv, srt_out=srt, sysinfo=sysinfo,
62
+ audio_player=str(wav_path), seek_bus="",
63
+ merged_state=merged, wav_state=wav_path,
64
+ )
65
+ except gr.Error:
66
+ yield emit()
67
+ raise
68
+
69
+
70
+ def download_youtube_for_ui(url):
71
+ if not url:
72
+ raise gr.Error("Please enter a YouTube URL first.")
73
+ yield gr.update(value=None, label="Downloading YouTube video..."), "Downloading YouTube video. This may take a few minutes."
74
+ try:
75
+ video_path = download_video(url, YOUTUBE_DIR)
76
+ except YouTubeAuthRequiredError as exc:
77
+ yield gr.update(value=None, label="Video / Audio file"), str(exc)
78
+ return
79
+ except Exception as exc: # noqa: BLE001
80
+ logger.error("YouTube download failed: %s", traceback.format_exc())
81
+ yield gr.update(value=None, label="Video / Audio file"), f"Download failed: {type(exc).__name__}."
82
+ return
83
+ cache_manager.cleanup_old_videos()
84
+ yield gr.update(value=video_path, label="Video / Audio file"), f"Downloaded: `{video_path}`"
85
+
86
+
87
+ def enter_edit_mode(_merged):
88
+ return (
89
+ gr.update(interactive=True),
90
+ gr.update(visible=False), gr.update(visible=True), gr.update(visible=True),
91
+ True,
92
+ )
93
+
94
+
95
+ def cancel_edit(_merged):
96
+ return (
97
+ gr.update(interactive=False),
98
+ gr.update(visible=True), gr.update(visible=False), gr.update(visible=False),
99
+ False,
100
+ )
101
+
102
+
103
+ def apply_renames(edited_df, merged):
104
+ if not merged:
105
+ return (
106
+ gr.update(interactive=False), gr.update(), gr.update(),
107
+ gr.update(visible=True), gr.update(visible=False), gr.update(visible=False),
108
+ False, merged,
109
+ )
110
+ label_map = build_label_map(edited_df, merged)
111
+ new_merged = formatters.rewrite_with_label_map(merged, label_map)
112
+ rows = formatters.build_grouped_rows(new_merged)
113
+ job = os.urandom(4).hex()
114
+ csv_path = formatters.write_csv(rows, OUTPUT_DIR / f"transcript_renamed_{job}.csv")
115
+ srt_path = formatters.write_srt(new_merged, OUTPUT_DIR / f"transcript_renamed_{job}.srt")
116
+ return (
117
+ gr.update(value=pd.DataFrame(rows), interactive=False),
118
+ csv_path, srt_path,
119
+ gr.update(visible=True), gr.update(visible=False), gr.update(visible=False),
120
+ False, new_merged,
121
+ )
122
+
123
+
124
+ def on_row_select(merged, edit_mode, evt: gr.SelectData):
125
+ if not merged or edit_mode or evt is None or evt.index is None:
126
+ return ""
127
+ idx = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index
128
+ start = grouped_row_start_seconds(merged, idx)
129
+ return f'<svg width="0" height="0" onload="window.brutSeek({start})"></svg>'
core/ui/layout.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Two-pane brutalist layout: 4-step stepper + tabbed results dock."""
2
+
3
+ import pandas as pd
4
+ import gradio as gr
5
+
6
+
7
+ LANGUAGES = [
8
+ ("Auto-detect", None), ("English", "en"), ("Japanese", "ja"), ("Chinese", "zh"),
9
+ ("Spanish", "es"), ("French", "fr"), ("German", "de"), ("Korean", "ko"),
10
+ ("Portuguese", "pt"), ("Russian", "ru"), ("Italian", "it"), ("Vietnamese", "vi"),
11
+ ("Arabic", "ar"), ("Hindi", "hi"), ("Indonesian", "id"), ("Dutch", "nl"),
12
+ ("Polish", "pl"), ("Turkish", "tr"), ("Thai", "th"), ("Ukrainian", "uk"),
13
+ ]
14
+
15
+ EXAMPLE_URLS = [
16
+ "https://www.youtube.com/watch?v=j7BfEzAFuYc&t=32s",
17
+ "https://www.youtube.com/watch?v=-UX0X45sYe4",
18
+ "https://www.youtube.com/watch?v=7minSgqi-Gw",
19
+ ]
20
+
21
+
22
+ def _step_header(num: str, title: str) -> gr.HTML:
23
+ return gr.HTML(
24
+ f'<div class="brut-step-header">'
25
+ f'<span class="brut-step-num">{num}</span>'
26
+ f'<h3 class="brut-step-title">{title}</h3>'
27
+ f'</div>'
28
+ )
29
+
30
+
31
+ def build_stepper(languages=LANGUAGES, example_urls=EXAMPLE_URLS) -> dict:
32
+ """4-card stepper: Source / Configure / Process / Review. Returns named handles."""
33
+ handles: dict = {}
34
+
35
+ # Step 1 — Source
36
+ with gr.Group(elem_classes=["brut-step-card", "step-1"]):
37
+ _step_header("01", "SOURCE")
38
+ handles["yt_url"] = gr.Textbox(label="YouTube URL", lines=1, placeholder="https://www.youtube.com/watch?v=...")
39
+ handles["video_in"] = gr.Video(label="Video / Audio file")
40
+ gr.Examples(examples=example_urls, inputs=[handles["yt_url"]], label="YouTube examples")
41
+ with gr.Accordion("Advanced YouTube download", open=False):
42
+ handles["yt_btn"] = gr.Button("Download YouTube video")
43
+ handles["yt_status"] = gr.Markdown()
44
+
45
+ # Step 2 — Configure
46
+ with gr.Group(elem_classes=["brut-step-card", "step-2"]):
47
+ _step_header("02", "CONFIGURE")
48
+ handles["language"] = gr.Dropdown(
49
+ choices=languages, value=None,
50
+ label="Spoken language (auto-detect if blank)",
51
+ )
52
+ handles["num_speakers"] = gr.Number(
53
+ value=0, precision=0,
54
+ label="Number of speakers (0 = auto-detect via pyannote)",
55
+ )
56
+
57
+ # Step 3 — Process
58
+ with gr.Group(elem_classes=["brut-step-card", "step-3"]):
59
+ _step_header("03", "PROCESS")
60
+ handles["run_btn"] = gr.Button("PROCESS", variant="primary", elem_classes="brut-primary")
61
+ gr.HTML('<small style="font-family: var(--brut-font-mono); font-size: 12px;">Downloads YouTube URL or processes uploaded file.</small>')
62
+
63
+ # Step 4 — Review
64
+ with gr.Group(elem_classes=["brut-step-card", "step-4"]):
65
+ _step_header("04", "REVIEW")
66
+ handles["progress_log"] = gr.Markdown(value="", elem_classes="brut-log")
67
+ handles["error_card"] = gr.Markdown(value="", elem_classes="brut-error-card")
68
+
69
+ return handles
70
+
71
+
72
+ def build_results_dock() -> dict:
73
+ """Tabbed results dock: Transcript / Audio / Downloads / Diagnostics."""
74
+ handles: dict = {}
75
+
76
+ with gr.Tabs(elem_classes="brut-tabs"):
77
+ with gr.TabItem("Transcript"):
78
+ gr.HTML(
79
+ '<div class="brut-df-badge">'
80
+ '<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" '
81
+ 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
82
+ 'stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 '
83
+ '0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" '
84
+ 'y2="13"/><line x1="9" y1="17" x2="15" y2="17"/></svg>'
85
+ '<span>Transcript</span>'
86
+ '</div>'
87
+ )
88
+ handles["df_out"] = gr.DataFrame(
89
+ value=pd.DataFrame(columns=["Start", "End", "Speaker", "Text"]),
90
+ show_label=False,
91
+ row_count=(0, "dynamic"),
92
+ wrap=True,
93
+ elem_classes="brut-df",
94
+ interactive=False,
95
+ )
96
+ with gr.Row():
97
+ handles["edit_btn"] = gr.Button("EDIT SPEAKERS")
98
+ handles["apply_btn"] = gr.Button("APPLY RENAMES", variant="primary", visible=False)
99
+ handles["cancel_btn"] = gr.Button("CANCEL", visible=False)
100
+
101
+ with gr.TabItem("Audio"):
102
+ handles["audio_player"] = gr.Audio(
103
+ label="Converted WAV (16kHz mono)",
104
+ interactive=False,
105
+ show_download_button=True,
106
+ elem_id="brut-audio-mount",
107
+ elem_classes="brut-audio-player",
108
+ value=None,
109
+ )
110
+ handles["seek_bus"] = gr.HTML(value="", elem_id="brut-seek-bus")
111
+
112
+ with gr.TabItem("Downloads"):
113
+ handles["csv_out"] = gr.File(label="Download CSV")
114
+ handles["srt_out"] = gr.File(label="Download SRT")
115
+
116
+ with gr.TabItem("Diagnostics"):
117
+ handles["sysinfo"] = gr.Markdown()
118
+
119
+ handles["merged_state"] = gr.State(value=None)
120
+ handles["edit_mode_state"] = gr.State(value=False)
121
+ handles["wav_state"] = gr.State(value=None)
122
+
123
+ return handles
core/ui/transcript_view.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Speaker chip palette, styler, and grouped-row time mapping for transcript DataFrame."""
2
+
3
+ import re
4
+
5
+ import pandas as pd
6
+
7
+
8
+ SPEAKER_PALETTE = [
9
+ "#FFD23F", # yellow
10
+ "#FF6B6B", # coral
11
+ "#5CE1E6", # cyan
12
+ "#A4F47A", # lime
13
+ "#FF6FC0", # magenta
14
+ "#FF9F1C", # orange
15
+ ]
16
+
17
+
18
+ def _extract_index(label: str) -> int:
19
+ """Pull a stable integer index out of a speaker label.
20
+
21
+ SPEAKER_00 → 0, SPK-1 → 1, "Alice" → hash-fallback so renames keep deterministic colors
22
+ only when caller passes original label; once renamed without a digit, fall back to hash.
23
+ """
24
+ if not label:
25
+ return 0
26
+ m = re.search(r"(\d+)", str(label))
27
+ if m:
28
+ return int(m.group(1))
29
+ return abs(hash(str(label))) % len(SPEAKER_PALETTE)
30
+
31
+
32
+ def color_for_speaker(label: str) -> str:
33
+ return SPEAKER_PALETTE[_extract_index(label) % len(SPEAKER_PALETTE)]
34
+
35
+
36
+ def render_chip(label: str) -> str:
37
+ color = color_for_speaker(label)
38
+ return f'<span class="brut-chip" style="background:{color}">▣ {label}</span>'
39
+
40
+
41
+ def style_transcript(df: pd.DataFrame):
42
+ """Return a Styler that paints the Speaker column cell background per palette."""
43
+ if df is None or df.empty or "Speaker" not in df.columns:
44
+ return df
45
+
46
+ def _color(value):
47
+ c = color_for_speaker(value)
48
+ return f"background-color: {c}; font-family: 'Archivo Black', sans-serif;"
49
+
50
+ try:
51
+ return df.style.applymap(_color, subset=["Speaker"])
52
+ except AttributeError:
53
+ # pandas >= 2.1 deprecates applymap → use map
54
+ return df.style.map(_color, subset=["Speaker"])
55
+
56
+
57
+ def grouped_row_start_seconds(merged: list, row_idx: int) -> float:
58
+ """Walk merged segments, group consecutive same-speaker turns, return start seconds of group at row_idx."""
59
+ if not merged or row_idx is None or row_idx < 0:
60
+ return 0.0
61
+
62
+ group_idx = -1
63
+ prev_speaker = None
64
+ for seg in merged:
65
+ spk = seg.get("speaker")
66
+ if spk != prev_speaker:
67
+ group_idx += 1
68
+ if group_idx == row_idx:
69
+ return float(seg.get("start", 0.0))
70
+ prev_speaker = spk
71
+ return 0.0
72
+
73
+
74
+ def build_label_map(edited_df, merged: list) -> dict:
75
+ """From edited DataFrame Speaker column + original merged segments, build {orig_label → new_label}.
76
+
77
+ Walks grouped segments in order, mapping each group's original speaker label to the
78
+ corresponding row's edited Speaker value.
79
+ """
80
+ if edited_df is None or merged is None:
81
+ return {}
82
+
83
+ if hasattr(edited_df, "values"):
84
+ rows = edited_df.values.tolist() if isinstance(edited_df, pd.DataFrame) else edited_df
85
+ if isinstance(edited_df, pd.DataFrame) and "Speaker" in edited_df.columns:
86
+ new_labels = edited_df["Speaker"].tolist()
87
+ else:
88
+ new_labels = [r[2] for r in rows if len(r) > 2]
89
+ elif isinstance(edited_df, list):
90
+ new_labels = [r[2] for r in edited_df if len(r) > 2]
91
+ else:
92
+ return {}
93
+
94
+ label_map: dict = {}
95
+ group_idx = -1
96
+ prev_speaker = None
97
+ for seg in merged:
98
+ spk = seg.get("speaker")
99
+ if spk != prev_speaker:
100
+ group_idx += 1
101
+ if group_idx < len(new_labels):
102
+ new_label = str(new_labels[group_idx]).strip()
103
+ if new_label and new_label != spk:
104
+ label_map[spk] = new_label
105
+ prev_speaker = spk
106
+ return label_map