Spaces:
Runtime error
Runtime error
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 +7 -0
- app.py +106 -162
- cookies.txt +4 -4
- core/formatters.py +14 -0
- core/pipeline_runner.py +183 -0
- core/styles/__init__.py +0 -0
- core/styles/neobrutalism.css +662 -0
- core/styles/seek.js +17 -0
- core/ui/__init__.py +23 -0
- core/ui/error_card.py +104 -0
- core/ui/handlers.py +129 -0
- core/ui/layout.py +123 -0
- core/ui/transcript_view.py +106 -0
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 (
|
| 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
|
| 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 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
)
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
yield (
|
| 121 |
-
gr.update(value=video_path, label="Video / Audio file"),
|
| 122 |
-
f"Downloaded video: `{video_path}`",
|
| 123 |
)
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 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 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 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
|
| 77 |
-
.youtube.com TRUE / TRUE
|
| 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
|
| 87 |
-
.youtube.com TRUE / TRUE 0 YSC
|
| 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
|