Vishal Patil commited on
Commit
4906d6d
Β·
verified Β·
1 Parent(s): 0a6f2ab

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +148 -0
  2. pipeline.py +206 -0
  3. requirements.txt +23 -0
app.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradio Web UI for Speech Annotation Pipeline
3
+ Run: python app.py
4
+ """
5
+
6
+ import json
7
+ import csv
8
+ import io
9
+ import os
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ import gradio as gr
14
+ from pipeline import get_pipeline, to_json, to_csv, to_abab_text
15
+
16
+ UPLOAD_DIR = Path(tempfile.gettempdir()) / "speech_annotation"
17
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
18
+
19
+ # ── State ─────────────────────────────────────────────────────────────────────
20
+ _last_segments = [] # module-level cache for export
21
+
22
+
23
+ # ── Core processing ───────────────────────────────────────────────────────────
24
+ def process_audio(audio_path: str):
25
+ global _last_segments
26
+ if audio_path is None:
27
+ return "⚠️ Please upload an audio file.", "", None, None
28
+
29
+ try:
30
+ pipeline = get_pipeline()
31
+ segments = pipeline.process(audio_path)
32
+ except EnvironmentError as e:
33
+ return f"❌ Config error:\n{e}", "", None, None
34
+ except Exception as e:
35
+ return f"❌ Pipeline error:\n{e}", "", None, None
36
+
37
+ if not segments:
38
+ return "⚠️ No speech detected.", "", None, None
39
+
40
+ _last_segments = segments
41
+ abab = to_abab_text(segments)
42
+
43
+ # Build HTML table
44
+ rows = "".join(
45
+ f"<tr><td><b>{s.speaker}</b></td><td>{s.start_fmt}</td><td>{s.end_fmt}</td><td>{s.text}</td></tr>"
46
+ for s in segments
47
+ )
48
+ table = f"""
49
+ <table style='width:100%;border-collapse:collapse;font-family:monospace;font-size:14px'>
50
+ <thead style='background:#1e3a5f;color:white'>
51
+ <tr>
52
+ <th style='padding:8px;text-align:left'>Speaker</th>
53
+ <th style='padding:8px'>Start</th>
54
+ <th style='padding:8px'>End</th>
55
+ <th style='padding:8px;text-align:left'>Transcript</th>
56
+ </tr>
57
+ </thead>
58
+ <tbody>
59
+ {''.join(f"<tr style='background:{'#f0f4ff' if i%2==0 else 'white'}'>" +
60
+ f"<td style='padding:6px 8px;font-weight:bold;color:#1e3a5f'>{s.speaker}</td>" +
61
+ f"<td style='padding:6px 8px;color:#555'>{s.start_fmt}</td>" +
62
+ f"<td style='padding:6px 8px;color:#555'>{s.end_fmt}</td>" +
63
+ f"<td style='padding:6px 8px'>{s.text}</td></tr>"
64
+ for i, s in enumerate(segments))}
65
+ </tbody>
66
+ </table>
67
+ """
68
+
69
+ status = f"βœ… Done β€” {len(segments)} segments detected"
70
+ return status, abab, table
71
+
72
+
73
+ def export_json():
74
+ if not _last_segments:
75
+ return None
76
+ out = UPLOAD_DIR / "annotation_export.json"
77
+ to_json(_last_segments, str(out))
78
+ return str(out)
79
+
80
+
81
+ def export_csv():
82
+ if not _last_segments:
83
+ return None
84
+ out = UPLOAD_DIR / "annotation_export.csv"
85
+ to_csv(_last_segments, str(out))
86
+ return str(out)
87
+
88
+
89
+ # ── UI ────────────────────────────────────────────────────────────────────────
90
+ CSS = """
91
+ #title { text-align: center; font-size: 28px; font-weight: bold; color: #1e3a5f; margin-bottom: 4px; }
92
+ #subtitle { text-align: center; color: #666; margin-bottom: 20px; }
93
+ .gr-button-primary { background: #1e3a5f !important; }
94
+ """
95
+
96
+ with gr.Blocks(css=CSS, title="Speech Annotation Pipeline") as demo:
97
+
98
+ gr.HTML("<div id='title'>πŸŽ™οΈ Speech Annotation Pipeline</div>")
99
+ gr.HTML("<div id='subtitle'>Upload audio β†’ get speaker-labeled transcripts automatically</div>")
100
+
101
+ with gr.Row():
102
+ with gr.Column(scale=1):
103
+ audio_input = gr.Audio(
104
+ label="Upload Audio (.wav / .mp3 / .flac)",
105
+ type="filepath",
106
+ )
107
+ run_btn = gr.Button("β–Ά Run Annotation", variant="primary")
108
+ status_box = gr.Textbox(label="Status", interactive=False, lines=1)
109
+
110
+ gr.Markdown("### πŸ“₯ Export Results")
111
+ with gr.Row():
112
+ json_btn = gr.Button("Export JSON")
113
+ csv_btn = gr.Button("Export CSV")
114
+ json_file = gr.File(label="JSON Download", visible=True)
115
+ csv_file = gr.File(label="CSV Download", visible=True)
116
+
117
+ with gr.Column(scale=2):
118
+ gr.Markdown("### πŸ“ Editable Transcript (ABAB format)")
119
+ abab_box = gr.Textbox(
120
+ label="Edit transcript below",
121
+ lines=20,
122
+ placeholder="Annotations will appear here after processing…",
123
+ )
124
+ gr.Markdown("### πŸ“Š Segment Table")
125
+ table_html = gr.HTML()
126
+
127
+ # Events
128
+ run_btn.click(
129
+ fn=process_audio,
130
+ inputs=[audio_input],
131
+ outputs=[status_box, abab_box, table_html],
132
+ )
133
+
134
+ json_btn.click(fn=export_json, inputs=[], outputs=[json_file])
135
+ csv_btn.click(fn=export_csv, inputs=[], outputs=[csv_file])
136
+
137
+ gr.Markdown(
138
+ """
139
+ ---
140
+ **Tips:**
141
+ - Set `HF_TOKEN` env var before running (required for speaker diarization)
142
+ - GPU strongly recommended for files > 5 min
143
+ - Edits in the transcript box are yours to save β€” export when ready
144
+ """
145
+ )
146
+
147
+ if __name__ == "__main__":
148
+ demo.launch(server_name="0.0.0.0", server_port=7860, share=False)
pipeline.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Speech Annotation Pipeline
3
+ - Voice Activity Detection (VAD) via pyannote
4
+ - Speaker Diarization via pyannote.audio
5
+ - Transcription via WhisperX
6
+ - Combines into structured ABAB-style annotations
7
+ """
8
+
9
+ import os
10
+ import json
11
+ import csv
12
+ import time
13
+ import logging
14
+ from pathlib import Path
15
+ from dataclasses import dataclass, asdict
16
+ from typing import List, Optional
17
+
18
+ import torch
19
+ import whisperx
20
+ from pyannote.audio import Pipeline as PyannotePipeline
21
+
22
+ logging.basicConfig(level=logging.INFO)
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # ── Config ────────────────────────────────────────────────────────────────────
26
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
27
+ COMPUTE_TYPE = "float16" if DEVICE == "cuda" else "int8"
28
+ HF_TOKEN = os.environ.get("HF_TOKEN", "") # Required for pyannote models
29
+ WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "base") # base / small / medium / large-v2
30
+
31
+
32
+ # ── Data model ────────────────────────────────────────────────────────────────
33
+ @dataclass
34
+ class AnnotationSegment:
35
+ speaker: str # A, B, C …
36
+ start: float # seconds
37
+ end: float # seconds
38
+ text: str
39
+
40
+ @property
41
+ def start_fmt(self) -> str:
42
+ m, s = divmod(int(self.start), 60)
43
+ return f"{m:02d}:{s:02d}"
44
+
45
+ @property
46
+ def end_fmt(self) -> str:
47
+ m, s = divmod(int(self.end), 60)
48
+ return f"{m:02d}:{s:02d}"
49
+
50
+ def to_abab(self) -> str:
51
+ return f"{self.speaker} {self.start_fmt} {self.text}"
52
+
53
+
54
+ # ── Label mapper ──────────────────────────────────────────────────────────────
55
+ def _build_speaker_map(raw_labels: List[str]) -> dict:
56
+ """Map pyannote speaker IDs (SPEAKER_00, …) to A, B, C, …"""
57
+ letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
58
+ seen, mapping = [], {}
59
+ for lbl in raw_labels:
60
+ if lbl not in seen:
61
+ seen.append(lbl)
62
+ for i, lbl in enumerate(seen):
63
+ mapping[lbl] = letters[i] if i < len(letters) else f"SPK{i}"
64
+ return mapping
65
+
66
+
67
+ # ── Core pipeline ─────────────────────────────────────────────────────────────
68
+ class SpeechAnnotationPipeline:
69
+ def __init__(self):
70
+ self._whisper_model = None
71
+ self._diarization_pipeline = None
72
+
73
+ # --- lazy loaders ---
74
+ def _load_whisper(self):
75
+ if self._whisper_model is None:
76
+ logger.info(f"Loading WhisperX model '{WHISPER_MODEL}' on {DEVICE} …")
77
+ self._whisper_model = whisperx.load_model(
78
+ WHISPER_MODEL, DEVICE, compute_type=COMPUTE_TYPE
79
+ )
80
+ return self._whisper_model
81
+
82
+ def _load_diarization(self):
83
+ if self._diarization_pipeline is None:
84
+ if not HF_TOKEN:
85
+ raise EnvironmentError(
86
+ "HF_TOKEN environment variable is required for pyannote diarization. "
87
+ "Get a token at https://huggingface.co/settings/tokens and accept the "
88
+ "model licence at https://hf.co/pyannote/speaker-diarization-3.1"
89
+ )
90
+ logger.info("Loading pyannote diarization pipeline …")
91
+ self._diarization_pipeline = PyannotePipeline.from_pretrained(
92
+ "pyannote/speaker-diarization-3.1",
93
+ use_auth_token=HF_TOKEN,
94
+ )
95
+ if DEVICE == "cuda":
96
+ self._diarization_pipeline = self._diarization_pipeline.to(torch.device("cuda"))
97
+ return self._diarization_pipeline
98
+
99
+ # --- steps ---
100
+ def _transcribe(self, audio_path: str) -> dict:
101
+ logger.info("Step 1/3 – Transcribing with WhisperX …")
102
+ model = self._load_whisper()
103
+ audio = whisperx.load_audio(audio_path)
104
+ result = model.transcribe(audio, batch_size=16)
105
+
106
+ logger.info("Step 1b – Aligning word timestamps …")
107
+ align_model, metadata = whisperx.load_align_model(
108
+ language_code=result["language"], device=DEVICE
109
+ )
110
+ result = whisperx.align(
111
+ result["segments"], align_model, metadata, audio, DEVICE,
112
+ return_char_alignments=False,
113
+ )
114
+ return result
115
+
116
+ def _diarize(self, audio_path: str) -> list:
117
+ logger.info("Step 2/3 – Speaker diarization …")
118
+ pipeline = self._load_diarization()
119
+ diarization = pipeline(audio_path)
120
+ segments = []
121
+ for turn, _, speaker in diarization.itertracks(yield_label=True):
122
+ segments.append({"start": turn.start, "end": turn.end, "speaker": speaker})
123
+ return segments
124
+
125
+ def _assign_speakers(self, transcript_segments: list, diarization_segments: list) -> List[AnnotationSegment]:
126
+ """Assign speaker labels to transcript segments using maximum-overlap rule."""
127
+ logger.info("Step 3/3 – Merging transcription + diarization …")
128
+
129
+ raw_labels = [d["speaker"] for d in diarization_segments]
130
+ speaker_map = _build_speaker_map(raw_labels)
131
+
132
+ results = []
133
+ for seg in transcript_segments:
134
+ t_start, t_end = seg["start"], seg["end"]
135
+ text = seg.get("text", "").strip()
136
+ if not text:
137
+ continue
138
+
139
+ # Find diarization segment with max overlap
140
+ best_speaker, best_overlap = "A", 0.0
141
+ for d in diarization_segments:
142
+ overlap = max(0, min(t_end, d["end"]) - max(t_start, d["start"]))
143
+ if overlap > best_overlap:
144
+ best_overlap = overlap
145
+ best_speaker = d["speaker"]
146
+
147
+ results.append(AnnotationSegment(
148
+ speaker=speaker_map.get(best_speaker, best_speaker),
149
+ start=t_start,
150
+ end=t_end,
151
+ text=text,
152
+ ))
153
+ return results
154
+
155
+ # --- public API ---
156
+ def process(self, audio_path: str) -> List[AnnotationSegment]:
157
+ t0 = time.time()
158
+ audio_path = str(Path(audio_path).resolve())
159
+
160
+ transcript = self._transcribe(audio_path)
161
+ diarization = self._diarize(audio_path)
162
+ annotations = self._assign_speakers(transcript["segments"], diarization)
163
+
164
+ elapsed = time.time() - t0
165
+ logger.info(f"Done in {elapsed:.1f}s – {len(annotations)} segments")
166
+ return annotations
167
+
168
+
169
+ # ── Export helpers ─────────────────────────────────────────────────────────────
170
+ def to_json(segments: List[AnnotationSegment], path: str):
171
+ data = [
172
+ {
173
+ "speaker": s.speaker,
174
+ "start": round(s.start, 3),
175
+ "end": round(s.end, 3),
176
+ "start_fmt": s.start_fmt,
177
+ "end_fmt": s.end_fmt,
178
+ "text": s.text,
179
+ }
180
+ for s in segments
181
+ ]
182
+ with open(path, "w", encoding="utf-8") as f:
183
+ json.dump(data, f, indent=2, ensure_ascii=False)
184
+
185
+
186
+ def to_csv(segments: List[AnnotationSegment], path: str):
187
+ with open(path, "w", newline="", encoding="utf-8") as f:
188
+ writer = csv.writer(f)
189
+ writer.writerow(["speaker", "start", "end", "text"])
190
+ for s in segments:
191
+ writer.writerow([s.speaker, s.start_fmt, s.end_fmt, s.text])
192
+
193
+
194
+ def to_abab_text(segments: List[AnnotationSegment]) -> str:
195
+ return "\n".join(s.to_abab() for s in segments)
196
+
197
+
198
+ # ── Singleton instance ─────────────────────────────────────────────────────────
199
+ _pipeline_instance: Optional[SpeechAnnotationPipeline] = None
200
+
201
+
202
+ def get_pipeline() -> SpeechAnnotationPipeline:
203
+ global _pipeline_instance
204
+ if _pipeline_instance is None:
205
+ _pipeline_instance = SpeechAnnotationPipeline()
206
+ return _pipeline_instance
requirements.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Core ML ───────────────────────────────────────────────────────────────────
2
+ torch>=2.1.0
3
+ torchaudio>=2.1.0
4
+
5
+ # WhisperX (transcription + alignment)
6
+ whisperx>=3.1.1
7
+
8
+ # pyannote (diarization + VAD)
9
+ pyannote.audio>=3.1.1
10
+
11
+ # ── API ───────────────────────────────────────────────────────────────────────
12
+ fastapi>=0.110.0
13
+ uvicorn[standard]>=0.29.0
14
+ python-multipart>=0.0.9 # required for FastAPI file uploads
15
+
16
+ # ── UI ────────────────────────────────────────────────────────────────────────
17
+ gradio>=4.26.0
18
+
19
+ # ── Utilities ─────────────────────────────────────────────────────────────────
20
+ pydantic>=2.0.0
21
+ numpy>=1.24.0
22
+ soundfile>=0.12.1
23
+ librosa>=0.10.1