testsAndMisc/linux_configuration/misc/tools/_transcribe_diarize.py
Krzysztof kuhy Rudnicki 08aac8d7d1 Flatten i3-configuration and misc/testsAndMisc-bash
i3 config was filed under periodic_background, which it never was. It is
now linux_configuration/i3/ (config + installer) and
linux_configuration/i3blocks/ (the status scripts), both at depth 2.

misc/testsAndMisc-bash/ was a directory named after the repo it already
lived in, so its contents moved up to misc/. Its stray .gitignore came
along, keeping the media rules working from the new location; its
.vscode/tasks.json did not, because it invoked Bash/transcribe.sh, a path
this repo dropped long ago.

Most of this commit is the references the previous flatten missed. Its
regex skipped anything with a variable prefix, which left 16 files --
including check_and_enable_services.sh and install_core_system.sh, the
orchestrators -- still naming $CONFIG_DIR/scripts/periodic_background/...
It had also rewritten three i3 paths into scripts/i3blocks/, a directory
that has never existed; the i3blocks suites caught that by failing.

13 more symlinks broke on the i3 move. Repointed, and cpu_monitor/volume/
disk were run again to confirm the bar still reports.

Depth 139 -> 104. Roughly 47 of the remainder sit in digital_wellbeing
and hosts, still deferred. The rest encode real structure -- a C project,
Python package directories, artgate's routes/styles, systemd unit dirs --
so flattening them further would destroy information rather than
taxonomy.

Verified: 1798 tests at 100%, both i3blocks suites pass, the shell
coverage gate is clean, and the relocated media rules still ignore what
they should while test_fw.srt stays tracked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp
2026-08-23 19:02:18 +02:00

165 lines
4.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Speaker diarization: embed each segment, then cluster the embeddings.
Every heavy dependency (torch, speechbrain, soundfile) is reached through
``_try_import``, so a machine without them degrades to no speaker labels
rather than failing the transcription.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any
from _transcribe_cluster import _kmeans_cosine, _resample_linear
from _transcribe_media import (
_cleanup_temp,
_ffmpeg_transcode_to_wav16_mono,
_try_import,
)
if TYPE_CHECKING:
import types
import numpy as np
import numpy.typing as npt
logger = logging.getLogger(__name__)
_NDIM_2D = 2
_SAMPLE_RATE_16K = 16000
_MIN_SAMPLES_DIAR = 1600
def _load_audio(
audio_path: str,
) -> tuple[Any, int, str | None] | None:
"""Load audio, with ffmpeg fallback.
Returns (wav, sample_rate, temp_path) or None.
"""
sf = _try_import("soundfile")
if sf is None:
return None
try:
wav, sr = sf.read(
audio_path,
dtype="float32",
always_2d=False,
)
except OSError as exc:
alt = _ffmpeg_transcode_to_wav16_mono(audio_path)
if alt is None:
logger.warning(
"Could not read audio for diarization and no ffmpeg fallback: %s",
exc,
)
return None
try:
wav, sr = sf.read(
alt,
dtype="float32",
always_2d=False,
)
except OSError as exc2:
logger.warning(
"Could not read transcoded audio: %s",
exc2,
)
_cleanup_temp(alt)
return None
return wav, sr, alt
return wav, sr, None
def _load_speaker_classifier(
temp_to_cleanup: str | None,
) -> object | None:
"""Load the ECAPA speaker embedding classifier."""
sb_inf = _try_import("speechbrain.inference")
if sb_inf is None:
return None
try:
cache_dir = Path.home() / ".cache" / "speechbrain_ecapa"
classifier = sb_inf.EncoderClassifier.from_hparams(
source="speechbrain/spkrec-ecapa-voxceleb",
run_opts={"device": "cpu"},
savedir=str(cache_dir),
)
except (OSError, RuntimeError) as exc:
logger.warning(
"Could not load speaker embedding model: %s",
exc,
)
_cleanup_temp(temp_to_cleanup)
return None
return classifier
def _extract_embeddings(
segments: list[Any],
wav16: npt.NDArray[np.float32],
classifier: object,
torch_mod: types.ModuleType,
) -> list[Any]:
"""Extract speaker embeddings per segment."""
embs: list[Any] = []
for seg in segments:
s = float(getattr(seg, "start", 0.0) or 0.0)
e = float(getattr(seg, "end", s) or s)
if e <= s:
e = s + 0.2
i0 = int(s * _SAMPLE_RATE_16K)
i1 = int(e * _SAMPLE_RATE_16K)
pad = int(0.05 * _SAMPLE_RATE_16K)
i0 = max(0, i0 - pad)
i1 = min(len(wav16), i1 + pad)
if i1 - i0 < _MIN_SAMPLES_DIAR:
i1 = min(len(wav16), i0 + _MIN_SAMPLES_DIAR)
seg_wav = torch_mod.tensor(wav16[i0:i1]).unsqueeze(0)
with torch_mod.no_grad():
emb = classifier.encode_batch(seg_wav).squeeze(0).squeeze(0).cpu().numpy()
embs.append(emb.astype("float32"))
return embs
def diarize_segments(
audio_path: str,
segments: list[Any],
num_speakers: int = 2,
) -> list[int] | None:
"""Compute speaker embeddings per segment and cluster.
Returns speaker labels aligned with segments,
or None on failure.
"""
torch_mod = _try_import("torch")
if torch_mod is None:
logger.warning(
"Diarization dependencies missing; skipping speaker labels.",
)
return None
audio_result = _load_audio(audio_path)
if audio_result is None:
return None
wav, sr, temp_to_cleanup = audio_result
if wav.ndim == _NDIM_2D:
wav = wav.mean(axis=1)
wav16 = _resample_linear(wav, sr, _SAMPLE_RATE_16K)
classifier = _load_speaker_classifier(temp_to_cleanup)
if classifier is None:
return None
embs = _extract_embeddings(segments, wav16, classifier, torch_mod)
if len(embs) == 0:
return None
labels = _kmeans_cosine(embs, k=max(1, int(num_speakers)))
_cleanup_temp(temp_to_cleanup)
return labels.tolist()