STBack23 commited on
Commit
eb55bfb
·
verified ·
1 Parent(s): 40b0078

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. diarize_audio.py +913 -837
  2. requirements-diarize.txt +24 -24
  3. translate_srt.py +16 -13
diarize_audio.py CHANGED
@@ -1,837 +1,913 @@
1
- """Speaker diarization + gender estimation for subtitle cues.
2
-
3
- This module answers "who spoke when" using pyannote.audio and attaches a stable
4
- speaker label (and a coarse male/female/unknown gender guess) to every SRT cue.
5
- Gemma then uses those labels to keep Vietnamese forms of address / pronouns
6
- (ta, hắn, y, muội muội, tỷ tỷ, phu quân, ...) consistent for each character.
7
-
8
- Pipeline:
9
- extract_audio (ffmpeg -> 16k mono wav) ->
10
- run_diarization (pyannote community-1) -> list[SpeakerTurn] ->
11
- estimate_speaker_genders (median F0 per speaker, optional) ->
12
- assign_speakers_to_cues (max temporal overlap)
13
-
14
- Heavy dependencies (pyannote.audio, torch, torchaudio) are imported lazily so
15
- that the rest of the project keeps working even when they are not installed.
16
- The Hugging Face access token is required by pyannote and is read, in order,
17
- from the explicit argument, then the ``HF_TOKEN`` / ``HUGGINGFACE_TOKEN`` env
18
- vars.
19
- """
20
-
21
- from __future__ import annotations
22
-
23
- import os
24
- import subprocess
25
- import sys
26
- import tempfile
27
- import time
28
- from dataclasses import dataclass
29
- from pathlib import Path
30
- from typing import TYPE_CHECKING, Any, Callable
31
-
32
- if TYPE_CHECKING: # pragma: no cover - typing only
33
- from translate_srt import Cue
34
-
35
- # Preferred open-source pipeline (pyannote.audio 4.x). Falls back to the legacy
36
- # 3.1 pipeline if the newer one is unavailable for the installed version.
37
- DEFAULT_PIPELINE = "pyannote/speaker-diarization-community-1"
38
- FALLBACK_PIPELINE = "pyannote/speaker-diarization-3.1"
39
-
40
- def _load_default_token() -> str:
41
- """Local convenience token, read from a sibling file that is NOT uploaded.
42
-
43
- Put your token in ``hf_token.local`` (or ``hf_token.txt``) next to this file
44
- to have local runs pick it up automatically. The file is git-ignored and is
45
- never copied by ``prepare_upload.ps1`` so the public code stays secret-free.
46
- """
47
- for name in ("hf_token.local", "hf_token.txt"):
48
- p = Path(__file__).with_name(name)
49
- try:
50
- if p.is_file():
51
- tok = p.read_text(encoding="utf-8").strip()
52
- if tok:
53
- return tok
54
- except OSError:
55
- pass
56
- return ""
57
-
58
-
59
- # Built-in fallback Hugging Face token (used when no arg/env token is supplied).
60
- # Loaded from a local-only file so the public source never embeds a secret.
61
- DEFAULT_HF_TOKEN = _load_default_token()
62
-
63
- _SUBPROCESS_FLAGS = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
64
-
65
- # Coarse pitch thresholds (Hz) for median fundamental frequency per speaker.
66
- # A deliberate "unknown" band avoids confidently mislabelling ambiguous voices.
67
- # Male speech F0 is typically ~85-180 Hz and female ~165-255 Hz, so the bands
68
- # overlap. We label only confident cases and leave the overlap as "unknown" so
69
- # the translator infers gender from dialogue/context instead of guessing wrong.
70
- _MALE_F0_MAX = 150.0
71
- _FEMALE_F0_MIN = 195.0
72
- _F0_MIN_HZ = 65.0
73
- _F0_MAX_HZ = 400.0
74
-
75
- # Dedicated speech model that predicts age (0..100) and gender (female/male/child).
76
- DEFAULT_AGE_GENDER_MODEL = "audeering/wav2vec2-large-robust-24-ft-age-gender"
77
- # The model's gender head order is [female, male, child].
78
- _AGE_GENDER_LABELS = ("female", "male", "child")
79
-
80
- # Cached (model, processor, device) so we only load the ~1 GB model once.
81
- _AGE_GENDER_CACHE: dict[str, Any] = {}
82
-
83
-
84
- def age_to_group(age_years: float | None, gender: str = "") -> str:
85
- """Bucket an age (years) into a coarse life-stage label.
86
-
87
- Returns one of "child", "teen", "young_adult", "middle_aged", "elderly" or
88
- "" when age is unknown.
89
- """
90
- if gender == "child":
91
- return "child"
92
- if age_years is None:
93
- return ""
94
- if age_years < 13:
95
- return "child"
96
- if age_years < 20:
97
- return "teen"
98
- if age_years < 40:
99
- return "young_adult"
100
- if age_years < 60:
101
- return "middle_aged"
102
- return "elderly"
103
-
104
-
105
- LogFn = Callable[[str], None]
106
-
107
-
108
- def _noop(_msg: str) -> None:
109
- pass
110
-
111
-
112
- def _fmt_dur(seconds: float) -> str:
113
- """Human-readable duration, e.g. '1h 23m' or '4m 12s'."""
114
- if seconds < 0:
115
- seconds = 0.0
116
- s = int(round(seconds))
117
- if s >= 3600:
118
- h, rem = divmod(s, 3600)
119
- m, sec = divmod(rem, 60)
120
- return f"{h}h {m}m" if sec < 30 else f"{h}h {m}m {sec}s"
121
- if s >= 60:
122
- m, sec = divmod(s, 60)
123
- return f"{m}m {sec}s"
124
- return f"{s}s"
125
-
126
-
127
- def _log_step(log: LogFn, step: int, total: int, msg: str) -> None:
128
- log(f"[diarize] [{step}/{total}] {msg}")
129
-
130
-
131
- def _audio_duration_sec(waveform, sample_rate: int) -> float:
132
- try:
133
- n = int(waveform.shape[-1])
134
- return n / float(sample_rate) if sample_rate else 0.0
135
- except Exception: # noqa: BLE001
136
- return 0.0
137
-
138
-
139
- @dataclass
140
- class SpeakerTurn:
141
- start: float
142
- end: float
143
- speaker: str
144
-
145
- @property
146
- def duration(self) -> float:
147
- return max(0.0, self.end - self.start)
148
-
149
-
150
- # --------------------------------------------------------------------------- #
151
- # Token handling
152
- # --------------------------------------------------------------------------- #
153
- def resolve_hf_token(token: str | None) -> str | None:
154
- if token and token.strip():
155
- return token.strip()
156
- for env in ("HF_TOKEN", "HUGGINGFACE_TOKEN", "HUGGING_FACE_HUB_TOKEN"):
157
- val = os.environ.get(env)
158
- if val and val.strip():
159
- return val.strip()
160
- if DEFAULT_HF_TOKEN and DEFAULT_HF_TOKEN.strip():
161
- return DEFAULT_HF_TOKEN.strip()
162
- return None
163
-
164
-
165
- # --------------------------------------------------------------------------- #
166
- # Audio extraction
167
- # --------------------------------------------------------------------------- #
168
- def extract_audio(
169
- video: Path,
170
- out_wav: Path,
171
- sample_rate: int = 16000,
172
- max_seconds: float | None = None,
173
- ) -> Path:
174
- """Extract a 16 kHz mono PCM wav from *video* (what pyannote expects).
175
-
176
- When *max_seconds* is given, only that many seconds from the start are
177
- extracted (so diarization scales with the number of cues being processed).
178
- """
179
- cmd = [
180
- "ffmpeg",
181
- "-hide_banner",
182
- "-loglevel",
183
- "error",
184
- "-i",
185
- str(video),
186
- "-vn",
187
- "-ac",
188
- "1",
189
- "-ar",
190
- str(sample_rate),
191
- ]
192
- if max_seconds and max_seconds > 0:
193
- cmd += ["-t", f"{max_seconds:.3f}"]
194
- cmd += [
195
- "-c:a",
196
- "pcm_s16le",
197
- "-y",
198
- str(out_wav),
199
- ]
200
- proc = subprocess.run(
201
- cmd, capture_output=True, timeout=600, creationflags=_SUBPROCESS_FLAGS
202
- )
203
- if proc.returncode != 0:
204
- raise RuntimeError(
205
- "ffmpeg audio extraction failed: "
206
- + proc.stderr.decode("utf-8", "replace").strip()
207
- )
208
- if not out_wav.exists():
209
- raise RuntimeError(f"Audio extraction produced no file at {out_wav}")
210
- return out_wav
211
-
212
-
213
- # --------------------------------------------------------------------------- #
214
- # Diarization
215
- # --------------------------------------------------------------------------- #
216
- def _pick_device(preferred: str = "auto") -> str:
217
- if preferred and preferred != "auto":
218
- return preferred
219
- try:
220
- import torch # noqa: PLC0415
221
-
222
- return "cuda" if torch.cuda.is_available() else "cpu"
223
- except Exception: # noqa: BLE001
224
- return "cpu"
225
-
226
-
227
- def _patch_speechbrain_lazy(log: LogFn = _noop) -> None:
228
- """Work around a speechbrain LazyModule bug that breaks pyannote on Windows.
229
-
230
- ``speechbrain.utils.importutils.LazyModule.ensure_module`` tries to skip the
231
- lazy import when the caller is ``inspect.py`` (e.g. ``inspect.getmodule``
232
- probing ``__file__``), but it only matches the POSIX ``/inspect.py`` path. On
233
- Windows the path uses ``\\`` so the guard misses, the lazy module (e.g.
234
- ``speechbrain.integrations.k2_fsa``) is force-imported, and it raises
235
- ``ImportError`` — which ``hasattr`` propagates, crashing pipeline loading.
236
-
237
- We re-implement ``ensure_module`` with an OS-agnostic ``inspect.py`` check.
238
- """
239
- try:
240
- import importlib as _importlib # noqa: PLC0415
241
- import inspect as _inspect # noqa: PLC0415
242
- import os as _os # noqa: PLC0415
243
- import sys as _sys # noqa: PLC0415
244
-
245
- from speechbrain.utils import importutils as iu # noqa: PLC0415
246
- except Exception: # noqa: BLE001 - speechbrain not used by this pipeline
247
- return
248
-
249
- if getattr(iu.LazyModule, "_gemma_patched", False):
250
- return
251
-
252
- def ensure_module(self, stacklevel: int):
253
- importer_frame = None
254
- try:
255
- importer_frame = _inspect.getframeinfo(_sys._getframe(stacklevel + 1))
256
- except (AttributeError, ValueError):
257
- importer_frame = None
258
- if (
259
- importer_frame is not None
260
- and _os.path.basename(importer_frame.filename) == "inspect.py"
261
- ):
262
- raise AttributeError()
263
- if self.lazy_module is None:
264
- try:
265
- if self.package is None:
266
- self.lazy_module = _importlib.import_module(self.target)
267
- else:
268
- self.lazy_module = _importlib.import_module(
269
- f".{self.target}", self.package
270
- )
271
- except Exception as e: # noqa: BLE001
272
- raise ImportError(f"Lazy import of {self!r} failed") from e
273
- return self.lazy_module
274
-
275
- iu.LazyModule.ensure_module = ensure_module
276
- iu.LazyModule._gemma_patched = True
277
- log("[diarize] applied speechbrain LazyModule Windows compatibility patch.")
278
-
279
-
280
- def _load_pipeline(hf_token: str, device: str, log: LogFn):
281
- try:
282
- from pyannote.audio import Pipeline # noqa: PLC0415
283
- except ImportError as exc: # pragma: no cover - env dependent
284
- raise RuntimeError(
285
- "pyannote.audio is not installed. Install diarization extras:\n"
286
- " pip install -r requirements-diarize.txt"
287
- ) from exc
288
-
289
- import torch # noqa: PLC0415
290
-
291
- _patch_speechbrain_lazy(log)
292
-
293
- last_err: Exception | None = None
294
- for model_id in (DEFAULT_PIPELINE, FALLBACK_PIPELINE):
295
- try:
296
- log(f"[diarize] loading pipeline {model_id} ...")
297
- pipeline = Pipeline.from_pretrained(model_id, token=hf_token)
298
- if pipeline is None:
299
- raise RuntimeError(
300
- "Pipeline.from_pretrained returned None — the HF token is "
301
- "likely invalid or the model conditions were not accepted at "
302
- f"https://hf.co/{model_id}"
303
- )
304
- try:
305
- pipeline.to(torch.device(device))
306
- except Exception: # noqa: BLE001 - keep CPU pipeline if .to fails
307
- pass
308
- log(f"[diarize] pipeline ready: {model_id} on {device}.")
309
- return pipeline
310
- except Exception as exc: # noqa: BLE001
311
- last_err = exc
312
- log(f"[diarize] could not load {model_id}: {exc}")
313
- raise RuntimeError(
314
- "Failed to load any diarization pipeline. Most common causes:\n"
315
- " 1) HF token's account has not accepted ALL gated model conditions (Agree):\n"
316
- f" https://hf.co/{DEFAULT_PIPELINE}\n"
317
- f" https://hf.co/{FALLBACK_PIPELINE}\n"
318
- " https://hf.co/pyannote/segmentation-3.0\n"
319
- " 2) On Colab: pytorch-lightning version mismatch (community-1).\n"
320
- f"Last error: {last_err}"
321
- )
322
-
323
-
324
- def load_wav(wav: Path):
325
- """Load a wav into an in-memory (channel, time) float32 tensor + sample rate.
326
-
327
- Uses ``soundfile`` instead of ``torchaudio.load`` / torchcodec so audio
328
- decoding does not depend on a working torchcodec/ffmpeg-DLL setup.
329
- """
330
- import soundfile as sf # noqa: PLC0415
331
- import torch # noqa: PLC0415
332
-
333
- data, sr = sf.read(str(wav), dtype="float32", always_2d=True) # (time, channels)
334
- waveform = torch.from_numpy(data.T).contiguous() # (channels, time)
335
- if waveform.shape[0] > 1:
336
- waveform = waveform.mean(dim=0, keepdim=True)
337
- return waveform, int(sr)
338
-
339
-
340
- def _as_annotation(diarization):
341
- """Return a pyannote Annotation (with ``itertracks``) from a pipeline result.
342
-
343
- pyannote.audio 4.x (community-1) returns a ``DiarizeOutput`` exposing
344
- ``speaker_diarization`` and ``exclusive_speaker_diarization``; the legacy 3.1
345
- pipeline returns an ``Annotation`` directly. Exclusive diarization assigns at
346
- most one speaker per instant, which is ideal for tagging subtitle cues.
347
- """
348
- if hasattr(diarization, "itertracks"):
349
- return diarization
350
- for attr in ("exclusive_speaker_diarization", "speaker_diarization"):
351
- candidate = getattr(diarization, attr, None)
352
- if candidate is not None and hasattr(candidate, "itertracks"):
353
- return candidate
354
- raise RuntimeError(
355
- f"Unexpected diarization output type without itertracks: {type(diarization)!r}"
356
- )
357
-
358
-
359
- def run_diarization(
360
- waveform,
361
- sample_rate: int,
362
- hf_token: str,
363
- num_speakers: int | None = None,
364
- min_speakers: int | None = None,
365
- max_speakers: int | None = None,
366
- device: str = "auto",
367
- log: LogFn = _noop,
368
- ) -> list[SpeakerTurn]:
369
- """Run pyannote diarization and return merged speaker turns sorted by time."""
370
- dev = _pick_device(device)
371
- pipeline = _load_pipeline(hf_token, dev, log)
372
-
373
- kwargs: dict[str, int] = {}
374
- if num_speakers and num_speakers > 0:
375
- kwargs["num_speakers"] = num_speakers
376
- else:
377
- if min_speakers and min_speakers > 0:
378
- kwargs["min_speakers"] = min_speakers
379
- if max_speakers and max_speakers > 0:
380
- kwargs["max_speakers"] = max_speakers
381
-
382
- dur = _audio_duration_sec(waveform, sample_rate)
383
- kw_hint = ", ".join(f"{k}={v}" for k, v in kwargs.items()) or "auto-detect speakers"
384
- log(
385
- f"[diarize] running pyannote on {_fmt_dur(dur)} audio "
386
- f"({dev}, {kw_hint}) — có thể mất vài phút đến ~1h với phim dài..."
387
- )
388
- t0 = time.time()
389
- audio = {"waveform": waveform, "sample_rate": sample_rate}
390
- diarization = pipeline(audio, **kwargs)
391
- log(f"[diarize] pyannote inference done in {_fmt_dur(time.time() - t0)}.")
392
- annotation = _as_annotation(diarization)
393
-
394
- turns: list[SpeakerTurn] = []
395
- for segment, _track, speaker in annotation.itertracks(yield_label=True):
396
- turns.append(SpeakerTurn(float(segment.start), float(segment.end), str(speaker)))
397
- turns.sort(key=lambda t: (t.start, t.end))
398
- n_spk = len({t.speaker for t in turns})
399
- log(f"[diarize] found {n_spk} speaker(s) across {len(turns)} turn(s).")
400
- return turns
401
-
402
-
403
- # --------------------------------------------------------------------------- #
404
- # Gender estimation (coarse, pitch based)
405
- # --------------------------------------------------------------------------- #
406
- def estimate_speaker_genders(
407
- waveform,
408
- sr: int,
409
- turns: list[SpeakerTurn],
410
- *,
411
- max_seconds_per_speaker: float = 30.0,
412
- log: LogFn = _noop,
413
- ) -> dict[str, str]:
414
- """Estimate male/female/unknown per speaker from median fundamental frequency.
415
-
416
- This is a lightweight heuristic (no extra model download) meant only as a
417
- hint for the translator. Returns a mapping ``{speaker_label: gender}`` where
418
- gender is one of ``"male"``, ``"female"`` or ``"unknown"``. *waveform* is a
419
- ``(channel, time)`` float32 tensor as returned by :func:`load_wav`.
420
- """
421
- try:
422
- import torch # noqa: PLC0415
423
- import torchaudio.functional as AF # noqa: PLC0415
424
- except Exception as exc: # noqa: BLE001
425
- log(f"[diarize] gender estimation skipped (torch/torchaudio missing): {exc}")
426
- return {}
427
-
428
- if waveform.ndim == 1:
429
- waveform = waveform.unsqueeze(0)
430
- elif waveform.shape[0] > 1:
431
- waveform = waveform.mean(dim=0, keepdim=True)
432
-
433
- by_speaker: dict[str, list[SpeakerTurn]] = {}
434
- for t in turns:
435
- by_speaker.setdefault(t.speaker, []).append(t)
436
-
437
- genders: dict[str, str] = {}
438
- for speaker, spk_turns in by_speaker.items():
439
- spk_turns = sorted(spk_turns, key=lambda t: t.duration, reverse=True)
440
- chunks: list["torch.Tensor"] = []
441
- used = 0.0
442
- for t in spk_turns:
443
- if used >= max_seconds_per_speaker:
444
- break
445
- s = max(0, int(t.start * sr))
446
- e = min(waveform.shape[-1], int(t.end * sr))
447
- if e - s < int(0.20 * sr):
448
- continue
449
- chunks.append(waveform[..., s:e])
450
- used += (e - s) / sr
451
- if not chunks:
452
- genders[speaker] = "unknown"
453
- continue
454
- audio = torch.cat(chunks, dim=-1)
455
- try:
456
- pitch = AF.detect_pitch_frequency(audio, sr)
457
- except Exception as exc: # noqa: BLE001
458
- log(f"[diarize] pitch detection failed for {speaker}: {exc}")
459
- genders[speaker] = "unknown"
460
- continue
461
- flat = pitch.flatten()
462
- voiced = flat[(flat >= _F0_MIN_HZ) & (flat <= _F0_MAX_HZ)]
463
- if voiced.numel() < 5:
464
- genders[speaker] = "unknown"
465
- continue
466
- median_f0 = float(voiced.median().item())
467
- if median_f0 <= _MALE_F0_MAX:
468
- gender = "male"
469
- elif median_f0 >= _FEMALE_F0_MIN:
470
- gender = "female"
471
- else:
472
- gender = "unknown"
473
- genders[speaker] = gender
474
- log(f"[diarize] {speaker}: median F0 ~{median_f0:.0f} Hz -> {gender}")
475
- return genders
476
-
477
-
478
- # --------------------------------------------------------------------------- #
479
- # Age + gender estimation (dedicated wav2vec2 model)
480
- # --------------------------------------------------------------------------- #
481
- def _collect_speaker_audio(waveform, sr, turns, max_seconds_per_speaker=30.0):
482
- """Group turns per speaker and concatenate up to N seconds of their audio.
483
-
484
- Returns ``{speaker: 1-D float32 torch.Tensor}``.
485
- """
486
- import torch # noqa: PLC0415
487
-
488
- if waveform.ndim == 1:
489
- waveform = waveform.unsqueeze(0)
490
- elif waveform.shape[0] > 1:
491
- waveform = waveform.mean(dim=0, keepdim=True)
492
-
493
- by_speaker: dict[str, list[SpeakerTurn]] = {}
494
- for t in turns:
495
- by_speaker.setdefault(t.speaker, []).append(t)
496
-
497
- out: dict[str, Any] = {}
498
- for speaker, spk_turns in by_speaker.items():
499
- spk_turns = sorted(spk_turns, key=lambda t: t.duration, reverse=True)
500
- chunks = []
501
- used = 0.0
502
- for t in spk_turns:
503
- if used >= max_seconds_per_speaker:
504
- break
505
- s = max(0, int(t.start * sr))
506
- e = min(waveform.shape[-1], int(t.end * sr))
507
- if e - s < int(0.20 * sr):
508
- continue
509
- chunks.append(waveform[..., s:e])
510
- used += (e - s) / sr
511
- if chunks:
512
- out[speaker] = torch.cat(chunks, dim=-1).flatten()
513
- return out
514
-
515
-
516
- def _load_age_gender_model(model_name: str, device: str, log: LogFn):
517
- """Load (and cache) the audeering age/gender wav2vec2 model + processor."""
518
- cache_key = f"{model_name}@{device}"
519
- if cache_key in _AGE_GENDER_CACHE:
520
- return _AGE_GENDER_CACHE[cache_key]
521
-
522
- import torch # noqa: PLC0415
523
- import torch.nn as nn # noqa: PLC0415
524
- from transformers import Wav2Vec2Processor # noqa: PLC0415
525
- from transformers.models.wav2vec2.modeling_wav2vec2 import ( # noqa: PLC0415
526
- Wav2Vec2Model,
527
- Wav2Vec2PreTrainedModel,
528
- )
529
-
530
- class ModelHead(nn.Module):
531
- def __init__(self, config, num_labels):
532
- super().__init__()
533
- self.dense = nn.Linear(config.hidden_size, config.hidden_size)
534
- self.dropout = nn.Dropout(config.final_dropout)
535
- self.out_proj = nn.Linear(config.hidden_size, num_labels)
536
-
537
- def forward(self, features):
538
- x = self.dropout(features)
539
- x = self.dense(x)
540
- x = torch.tanh(x)
541
- x = self.dropout(x)
542
- return self.out_proj(x)
543
-
544
- class AgeGenderModel(Wav2Vec2PreTrainedModel):
545
- # This model has no tied weights, so the mapping is always empty.
546
- _tied_weights_keys = {}
547
-
548
- def __init__(self, config):
549
- super().__init__(config)
550
- self.wav2vec2 = Wav2Vec2Model(config)
551
- self.age = ModelHead(config, 1)
552
- self.gender = ModelHead(config, 3)
553
- # transformers >=5 builds `all_tied_weights_keys` in post_init();
554
- # older releases only have init_weights(). Support both.
555
- if hasattr(self, "post_init"):
556
- self.post_init()
557
- else:
558
- self.init_weights()
559
- # `from_pretrained` calls `all_tied_weights_keys.keys()` while
560
- # loading; guarantee it is a dict so the load never crashes.
561
- if not isinstance(getattr(self, "all_tied_weights_keys", None), dict):
562
- self.all_tied_weights_keys = {}
563
-
564
- def forward(self, input_values):
565
- outputs = self.wav2vec2(input_values)
566
- hidden_states = outputs[0]
567
- hidden_states = torch.mean(hidden_states, dim=1)
568
- logits_age = self.age(hidden_states)
569
- logits_gender = torch.softmax(self.gender(hidden_states), dim=1)
570
- return hidden_states, logits_age, logits_gender
571
-
572
- log(f"[diarize] loading age/gender model {model_name} (first run downloads ~1 GB)...")
573
- processor = Wav2Vec2Processor.from_pretrained(model_name)
574
- model = AgeGenderModel.from_pretrained(model_name)
575
- tied = getattr(model, "all_tied_weights_keys", None)
576
- if not isinstance(tied, dict):
577
- model.all_tied_weights_keys = {}
578
- model = model.to(torch.device(device)).eval()
579
- _AGE_GENDER_CACHE[cache_key] = (model, processor, device)
580
- log(f"[diarize] age/gender model ready on {device}.")
581
- return _AGE_GENDER_CACHE[cache_key]
582
-
583
-
584
- def estimate_speaker_demographics_model(
585
- waveform,
586
- sr: int,
587
- turns: list[SpeakerTurn],
588
- *,
589
- model_name: str = DEFAULT_AGE_GENDER_MODEL,
590
- device: str = "auto",
591
- max_seconds_per_speaker: float = 30.0,
592
- log: LogFn = _noop,
593
- ) -> dict[str, dict[str, Any]]:
594
- """Predict gender (female/male/child) + age per speaker with a dedicated model.
595
-
596
- Returns ``{speaker: {"gender": str, "age": float|None, "age_group": str}}``.
597
- """
598
- import torch # noqa: PLC0415
599
-
600
- dev = _pick_device(device)
601
- model, processor, dev = _load_age_gender_model(model_name, dev, log)
602
- audio_by_speaker = _collect_speaker_audio(
603
- waveform, sr, turns, max_seconds_per_speaker
604
- )
605
- n_spk = len(audio_by_speaker)
606
- log(f"[diarize] age/gender: predicting {n_spk} speaker(s)...")
607
-
608
- out: dict[str, dict[str, Any]] = {}
609
- for i, (speaker, audio) in enumerate(audio_by_speaker.items(), start=1):
610
- log(f"[diarize] age/gender: [{i}/{n_spk}] {speaker}...")
611
- signal = audio.detach().cpu().numpy().astype("float32")
612
- inputs = processor(signal, sampling_rate=sr)
613
- values = torch.from_numpy(
614
- inputs["input_values"][0].reshape(1, -1)
615
- ).to(torch.device(dev))
616
- with torch.no_grad():
617
- _hidden, logits_age, logits_gender = model(values)
618
- age_years = float(logits_age[0].item()) * 100.0
619
- gender_idx = int(torch.argmax(logits_gender[0]).item())
620
- gender = _AGE_GENDER_LABELS[gender_idx]
621
- age_group = age_to_group(age_years, gender)
622
- out[speaker] = {
623
- "gender": gender,
624
- "age": round(age_years, 1),
625
- "age_group": age_group,
626
- }
627
- log(
628
- f"[diarize] {speaker}: ~{age_years:.0f}y, gender={gender} "
629
- f"({age_group or 'n/a'})"
630
- )
631
- return out
632
-
633
-
634
- def estimate_speaker_demographics(
635
- waveform,
636
- sr: int,
637
- turns: list[SpeakerTurn],
638
- *,
639
- method: str = "auto",
640
- model_name: str = DEFAULT_AGE_GENDER_MODEL,
641
- device: str = "auto",
642
- log: LogFn = _noop,
643
- ) -> dict[str, dict[str, Any]]:
644
- """Estimate per-speaker demographics, dispatching by *method*.
645
-
646
- method:
647
- "model" -> dedicated wav2vec2 age/gender model (accurate, age + gender).
648
- "pitch" -> lightweight F0 heuristic (gender only, no age).
649
- "auto" -> try the model, fall back to pitch on any failure.
650
- Returns ``{speaker: {"gender","age","age_group"}}``.
651
- """
652
- if method in ("model", "auto"):
653
- try:
654
- log(f"[diarize] age/gender: loading model ({model_name})...")
655
- return estimate_speaker_demographics_model(
656
- waveform, sr, turns, model_name=model_name, device=device, log=log
657
- )
658
- except Exception as exc: # noqa: BLE001
659
- log(f"[diarize] age/gender model failed, using pitch heuristic: {exc}")
660
-
661
- log("[diarize] age/gender: pitch heuristic (F0) per speaker...")
662
- genders = estimate_speaker_genders(waveform, sr, turns, log=log)
663
- return {
664
- spk: {"gender": g, "age": None, "age_group": ""} for spk, g in genders.items()
665
- }
666
-
667
-
668
- # --------------------------------------------------------------------------- #
669
- # Cue assignment
670
- # --------------------------------------------------------------------------- #
671
- def _overlap(a_start: float, a_end: float, b_start: float, b_end: float) -> float:
672
- return max(0.0, min(a_end, b_end) - max(a_start, b_start))
673
-
674
-
675
- def _speaker_gender(info: Any) -> str:
676
- return info if isinstance(info, str) else (info or {}).get("gender", "")
677
-
678
-
679
- def _speaker_age_group(info: Any) -> str:
680
- return "" if isinstance(info, str) else (info or {}).get("age_group", "")
681
-
682
-
683
- def assign_speakers_to_cues(
684
- cues: list["Cue"],
685
- turns: list[SpeakerTurn],
686
- demographics: dict[str, Any] | None = None,
687
- *,
688
- log: LogFn = _noop,
689
- ) -> int:
690
- """Tag each cue with the speaker that overlaps it most in time.
691
-
692
- *demographics* maps a speaker label to either a gender string or a dict
693
- ``{"gender","age_group",...}``. Returns the number of cues tagged.
694
- """
695
- demographics = demographics or {}
696
- tagged = 0
697
- total = len(cues)
698
- log(f"[diarize] gán speaker vào {total} cue(s)...")
699
- report_every = max(200, total // 20) if total else 200
700
- for idx, cue in enumerate(cues, start=1):
701
- best_speaker = ""
702
- best_overlap = 0.0
703
- for t in turns:
704
- if t.end <= cue.start:
705
- continue
706
- if t.start >= cue.end:
707
- break
708
- ov = _overlap(cue.start, cue.end, t.start, t.end)
709
- if ov > best_overlap:
710
- best_overlap = ov
711
- best_speaker = t.speaker
712
- if best_speaker:
713
- info = demographics.get(best_speaker, {})
714
- cue.speaker = best_speaker
715
- cue.speaker_gender = _speaker_gender(info)
716
- cue.speaker_age_group = _speaker_age_group(info)
717
- tagged += 1
718
- if idx % report_every == 0 or idx == total:
719
- log(f"[diarize] gán cue: {idx}/{total} ({tagged} tagged)...")
720
- return tagged
721
-
722
-
723
- # --------------------------------------------------------------------------- #
724
- # High level entry point
725
- # --------------------------------------------------------------------------- #
726
- def diarize_and_tag_cues(
727
- video: Path,
728
- cues: list["Cue"],
729
- *,
730
- hf_token: str | None = None,
731
- num_speakers: int | None = None,
732
- min_speakers: int | None = None,
733
- max_speakers: int | None = None,
734
- device: str = "auto",
735
- detect_gender: bool = True,
736
- gender_method: str = "auto",
737
- age_gender_model: str = DEFAULT_AGE_GENDER_MODEL,
738
- log: LogFn = _noop,
739
- ) -> dict[str, Any]:
740
- """Run the full diarization flow and tag *cues* in place.
741
-
742
- Returns the ``{speaker: {"gender","age","age_group"}}`` registry (possibly
743
- empty). Raises RuntimeError with an actionable message when the HF token is
744
- missing.
745
- """
746
- token = resolve_hf_token(hf_token)
747
- if not token:
748
- raise RuntimeError(
749
- "Hugging Face token required for diarization. Provide --hf-token, set "
750
- "the HF_TOKEN environment variable, and accept the model conditions at "
751
- f"https://hf.co/{DEFAULT_PIPELINE}"
752
- )
753
-
754
- total_steps = 5 if detect_gender else 4
755
- t_all = time.time()
756
- dev = _pick_device(device)
757
- n_cues = len(cues)
758
- _log_step(
759
- log,
760
- 1,
761
- total_steps,
762
- f"bắt đầu Pass 0 {n_cues} cue(s), device={dev}, gender={gender_method if detect_gender else 'off'}",
763
- )
764
-
765
- tmp_dir = Path(tempfile.mkdtemp(prefix="gemma_diarize_"))
766
- wav = tmp_dir / "audio.wav"
767
- max_seconds = None
768
- if cues:
769
- max_seconds = max((c.end for c in cues), default=0.0) + 5.0
770
- _log_step(
771
- log,
772
- 2,
773
- total_steps,
774
- f"tách audio ffmpeg (16 kHz mono, tối đa {_fmt_dur(max_seconds or 0)})...",
775
- )
776
- t0 = time.time()
777
- extract_audio(video, wav, max_seconds=max_seconds)
778
- wav_mb = wav.stat().st_size / (1024 * 1024) if wav.is_file() else 0.0
779
- log(f"[diarize] audio wav ready: {wav_mb:.0f} MB in {_fmt_dur(time.time() - t0)}.")
780
-
781
- _log_step(log, 3, total_steps, "nạp waveform + chạy pyannote (who spoke when)...")
782
- t0 = time.time()
783
- waveform, sr = load_wav(wav)
784
- aud_dur = _audio_duration_sec(waveform, sr)
785
- log(f"[diarize] waveform: {_fmt_dur(aud_dur)} @ {sr} Hz.")
786
-
787
- turns = run_diarization(
788
- waveform,
789
- sr,
790
- token,
791
- num_speakers=num_speakers,
792
- min_speakers=min_speakers,
793
- max_speakers=max_speakers,
794
- device=device,
795
- log=log,
796
- )
797
- log(f"[diarize] bước pyannote xong trong {_fmt_dur(time.time() - t0)}.")
798
- if not turns:
799
- log("[diarize] no speaker turns detected; skipping speaker tags.")
800
- return {}
801
-
802
- demographics: dict[str, Any] = {}
803
- if detect_gender:
804
- _log_step(
805
- log,
806
- 4,
807
- total_steps,
808
- f"ước lượng giới/tuổi ({gender_method}) cho {len({t.speaker for t in turns})} speaker(s)...",
809
- )
810
- t0 = time.time()
811
- try:
812
- demographics = estimate_speaker_demographics(
813
- waveform,
814
- sr,
815
- turns,
816
- method=gender_method,
817
- model_name=age_gender_model,
818
- device=device,
819
- log=log,
820
- )
821
- log(f"[diarize] demographics xong trong {_fmt_dur(time.time() - t0)}.")
822
- except Exception as exc: # noqa: BLE001
823
- log(
824
- f"[diarize] demographics failed ({exc}) — gán speaker không có giới/tuổi."
825
- )
826
-
827
- assign_step = 5 if detect_gender else 4
828
- _log_step(log, assign_step, total_steps, "gán speaker + giới/tuổi vào từng cue...")
829
- t0 = time.time()
830
- tagged = assign_speakers_to_cues(cues, turns, demographics, log=log)
831
- n_spk = len({c.speaker for c in cues if c.speaker})
832
- log(
833
- f"[diarize] Pass 0 xong — {tagged}/{n_cues} cue tagged, "
834
- f"{n_spk} speaker(s), tổng {_fmt_dur(time.time() - t_all)} "
835
- f"(gán cue: {_fmt_dur(time.time() - t0)})."
836
- )
837
- return demographics
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Speaker diarization + gender estimation for subtitle cues.
2
+
3
+ This module answers "who spoke when" using pyannote.audio and attaches a stable
4
+ speaker label (and a coarse male/female/unknown gender guess) to every SRT cue.
5
+ Gemma then uses those labels to keep Vietnamese forms of address / pronouns
6
+ (ta, hắn, y, muội muội, tỷ tỷ, phu quân, ...) consistent for each character.
7
+
8
+ Pipeline:
9
+ extract_audio (ffmpeg -> 16k mono wav) ->
10
+ run_diarization (pyannote community-1) -> list[SpeakerTurn] ->
11
+ estimate_speaker_genders (median F0 per speaker, optional) ->
12
+ assign_speakers_to_cues (max temporal overlap)
13
+
14
+ Heavy dependencies (pyannote.audio, torch, torchaudio) are imported lazily so
15
+ that the rest of the project keeps working even when they are not installed.
16
+ The Hugging Face access token is required by pyannote and is read, in order,
17
+ from the explicit argument, then the ``HF_TOKEN`` / ``HUGGINGFACE_TOKEN`` env
18
+ vars.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ import time
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING, Any, Callable
31
+
32
+ if TYPE_CHECKING: # pragma: no cover - typing only
33
+ from translate_srt import Cue
34
+
35
+ # Preferred open-source pipeline (pyannote.audio 4.x). Falls back to the legacy
36
+ # 3.1 pipeline if the newer one is unavailable for the installed version.
37
+ DEFAULT_PIPELINE = "pyannote/speaker-diarization-community-1"
38
+ FALLBACK_PIPELINE = "pyannote/speaker-diarization-3.1"
39
+
40
+ def _load_default_token() -> str:
41
+ """Local convenience token, read from a sibling file that is NOT uploaded.
42
+
43
+ Put your token in ``hf_token.local`` (or ``hf_token.txt``) next to this file
44
+ to have local runs pick it up automatically. The file is git-ignored and is
45
+ never copied by ``prepare_upload.ps1`` so the public code stays secret-free.
46
+ """
47
+ for name in ("hf_token.local", "hf_token.txt"):
48
+ p = Path(__file__).with_name(name)
49
+ try:
50
+ if p.is_file():
51
+ tok = p.read_text(encoding="utf-8").strip()
52
+ if tok:
53
+ return tok
54
+ except OSError:
55
+ pass
56
+ return ""
57
+
58
+
59
+ # Built-in fallback Hugging Face token (used when no arg/env token is supplied).
60
+ # Loaded from a local-only file so the public source never embeds a secret.
61
+ DEFAULT_HF_TOKEN = _load_default_token()
62
+
63
+ _SUBPROCESS_FLAGS = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
64
+
65
+ # Coarse pitch thresholds (Hz) for median fundamental frequency per speaker.
66
+ # A deliberate "unknown" band avoids confidently mislabelling ambiguous voices.
67
+ # Male speech F0 is typically ~85-180 Hz and female ~165-255 Hz, so the bands
68
+ # overlap. We label only confident cases and leave the overlap as "unknown" so
69
+ # the translator infers gender from dialogue/context instead of guessing wrong.
70
+ _MALE_F0_MAX = 150.0
71
+ _FEMALE_F0_MIN = 195.0
72
+ _F0_MIN_HZ = 65.0
73
+ _F0_MAX_HZ = 400.0
74
+
75
+ # Dedicated speech model that predicts age (0..100) and gender (female/male/child).
76
+ DEFAULT_AGE_GENDER_MODEL = "audeering/wav2vec2-large-robust-24-ft-age-gender"
77
+ # The model's gender head order is [female, male, child].
78
+ _AGE_GENDER_LABELS = ("female", "male", "child")
79
+
80
+ # Cached (model, processor, device) so we only load the ~1 GB model once.
81
+ _AGE_GENDER_CACHE: dict[str, Any] = {}
82
+
83
+
84
+ def age_to_group(age_years: float | None, gender: str = "") -> str:
85
+ """Bucket an age (years) into a coarse life-stage label.
86
+
87
+ Returns one of "child", "teen", "young_adult", "middle_aged", "elderly" or
88
+ "" when age is unknown.
89
+ """
90
+ if gender == "child":
91
+ return "child"
92
+ if age_years is None:
93
+ return ""
94
+ if age_years < 13:
95
+ return "child"
96
+ if age_years < 20:
97
+ return "teen"
98
+ if age_years < 40:
99
+ return "young_adult"
100
+ if age_years < 60:
101
+ return "middle_aged"
102
+ return "elderly"
103
+
104
+
105
+ LogFn = Callable[[str], None]
106
+
107
+
108
+ def _noop(_msg: str) -> None:
109
+ pass
110
+
111
+
112
+ def _fmt_dur(seconds: float) -> str:
113
+ """Human-readable duration, e.g. '1h 23m' or '4m 12s'."""
114
+ if seconds < 0:
115
+ seconds = 0.0
116
+ s = int(round(seconds))
117
+ if s >= 3600:
118
+ h, rem = divmod(s, 3600)
119
+ m, sec = divmod(rem, 60)
120
+ return f"{h}h {m}m" if sec < 30 else f"{h}h {m}m {sec}s"
121
+ if s >= 60:
122
+ m, sec = divmod(s, 60)
123
+ return f"{m}m {sec}s"
124
+ return f"{s}s"
125
+
126
+
127
+ def _log_step(log: LogFn, step: int, total: int, msg: str) -> None:
128
+ log(f"[diarize] [{step}/{total}] {msg}")
129
+
130
+
131
+ def _audio_duration_sec(waveform, sample_rate: int) -> float:
132
+ try:
133
+ n = int(waveform.shape[-1])
134
+ return n / float(sample_rate) if sample_rate else 0.0
135
+ except Exception: # noqa: BLE001
136
+ return 0.0
137
+
138
+
139
+ @dataclass
140
+ class SpeakerTurn:
141
+ start: float
142
+ end: float
143
+ speaker: str
144
+
145
+ @property
146
+ def duration(self) -> float:
147
+ return max(0.0, self.end - self.start)
148
+
149
+
150
+ # --------------------------------------------------------------------------- #
151
+ # Token handling
152
+ # --------------------------------------------------------------------------- #
153
+ def resolve_hf_token(token: str | None) -> str | None:
154
+ if token and token.strip():
155
+ return token.strip()
156
+ for env in ("HF_TOKEN", "HUGGINGFACE_TOKEN", "HUGGING_FACE_HUB_TOKEN"):
157
+ val = os.environ.get(env)
158
+ if val and val.strip():
159
+ return val.strip()
160
+ if DEFAULT_HF_TOKEN and DEFAULT_HF_TOKEN.strip():
161
+ return DEFAULT_HF_TOKEN.strip()
162
+ return None
163
+
164
+
165
+ # --------------------------------------------------------------------------- #
166
+ # Audio extraction
167
+ # --------------------------------------------------------------------------- #
168
+ def extract_audio(
169
+ video: Path,
170
+ out_wav: Path,
171
+ sample_rate: int = 16000,
172
+ max_seconds: float | None = None,
173
+ ) -> Path:
174
+ """Extract a 16 kHz mono PCM wav from *video* (what pyannote expects).
175
+
176
+ When *max_seconds* is given, only that many seconds from the start are
177
+ extracted (so diarization scales with the number of cues being processed).
178
+ """
179
+ cmd = [
180
+ "ffmpeg",
181
+ "-hide_banner",
182
+ "-loglevel",
183
+ "error",
184
+ "-i",
185
+ str(video),
186
+ "-vn",
187
+ "-ac",
188
+ "1",
189
+ "-ar",
190
+ str(sample_rate),
191
+ ]
192
+ if max_seconds and max_seconds > 0:
193
+ cmd += ["-t", f"{max_seconds:.3f}"]
194
+ cmd += [
195
+ "-c:a",
196
+ "pcm_s16le",
197
+ "-y",
198
+ str(out_wav),
199
+ ]
200
+ proc = subprocess.run(
201
+ cmd, capture_output=True, timeout=600, creationflags=_SUBPROCESS_FLAGS
202
+ )
203
+ if proc.returncode != 0:
204
+ raise RuntimeError(
205
+ "ffmpeg audio extraction failed: "
206
+ + proc.stderr.decode("utf-8", "replace").strip()
207
+ )
208
+ if not out_wav.exists():
209
+ raise RuntimeError(f"Audio extraction produced no file at {out_wav}")
210
+ return out_wav
211
+
212
+
213
+ # --------------------------------------------------------------------------- #
214
+ # Diarization
215
+ # --------------------------------------------------------------------------- #
216
+ def _pick_device(preferred: str = "auto") -> str:
217
+ if preferred and preferred != "auto":
218
+ return preferred
219
+ try:
220
+ import torch # noqa: PLC0415
221
+
222
+ return "cuda" if torch.cuda.is_available() else "cpu"
223
+ except Exception: # noqa: BLE001
224
+ return "cpu"
225
+
226
+
227
+ def _patch_speechbrain_lazy(log: LogFn = _noop) -> None:
228
+ """Work around a speechbrain LazyModule bug that breaks pyannote on Windows.
229
+
230
+ ``speechbrain.utils.importutils.LazyModule.ensure_module`` tries to skip the
231
+ lazy import when the caller is ``inspect.py`` (e.g. ``inspect.getmodule``
232
+ probing ``__file__``), but it only matches the POSIX ``/inspect.py`` path. On
233
+ Windows the path uses ``\\`` so the guard misses, the lazy module (e.g.
234
+ ``speechbrain.integrations.k2_fsa``) is force-imported, and it raises
235
+ ``ImportError`` — which ``hasattr`` propagates, crashing pipeline loading.
236
+
237
+ We re-implement ``ensure_module`` with an OS-agnostic ``inspect.py`` check.
238
+ """
239
+ try:
240
+ import importlib as _importlib # noqa: PLC0415
241
+ import inspect as _inspect # noqa: PLC0415
242
+ import os as _os # noqa: PLC0415
243
+ import sys as _sys # noqa: PLC0415
244
+
245
+ from speechbrain.utils import importutils as iu # noqa: PLC0415
246
+ except Exception: # noqa: BLE001 - speechbrain not used by this pipeline
247
+ return
248
+
249
+ if getattr(iu.LazyModule, "_gemma_patched", False):
250
+ return
251
+
252
+ def ensure_module(self, stacklevel: int):
253
+ importer_frame = None
254
+ try:
255
+ importer_frame = _inspect.getframeinfo(_sys._getframe(stacklevel + 1))
256
+ except (AttributeError, ValueError):
257
+ importer_frame = None
258
+ if (
259
+ importer_frame is not None
260
+ and _os.path.basename(importer_frame.filename) == "inspect.py"
261
+ ):
262
+ raise AttributeError()
263
+ if self.lazy_module is None:
264
+ try:
265
+ if self.package is None:
266
+ self.lazy_module = _importlib.import_module(self.target)
267
+ else:
268
+ self.lazy_module = _importlib.import_module(
269
+ f".{self.target}", self.package
270
+ )
271
+ except Exception as e: # noqa: BLE001
272
+ raise ImportError(f"Lazy import of {self!r} failed") from e
273
+ return self.lazy_module
274
+
275
+ iu.LazyModule.ensure_module = ensure_module
276
+ iu.LazyModule._gemma_patched = True
277
+ log("[diarize] applied speechbrain LazyModule Windows compatibility patch.")
278
+
279
+
280
+ def _load_pipeline(hf_token: str, device: str, log: LogFn):
281
+ try:
282
+ from pyannote.audio import Pipeline # noqa: PLC0415
283
+ except ImportError as exc: # pragma: no cover - env dependent
284
+ raise RuntimeError(
285
+ "pyannote.audio is not installed. Install diarization extras:\n"
286
+ " pip install -r requirements-diarize.txt"
287
+ ) from exc
288
+
289
+ import torch # noqa: PLC0415
290
+
291
+ _patch_speechbrain_lazy(log)
292
+
293
+ last_err: Exception | None = None
294
+ for model_id in (DEFAULT_PIPELINE, FALLBACK_PIPELINE):
295
+ try:
296
+ log(f"[diarize] loading pipeline {model_id} ...")
297
+ pipeline = Pipeline.from_pretrained(model_id, token=hf_token)
298
+ if pipeline is None:
299
+ raise RuntimeError(
300
+ "Pipeline.from_pretrained returned None — the HF token is "
301
+ "likely invalid or the model conditions were not accepted at "
302
+ f"https://hf.co/{model_id}"
303
+ )
304
+ try:
305
+ pipeline.to(torch.device(device))
306
+ except Exception: # noqa: BLE001 - keep CPU pipeline if .to fails
307
+ pass
308
+ log(f"[diarize] pipeline ready: {model_id} on {device}.")
309
+ return pipeline
310
+ except Exception as exc: # noqa: BLE001
311
+ last_err = exc
312
+ log(f"[diarize] could not load {model_id}: {exc}")
313
+ raise RuntimeError(
314
+ "Failed to load any diarization pipeline. Most common causes:\n"
315
+ " 1) HF token's account has not accepted ALL gated model conditions (Agree):\n"
316
+ f" https://hf.co/{DEFAULT_PIPELINE}\n"
317
+ f" https://hf.co/{FALLBACK_PIPELINE}\n"
318
+ " https://hf.co/pyannote/segmentation-3.0\n"
319
+ " 2) On Colab: pytorch-lightning version mismatch (community-1).\n"
320
+ f"Last error: {last_err}"
321
+ )
322
+
323
+
324
+ def load_wav(wav: Path):
325
+ """Load a wav into an in-memory (channel, time) float32 tensor + sample rate.
326
+
327
+ Uses ``soundfile`` instead of ``torchaudio.load`` / torchcodec so audio
328
+ decoding does not depend on a working torchcodec/ffmpeg-DLL setup.
329
+ """
330
+ import soundfile as sf # noqa: PLC0415
331
+ import torch # noqa: PLC0415
332
+
333
+ data, sr = sf.read(str(wav), dtype="float32", always_2d=True) # (time, channels)
334
+ waveform = torch.from_numpy(data.T).contiguous() # (channels, time)
335
+ if waveform.shape[0] > 1:
336
+ waveform = waveform.mean(dim=0, keepdim=True)
337
+ return waveform, int(sr)
338
+
339
+
340
+ def _as_annotation(diarization):
341
+ """Return a pyannote Annotation (with ``itertracks``) from a pipeline result.
342
+
343
+ pyannote.audio 4.x (community-1) returns a ``DiarizeOutput`` exposing
344
+ ``speaker_diarization`` and ``exclusive_speaker_diarization``; the legacy 3.1
345
+ pipeline returns an ``Annotation`` directly. Exclusive diarization assigns at
346
+ most one speaker per instant, which is ideal for tagging subtitle cues.
347
+ """
348
+ if hasattr(diarization, "itertracks"):
349
+ return diarization
350
+ for attr in ("exclusive_speaker_diarization", "speaker_diarization"):
351
+ candidate = getattr(diarization, attr, None)
352
+ if candidate is not None and hasattr(candidate, "itertracks"):
353
+ return candidate
354
+ raise RuntimeError(
355
+ f"Unexpected diarization output type without itertracks: {type(diarization)!r}"
356
+ )
357
+
358
+
359
+ def run_diarization(
360
+ waveform,
361
+ sample_rate: int,
362
+ hf_token: str,
363
+ num_speakers: int | None = None,
364
+ min_speakers: int | None = None,
365
+ max_speakers: int | None = None,
366
+ device: str = "auto",
367
+ log: LogFn = _noop,
368
+ ) -> list[SpeakerTurn]:
369
+ """Run pyannote diarization and return merged speaker turns sorted by time."""
370
+ dev = _pick_device(device)
371
+ pipeline = _load_pipeline(hf_token, dev, log)
372
+
373
+ kwargs: dict[str, int] = {}
374
+ if num_speakers and num_speakers > 0:
375
+ kwargs["num_speakers"] = num_speakers
376
+ else:
377
+ if min_speakers and min_speakers > 0:
378
+ kwargs["min_speakers"] = min_speakers
379
+ if max_speakers and max_speakers > 0:
380
+ kwargs["max_speakers"] = max_speakers
381
+
382
+ dur = _audio_duration_sec(waveform, sample_rate)
383
+ kw_hint = ", ".join(f"{k}={v}" for k, v in kwargs.items()) or "auto-detect speakers"
384
+ log(
385
+ f"[diarize] running pyannote on {_fmt_dur(dur)} audio "
386
+ f"({dev}, {kw_hint}) — có thể mất vài phút đến ~1h với phim dài..."
387
+ )
388
+ t0 = time.time()
389
+ audio = {"waveform": waveform, "sample_rate": sample_rate}
390
+ diarization = pipeline(audio, **kwargs)
391
+ log(f"[diarize] pyannote inference done in {_fmt_dur(time.time() - t0)}.")
392
+ annotation = _as_annotation(diarization)
393
+
394
+ turns: list[SpeakerTurn] = []
395
+ for segment, _track, speaker in annotation.itertracks(yield_label=True):
396
+ turns.append(SpeakerTurn(float(segment.start), float(segment.end), str(speaker)))
397
+ turns.sort(key=lambda t: (t.start, t.end))
398
+ n_spk = len({t.speaker for t in turns})
399
+ log(f"[diarize] found {n_spk} speaker(s) across {len(turns)} turn(s).")
400
+ return turns
401
+
402
+
403
+ # --------------------------------------------------------------------------- #
404
+ # Gender estimation (coarse, pitch based)
405
+ # --------------------------------------------------------------------------- #
406
+ def estimate_speaker_genders(
407
+ waveform,
408
+ sr: int,
409
+ turns: list[SpeakerTurn],
410
+ *,
411
+ max_seconds_per_speaker: float = 30.0,
412
+ log: LogFn = _noop,
413
+ ) -> dict[str, str]:
414
+ """Estimate male/female/unknown per speaker from median fundamental frequency.
415
+
416
+ This is a lightweight heuristic (no extra model download) meant only as a
417
+ hint for the translator. Returns a mapping ``{speaker_label: gender}`` where
418
+ gender is one of ``"male"``, ``"female"`` or ``"unknown"``. *waveform* is a
419
+ ``(channel, time)`` float32 tensor as returned by :func:`load_wav`.
420
+ """
421
+ try:
422
+ import torch # noqa: PLC0415
423
+ import torchaudio.functional as AF # noqa: PLC0415
424
+ except Exception as exc: # noqa: BLE001
425
+ log(f"[diarize] gender estimation skipped (torch/torchaudio missing): {exc}")
426
+ return {}
427
+
428
+ if waveform.ndim == 1:
429
+ waveform = waveform.unsqueeze(0)
430
+ elif waveform.shape[0] > 1:
431
+ waveform = waveform.mean(dim=0, keepdim=True)
432
+
433
+ by_speaker: dict[str, list[SpeakerTurn]] = {}
434
+ for t in turns:
435
+ by_speaker.setdefault(t.speaker, []).append(t)
436
+
437
+ genders: dict[str, str] = {}
438
+ for speaker, spk_turns in by_speaker.items():
439
+ spk_turns = sorted(spk_turns, key=lambda t: t.duration, reverse=True)
440
+ chunks: list["torch.Tensor"] = []
441
+ used = 0.0
442
+ for t in spk_turns:
443
+ if used >= max_seconds_per_speaker:
444
+ break
445
+ s = max(0, int(t.start * sr))
446
+ e = min(waveform.shape[-1], int(t.end * sr))
447
+ if e - s < int(0.20 * sr):
448
+ continue
449
+ chunks.append(waveform[..., s:e])
450
+ used += (e - s) / sr
451
+ if not chunks:
452
+ genders[speaker] = "unknown"
453
+ continue
454
+ audio = torch.cat(chunks, dim=-1)
455
+ try:
456
+ pitch = AF.detect_pitch_frequency(audio, sr)
457
+ except Exception as exc: # noqa: BLE001
458
+ log(f"[diarize] pitch detection failed for {speaker}: {exc}")
459
+ genders[speaker] = "unknown"
460
+ continue
461
+ flat = pitch.flatten()
462
+ voiced = flat[(flat >= _F0_MIN_HZ) & (flat <= _F0_MAX_HZ)]
463
+ if voiced.numel() < 5:
464
+ genders[speaker] = "unknown"
465
+ continue
466
+ median_f0 = float(voiced.median().item())
467
+ if median_f0 <= _MALE_F0_MAX:
468
+ gender = "male"
469
+ elif median_f0 >= _FEMALE_F0_MIN:
470
+ gender = "female"
471
+ else:
472
+ gender = "unknown"
473
+ genders[speaker] = gender
474
+ log(f"[diarize] {speaker}: median F0 ~{median_f0:.0f} Hz -> {gender}")
475
+ return genders
476
+
477
+
478
+ # --------------------------------------------------------------------------- #
479
+ # Age + gender estimation (dedicated wav2vec2 model)
480
+ # --------------------------------------------------------------------------- #
481
+ def _weighted_median(values: list[float], weights: list[float]) -> float:
482
+ """Weighted median robust to a few outlier windows (e.g. noisy segments).
483
+
484
+ Falls back to the plain mean when weights are missing/zero.
485
+ """
486
+ pairs = [(v, w) for v, w in zip(values, weights) if w and w > 0]
487
+ if not pairs:
488
+ return sum(values) / len(values) if values else 0.0
489
+ pairs.sort(key=lambda p: p[0])
490
+ total = sum(w for _, w in pairs)
491
+ half = total / 2.0
492
+ acc = 0.0
493
+ for v, w in pairs:
494
+ acc += w
495
+ if acc >= half:
496
+ return v
497
+ return pairs[-1][0]
498
+
499
+
500
+ def _collect_speaker_windows(
501
+ waveform,
502
+ sr,
503
+ turns,
504
+ *,
505
+ max_seconds_per_speaker: float = 45.0,
506
+ window_sec: float = 8.0,
507
+ min_sec: float = 0.6,
508
+ max_windows: int = 12,
509
+ ):
510
+ """Group turns per speaker and split them into short coherent windows.
511
+
512
+ Returns ``{speaker: [(1-D float32 torch.Tensor, duration_sec), ...]}``.
513
+
514
+ Unlike concatenating one long 30 s clip and mean-pooling it once (which
515
+ biases the predicted age toward the dataset mean and lets a couple of noisy
516
+ seconds flip the gender), we keep each window separate so the caller can
517
+ predict per window and aggregate robustly (weighted median age, weighted
518
+ mean gender probability). Longest turns are used first.
519
+ """
520
+ import torch # noqa: PLC0415
521
+
522
+ if waveform.ndim == 1:
523
+ waveform = waveform.unsqueeze(0)
524
+ elif waveform.shape[0] > 1:
525
+ waveform = waveform.mean(dim=0, keepdim=True)
526
+
527
+ by_speaker: dict[str, list[SpeakerTurn]] = {}
528
+ for t in turns:
529
+ by_speaker.setdefault(t.speaker, []).append(t)
530
+
531
+ win = max(1, int(window_sec * sr))
532
+ min_len = max(1, int(min_sec * sr))
533
+ total_samples = waveform.shape[-1]
534
+
535
+ out: dict[str, list] = {}
536
+ for speaker, spk_turns in by_speaker.items():
537
+ spk_turns = sorted(spk_turns, key=lambda t: t.duration, reverse=True)
538
+ windows: list = []
539
+ used = 0.0
540
+ for t in spk_turns:
541
+ if used >= max_seconds_per_speaker or len(windows) >= max_windows:
542
+ break
543
+ s = max(0, int(t.start * sr))
544
+ e = min(total_samples, int(t.end * sr))
545
+ pos = s
546
+ while pos + min_len <= e:
547
+ if used >= max_seconds_per_speaker or len(windows) >= max_windows:
548
+ break
549
+ w_end = min(e, pos + win)
550
+ if w_end - pos < min_len:
551
+ break
552
+ seg = waveform[..., pos:w_end].flatten()
553
+ dur = (w_end - pos) / sr
554
+ windows.append((seg, dur))
555
+ used += dur
556
+ pos = w_end
557
+ if windows:
558
+ out[speaker] = windows
559
+ return out
560
+
561
+
562
+ def _load_age_gender_model(model_name: str, device: str, log: LogFn):
563
+ """Load (and cache) the audeering age/gender wav2vec2 model + processor."""
564
+ cache_key = f"{model_name}@{device}"
565
+ if cache_key in _AGE_GENDER_CACHE:
566
+ return _AGE_GENDER_CACHE[cache_key]
567
+
568
+ import torch # noqa: PLC0415
569
+ import torch.nn as nn # noqa: PLC0415
570
+ from transformers import Wav2Vec2Processor # noqa: PLC0415
571
+ from transformers.models.wav2vec2.modeling_wav2vec2 import ( # noqa: PLC0415
572
+ Wav2Vec2Model,
573
+ Wav2Vec2PreTrainedModel,
574
+ )
575
+
576
+ class ModelHead(nn.Module):
577
+ def __init__(self, config, num_labels):
578
+ super().__init__()
579
+ self.dense = nn.Linear(config.hidden_size, config.hidden_size)
580
+ self.dropout = nn.Dropout(config.final_dropout)
581
+ self.out_proj = nn.Linear(config.hidden_size, num_labels)
582
+
583
+ def forward(self, features):
584
+ x = self.dropout(features)
585
+ x = self.dense(x)
586
+ x = torch.tanh(x)
587
+ x = self.dropout(x)
588
+ return self.out_proj(x)
589
+
590
+ class AgeGenderModel(Wav2Vec2PreTrainedModel):
591
+ # This model has no tied weights, so the mapping is always empty.
592
+ _tied_weights_keys = {}
593
+
594
+ def __init__(self, config):
595
+ super().__init__(config)
596
+ self.wav2vec2 = Wav2Vec2Model(config)
597
+ self.age = ModelHead(config, 1)
598
+ self.gender = ModelHead(config, 3)
599
+ # transformers >=5 builds `all_tied_weights_keys` in post_init();
600
+ # older releases only have init_weights(). Support both.
601
+ if hasattr(self, "post_init"):
602
+ self.post_init()
603
+ else:
604
+ self.init_weights()
605
+ # `from_pretrained` calls `all_tied_weights_keys.keys()` while
606
+ # loading; guarantee it is a dict so the load never crashes.
607
+ if not isinstance(getattr(self, "all_tied_weights_keys", None), dict):
608
+ self.all_tied_weights_keys = {}
609
+
610
+ def forward(self, input_values):
611
+ outputs = self.wav2vec2(input_values)
612
+ hidden_states = outputs[0]
613
+ hidden_states = torch.mean(hidden_states, dim=1)
614
+ logits_age = self.age(hidden_states)
615
+ logits_gender = torch.softmax(self.gender(hidden_states), dim=1)
616
+ return hidden_states, logits_age, logits_gender
617
+
618
+ log(f"[diarize] loading age/gender model {model_name} (first run downloads ~1 GB)...")
619
+ processor = Wav2Vec2Processor.from_pretrained(model_name)
620
+ model = AgeGenderModel.from_pretrained(model_name)
621
+ tied = getattr(model, "all_tied_weights_keys", None)
622
+ if not isinstance(tied, dict):
623
+ model.all_tied_weights_keys = {}
624
+ model = model.to(torch.device(device)).eval()
625
+ _AGE_GENDER_CACHE[cache_key] = (model, processor, device)
626
+ log(f"[diarize] age/gender model ready on {device}.")
627
+ return _AGE_GENDER_CACHE[cache_key]
628
+
629
+
630
+ def estimate_speaker_demographics_model(
631
+ waveform,
632
+ sr: int,
633
+ turns: list[SpeakerTurn],
634
+ *,
635
+ model_name: str = DEFAULT_AGE_GENDER_MODEL,
636
+ device: str = "auto",
637
+ max_seconds_per_speaker: float = 30.0,
638
+ log: LogFn = _noop,
639
+ ) -> dict[str, dict[str, Any]]:
640
+ """Predict gender (female/male/child) + age per speaker with a dedicated model.
641
+
642
+ Returns ``{speaker: {"gender": str, "age": float|None, "age_group": str}}``.
643
+ """
644
+ import torch # noqa: PLC0415
645
+
646
+ dev = _pick_device(device)
647
+ model, processor, dev = _load_age_gender_model(model_name, dev, log)
648
+ windows_by_speaker = _collect_speaker_windows(
649
+ waveform, sr, turns, max_seconds_per_speaker=max_seconds_per_speaker
650
+ )
651
+ n_spk = len(windows_by_speaker)
652
+ log(f"[diarize] age/gender: predicting {n_spk} speaker(s) (per-window)...")
653
+
654
+ out: dict[str, dict[str, Any]] = {}
655
+ for i, (speaker, windows) in enumerate(windows_by_speaker.items(), start=1):
656
+ ages: list[float] = []
657
+ weights: list[float] = []
658
+ gender_prob_sum = torch.zeros(len(_AGE_GENDER_LABELS))
659
+ for seg, dur in windows:
660
+ signal = seg.detach().cpu().numpy().astype("float32")
661
+ inputs = processor(signal, sampling_rate=sr)
662
+ values = torch.from_numpy(
663
+ inputs["input_values"][0].reshape(1, -1)
664
+ ).to(torch.device(dev))
665
+ with torch.no_grad():
666
+ _hidden, logits_age, logits_gender = model(values)
667
+ ages.append(float(logits_age[0].item()) * 100.0)
668
+ weights.append(dur)
669
+ gender_prob_sum += logits_gender[0].detach().cpu().float() * dur
670
+
671
+ total_w = sum(weights)
672
+ if total_w <= 0:
673
+ continue
674
+ mean_gender = gender_prob_sum / total_w
675
+ gender_idx = int(torch.argmax(mean_gender).item())
676
+ gender = _AGE_GENDER_LABELS[gender_idx]
677
+ gender_conf = float(mean_gender[gender_idx].item())
678
+ # Weighted median age is robust to a few outlier windows; mean-pooling
679
+ # one long clip instead pulled every speaker toward the dataset mean.
680
+ age_years = _weighted_median(ages, weights)
681
+ age_group = age_to_group(age_years, gender)
682
+ out[speaker] = {
683
+ "gender": gender,
684
+ "age": round(age_years, 1),
685
+ "age_group": age_group,
686
+ }
687
+ log(
688
+ f"[diarize] age/gender: [{i}/{n_spk}] {speaker}: ~{age_years:.0f}y, "
689
+ f"gender={gender} (conf {gender_conf:.2f}) ({age_group or 'n/a'}) "
690
+ f"from {len(windows)} window(s)/{total_w:.0f}s"
691
+ )
692
+ return out
693
+
694
+
695
+ def estimate_speaker_demographics(
696
+ waveform,
697
+ sr: int,
698
+ turns: list[SpeakerTurn],
699
+ *,
700
+ method: str = "auto",
701
+ model_name: str = DEFAULT_AGE_GENDER_MODEL,
702
+ device: str = "auto",
703
+ log: LogFn = _noop,
704
+ ) -> dict[str, dict[str, Any]]:
705
+ """Estimate per-speaker demographics, dispatching by *method*.
706
+
707
+ method:
708
+ "model" -> dedicated wav2vec2 age/gender model (accurate, age + gender).
709
+ "pitch" -> lightweight F0 heuristic (gender only, no age).
710
+ "auto" -> try the model, fall back to pitch on any failure.
711
+ Returns ``{speaker: {"gender","age","age_group"}}``.
712
+ """
713
+ if method in ("model", "auto"):
714
+ try:
715
+ log(f"[diarize] age/gender: loading model ({model_name})...")
716
+ return estimate_speaker_demographics_model(
717
+ waveform, sr, turns, model_name=model_name, device=device, log=log
718
+ )
719
+ except Exception as exc: # noqa: BLE001
720
+ log(f"[diarize] age/gender model failed, using pitch heuristic: {exc}")
721
+
722
+ log("[diarize] age/gender: pitch heuristic (F0) per speaker...")
723
+ genders = estimate_speaker_genders(waveform, sr, turns, log=log)
724
+ return {
725
+ spk: {"gender": g, "age": None, "age_group": ""} for spk, g in genders.items()
726
+ }
727
+
728
+
729
+ # --------------------------------------------------------------------------- #
730
+ # Cue assignment
731
+ # --------------------------------------------------------------------------- #
732
+ def _overlap(a_start: float, a_end: float, b_start: float, b_end: float) -> float:
733
+ return max(0.0, min(a_end, b_end) - max(a_start, b_start))
734
+
735
+
736
+ def _speaker_gender(info: Any) -> str:
737
+ return info if isinstance(info, str) else (info or {}).get("gender", "")
738
+
739
+
740
+ def _speaker_age_group(info: Any) -> str:
741
+ return "" if isinstance(info, str) else (info or {}).get("age_group", "")
742
+
743
+
744
+ def assign_speakers_to_cues(
745
+ cues: list["Cue"],
746
+ turns: list[SpeakerTurn],
747
+ demographics: dict[str, Any] | None = None,
748
+ *,
749
+ nearest_gap_tolerance: float = 0.4,
750
+ log: LogFn = _noop,
751
+ ) -> int:
752
+ """Tag each cue with the speaker that overlaps it most in time.
753
+
754
+ When a cue overlaps no turn (a small diarization gap), it is assigned to the
755
+ nearest turn within *nearest_gap_tolerance* seconds instead of being left
756
+ blank. *demographics* maps a speaker label to either a gender string or a
757
+ dict ``{"gender","age_group",...}``. Returns the number of cues tagged.
758
+ """
759
+ demographics = demographics or {}
760
+ tagged = 0
761
+ total = len(cues)
762
+ log(f"[diarize] gán speaker vào {total} cue(s)...")
763
+ report_every = max(200, total // 20) if total else 200
764
+ for idx, cue in enumerate(cues, start=1):
765
+ best_speaker = ""
766
+ best_overlap = 0.0
767
+ nearest_speaker = ""
768
+ nearest_gap = float("inf")
769
+ for t in turns:
770
+ if t.end <= cue.start:
771
+ gap = cue.start - t.end
772
+ if gap < nearest_gap:
773
+ nearest_gap = gap
774
+ nearest_speaker = t.speaker
775
+ continue
776
+ if t.start >= cue.end:
777
+ gap = t.start - cue.end
778
+ if gap < nearest_gap:
779
+ nearest_gap = gap
780
+ nearest_speaker = t.speaker
781
+ break
782
+ ov = _overlap(cue.start, cue.end, t.start, t.end)
783
+ if ov > best_overlap:
784
+ best_overlap = ov
785
+ best_speaker = t.speaker
786
+ if not best_speaker and nearest_speaker and nearest_gap <= nearest_gap_tolerance:
787
+ best_speaker = nearest_speaker
788
+ if best_speaker:
789
+ info = demographics.get(best_speaker, {})
790
+ cue.speaker = best_speaker
791
+ cue.speaker_gender = _speaker_gender(info)
792
+ cue.speaker_age_group = _speaker_age_group(info)
793
+ tagged += 1
794
+ if idx % report_every == 0 or idx == total:
795
+ log(f"[diarize] gán cue: {idx}/{total} ({tagged} tagged)...")
796
+ return tagged
797
+
798
+
799
+ # --------------------------------------------------------------------------- #
800
+ # High level entry point
801
+ # --------------------------------------------------------------------------- #
802
+ def diarize_and_tag_cues(
803
+ video: Path,
804
+ cues: list["Cue"],
805
+ *,
806
+ hf_token: str | None = None,
807
+ num_speakers: int | None = None,
808
+ min_speakers: int | None = None,
809
+ max_speakers: int | None = None,
810
+ device: str = "auto",
811
+ detect_gender: bool = True,
812
+ gender_method: str = "auto",
813
+ age_gender_model: str = DEFAULT_AGE_GENDER_MODEL,
814
+ log: LogFn = _noop,
815
+ ) -> dict[str, Any]:
816
+ """Run the full diarization flow and tag *cues* in place.
817
+
818
+ Returns the ``{speaker: {"gender","age","age_group"}}`` registry (possibly
819
+ empty). Raises RuntimeError with an actionable message when the HF token is
820
+ missing.
821
+ """
822
+ token = resolve_hf_token(hf_token)
823
+ if not token:
824
+ raise RuntimeError(
825
+ "Hugging Face token required for diarization. Provide --hf-token, set "
826
+ "the HF_TOKEN environment variable, and accept the model conditions at "
827
+ f"https://hf.co/{DEFAULT_PIPELINE}"
828
+ )
829
+
830
+ total_steps = 5 if detect_gender else 4
831
+ t_all = time.time()
832
+ dev = _pick_device(device)
833
+ n_cues = len(cues)
834
+ _log_step(
835
+ log,
836
+ 1,
837
+ total_steps,
838
+ f"bắt đầu Pass 0 — {n_cues} cue(s), device={dev}, gender={gender_method if detect_gender else 'off'}",
839
+ )
840
+
841
+ tmp_dir = Path(tempfile.mkdtemp(prefix="gemma_diarize_"))
842
+ wav = tmp_dir / "audio.wav"
843
+ max_seconds = None
844
+ if cues:
845
+ max_seconds = max((c.end for c in cues), default=0.0) + 5.0
846
+ _log_step(
847
+ log,
848
+ 2,
849
+ total_steps,
850
+ f"tách audio ffmpeg (16 kHz mono, tối đa {_fmt_dur(max_seconds or 0)})...",
851
+ )
852
+ t0 = time.time()
853
+ extract_audio(video, wav, max_seconds=max_seconds)
854
+ wav_mb = wav.stat().st_size / (1024 * 1024) if wav.is_file() else 0.0
855
+ log(f"[diarize] audio wav ready: {wav_mb:.0f} MB in {_fmt_dur(time.time() - t0)}.")
856
+
857
+ _log_step(log, 3, total_steps, "nạp waveform + chạy pyannote (who spoke when)...")
858
+ t0 = time.time()
859
+ waveform, sr = load_wav(wav)
860
+ aud_dur = _audio_duration_sec(waveform, sr)
861
+ log(f"[diarize] waveform: {_fmt_dur(aud_dur)} @ {sr} Hz.")
862
+
863
+ turns = run_diarization(
864
+ waveform,
865
+ sr,
866
+ token,
867
+ num_speakers=num_speakers,
868
+ min_speakers=min_speakers,
869
+ max_speakers=max_speakers,
870
+ device=device,
871
+ log=log,
872
+ )
873
+ log(f"[diarize] bước pyannote xong trong {_fmt_dur(time.time() - t0)}.")
874
+ if not turns:
875
+ log("[diarize] no speaker turns detected; skipping speaker tags.")
876
+ return {}
877
+
878
+ demographics: dict[str, Any] = {}
879
+ if detect_gender:
880
+ _log_step(
881
+ log,
882
+ 4,
883
+ total_steps,
884
+ f"ước lượng giới/tuổi ({gender_method}) cho {len({t.speaker for t in turns})} speaker(s)...",
885
+ )
886
+ t0 = time.time()
887
+ try:
888
+ demographics = estimate_speaker_demographics(
889
+ waveform,
890
+ sr,
891
+ turns,
892
+ method=gender_method,
893
+ model_name=age_gender_model,
894
+ device=device,
895
+ log=log,
896
+ )
897
+ log(f"[diarize] demographics xong trong {_fmt_dur(time.time() - t0)}.")
898
+ except Exception as exc: # noqa: BLE001
899
+ log(
900
+ f"[diarize] demographics failed ({exc}) — gán speaker không có giới/tuổi."
901
+ )
902
+
903
+ assign_step = 5 if detect_gender else 4
904
+ _log_step(log, assign_step, total_steps, "gán speaker + giới/tuổi vào từng cue...")
905
+ t0 = time.time()
906
+ tagged = assign_speakers_to_cues(cues, turns, demographics, log=log)
907
+ n_spk = len({c.speaker for c in cues if c.speaker})
908
+ log(
909
+ f"[diarize] Pass 0 xong — {tagged}/{n_cues} cue tagged, "
910
+ f"{n_spk} speaker(s), tổng {_fmt_dur(time.time() - t_all)} "
911
+ f"(gán cue: {_fmt_dur(time.time() - t0)})."
912
+ )
913
+ return demographics
requirements-diarize.txt CHANGED
@@ -1,24 +1,24 @@
1
- # Optional dependencies for speaker diarization (Pass 0).
2
- # Only needed when you enable "Phân tích giọng nói nhân vật" / --diarize.
3
- #
4
- # Setup (Windows, NVIDIA GPU recommended):
5
- # 1. Install PyTorch with CUDA matching your driver, e.g.:
6
- # pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu124
7
- # (CPU-only also works but is much slower:)
8
- # pip install torch torchaudio
9
- # 2. pip install -r requirements-diarize.txt
10
- # 3. Create a Hugging Face token at https://hf.co/settings/tokens
11
- # 4. Accept the model conditions at:
12
- # https://hf.co/pyannote/speaker-diarization-community-1
13
- # 5. Set HF_TOKEN env var or paste the token into the app's "HF token" field.
14
- #
15
- # torchcodec is used by pyannote.audio 4.x for audio decoding and needs ffmpeg
16
- # available on PATH (this project already ships/uses ffmpeg).
17
-
18
- # Audio is decoded with soundfile (not torchcodec) for a robust Windows setup.
19
- pyannote.audio>=4.0
20
- torch
21
- torchaudio
22
- soundfile>=0.11
23
- # transformers is needed for the audeering age/gender model (--gender-method model).
24
- transformers>=4.40
 
1
+ # Optional dependencies for speaker diarization (Pass 0).
2
+ # Only needed when you enable "Phân tích giọng nói nhân vật" / --diarize.
3
+ #
4
+ # Setup (Windows, NVIDIA GPU recommended):
5
+ # 1. Install PyTorch with CUDA matching your driver, e.g.:
6
+ # pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu124
7
+ # (CPU-only also works but is much slower:)
8
+ # pip install torch torchaudio
9
+ # 2. pip install -r requirements-diarize.txt
10
+ # 3. Create a Hugging Face token at https://hf.co/settings/tokens
11
+ # 4. Accept the model conditions at:
12
+ # https://hf.co/pyannote/speaker-diarization-community-1
13
+ # 5. Set HF_TOKEN env var or paste the token into the app's "HF token" field.
14
+ #
15
+ # torchcodec is used by pyannote.audio 4.x for audio decoding and needs ffmpeg
16
+ # available on PATH (this project already ships/uses ffmpeg).
17
+
18
+ # Audio is decoded with soundfile (not torchcodec) for a robust Windows setup.
19
+ pyannote.audio>=4.0
20
+ torch
21
+ torchaudio
22
+ soundfile>=0.11
23
+ # transformers is needed for the audeering age/gender model (--gender-method model).
24
+ transformers>=4.40
translate_srt.py CHANGED
@@ -814,19 +814,22 @@ def build_translation_prompt(
814
  speaker_block = (
815
  "\nSPEAKER GUIDE (from voice diarization — who is speaking):\n"
816
  + speaker_registry
817
- + "\n- Each cue's 'speaker' field tells you WHICH character is talking plus "
818
- "their detected gender and age group: gender nam=male, nữ=female, "
819
- "trẻ em=child, chưa rõ=unknown; age thiếu niên=teen, thanh niên=young adult, "
820
- "trung niên=middle-aged, lớn tuổi=elderly.\n"
821
- "- Pick ONE consistent persona (gender + age + pronouns/forms of address) for "
822
- "each speaker label and reuse it every time that speaker talks.\n"
823
- "- Use BOTH gender and age for natural Vietnamese address: e.g. elderly male "
824
- "→ ông/lão/cụ/bác; elderly female → bà/cụ; middle-aged → chú/bác/cô/dì; young "
825
- "adult male anh/cậu; young adult female cô/chị; child bé/cháu/con. Match "
826
- "older-vs-younger relationships (cha/mẹ vs con, bác vs cháu) to the age gaps.\n"
827
- "- Do not flip a character's gender or age between cues.\n"
828
- "- 'chưa rõ' or a missing age means it is uncertain: infer from dialogue, "
829
- "names and the scene image instead of guessing blindly.\n"
 
 
 
830
  )
831
  return (
832
  f"Translate the following subtitle cues from {src} into {target_lang}.\n"
 
814
  speaker_block = (
815
  "\nSPEAKER GUIDE (from voice diarization — who is speaking):\n"
816
  + speaker_registry
817
+ + "\n- Each cue's 'speaker' field tells you WHICH character is talking. "
818
+ "Labels: gender nam=male, nữ=female, trẻ em=child, chưa rõ=unknown; age "
819
+ "thiếu niên=teen, thanh niên=young adult, trung niên=middle-aged, lớn "
820
+ "tuổi=elderly.\n"
821
+ "- GENDER is the reliable anchor: keep each speaker's gender consistent and "
822
+ "use it to pick the right gendered Vietnamese address nam → anh/cậu/chú/"
823
+ "ông/lão...; nữ cô/chị/dì/bà...; never flip a character's gender between "
824
+ "cues.\n"
825
+ "- AGE group is only a ROUGH HINT from voice and may be wrong. Treat it as a "
826
+ "clue, not a rule: YOU decide the most natural address by combining the age "
827
+ "hint with the dialogue, names, relationships and the scene image. If the "
828
+ "age hint clearly conflicts with the context, trust the context.\n"
829
+ "- 'chưa rõ' gender or a missing age means it is uncertain: infer everything "
830
+ "from dialogue, names and the scene image instead of guessing blindly.\n"
831
+ "- Pick ONE consistent persona (forms of address / pronouns) per speaker "
832
+ "label and reuse it every time that speaker talks.\n"
833
  )
834
  return (
835
  f"Translate the following subtitle cues from {src} into {target_lang}.\n"