Skip to content

Commit 1f972a9

Browse files
Evan8456claude
andcommitted
speaker_diarization: add DiariZen backend
Adds a subprocess-venv backend for DiariZen (BUTSpeechFIT), a WavLM-Conformer EEND + VBx-clustering diarization toolkit, wired into diarize_audios() dispatch alongside the existing backends. Installs DiariZen's own forked pyannote-audio (needed for VBx clustering, absent from the PyPI pyannote.audio release) since neither package declares runtime dependencies in its packaging metadata. Also wires it into analyze_audio.py's model dispatch/CLI help and registers it in model_registry.yaml, matching the other diarization backends. Note: DiariZen's pretrained weights are CC BY-NC 4.0 (non-commercial only), unlike this package's other diarization backends. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 9f84a95 commit 1f972a9

5 files changed

Lines changed: 289 additions & 5 deletions

File tree

scripts/analyze_audio.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
**nvidia/diar_sortformer_4spk-v1** (HFModel, ≤ 4 speakers, NeMo)
2424
**microsoft/VibeVoice-ASR-HF** (HFModel, unified ASR+diarization, in-process)
2525
OpenMOSS-Team/MOSS-Transcribe-Diarize (HFModel, unified ASR+diarization, 0.9B, subprocess venv)
26+
BUT-FIT/diarizen-wavlm-large-s80-md (HFModel, diarization only, subprocess venv, weights CC BY-NC 4.0)
2627
2728
child-adult speaker-role classification (separate from diarization; CUDA only):
2829
**AlexXu811/whisper-child-adult** (HFModel, CHILD/ADULT/OVERLAP role, not identity)
@@ -218,10 +219,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
218219
"diarization/multi_speaker_uncertainty.json) has 4 architecturally-diverse voters "
219220
"instead of a single-family split. "
220221
"Pyannote ids → PyannoteAudioModel; NeMo ids (nvidia/diar_sortformer*), "
221-
"VibeVoice ids (microsoft/VibeVoice*), and MOSS-Transcribe-Diarize ids "
222-
"(OpenMOSS-Team/MOSS-Transcribe-Diarize*) → HFModel. Pass "
223-
"OpenMOSS-Team/MOSS-Transcribe-Diarize explicitly to add it as a 5th voter "
224-
"(not run by default; provisions its own subprocess venv on first use)."
222+
"VibeVoice ids (microsoft/VibeVoice*), MOSS-Transcribe-Diarize ids "
223+
"(OpenMOSS-Team/MOSS-Transcribe-Diarize*), and DiariZen ids (BUT-FIT/diarizen*) "
224+
"→ HFModel. Pass OpenMOSS-Team/MOSS-Transcribe-Diarize or "
225+
"BUT-FIT/diarizen-wavlm-large-s80-md explicitly to add them as extra voters "
226+
"(not run by default; each provisions its own subprocess venv on first use. "
227+
"DiariZen's pretrained weights are CC BY-NC 4.0 — non-commercial only)."
225228
),
226229
)
227230
parser.add_argument(
@@ -573,6 +576,7 @@ def pick_dispatch_model(model_id: str, *, task: str) -> Any: # noqa: ANN401
573576
model_id.startswith("nvidia/diar_sortformer")
574577
or model_id.startswith("microsoft/VibeVoice")
575578
or model_id.startswith("OpenMOSS-Team/MOSS-Transcribe-Diarize")
579+
or model_id.startswith("BUT-FIT/diarizen")
576580
):
577581
return HFModel(path_or_uri=model_id)
578582
return PyannoteAudioModel(path_or_uri=model_id)

src/senselab/audio/tasks/speaker_diarization/api.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from senselab.audio.data_structures import Audio
66
from senselab.audio.tasks.speaker_diarization.child_adult import diarize_audios_with_child_adult
7+
from senselab.audio.tasks.speaker_diarization.diarizen import diarize_audios_with_diarizen
78
from senselab.audio.tasks.speaker_diarization.moss import diarize_audios_with_moss
89
from senselab.audio.tasks.speaker_diarization.nvidia import diarize_audios_with_nvidia_sortformer
910
from senselab.audio.tasks.speaker_diarization.pyannote import diarize_audios_with_pyannote
@@ -14,6 +15,7 @@
1415
_VIBEVOICE_PREFIXES = ("microsoft/VibeVoice",)
1516
_CHILD_ADULT_PREFIXES = ("AlexXu811/whisper-child-adult",)
1617
_MOSS_PREFIXES = ("OpenMOSS-Team/MOSS-Transcribe-Diarize",)
18+
_DIARIZEN_PREFIXES = ("BUT-FIT/diarizen",)
1719

1820

1921
@requires_compatibility("audio.tasks.speaker_diarization.diarize_audios")
@@ -45,6 +47,11 @@ def diarize_audios(
4547
`"OpenMOSS-Team/MOSS-Transcribe-Diarize"`, uses MOSS-Transcribe-Diarize (0.9B, Apache 2.0)
4648
via an isolated subprocess venv (needs ``transformers>=5.6``, kept out of the core
4749
environment — see ``moss.py``).
50+
- If `model` is an `HFModel` and `model.path_or_uri` starts with `"BUT-FIT/diarizen"`,
51+
uses DiariZen (WavLM-Conformer EEND + VBx clustering; diarization only, no
52+
transcription) via an isolated subprocess venv that installs DiariZen's own
53+
forked pyannote-audio — code MIT, but **model weights are CC BY-NC 4.0
54+
(non-commercial only)**; see ``diarizen.py``.
4855
4956
Args:
5057
audios (list[Audio]):
@@ -56,6 +63,7 @@ def diarize_audios(
5663
* ``HFModel(path_or_uri="microsoft/VibeVoice...")`` → VibeVoice-ASR-HF.
5764
* ``HFModel(path_or_uri="AlexXu811/whisper-child-adult")`` → USC-SAIL child-adult.
5865
* ``HFModel(path_or_uri="OpenMOSS-Team/MOSS-Transcribe-Diarize")`` → MOSS-Transcribe-Diarize.
66+
* ``HFModel(path_or_uri="BUT-FIT/diarizen-wavlm-large-s80-md")`` → DiariZen.
5967
num_speakers (int | None):
6068
If known, fix the number of speakers (Pyannote only).
6169
min_speakers (int | None):
@@ -129,8 +137,15 @@ def diarize_audios(
129137
model=model,
130138
device=device,
131139
)
140+
elif isinstance(model, HFModel) and str(model.path_or_uri).startswith(_DIARIZEN_PREFIXES):
141+
return diarize_audios_with_diarizen(
142+
audios=audios,
143+
model=model,
144+
device=device,
145+
)
132146
else:
133147
raise NotImplementedError(
134148
"Only Pyannote, NVIDIA Sortformer, VibeVoice-ASR-HF, the USC-SAIL child-adult "
135-
"classifier, and MOSS-Transcribe-Diarize (from HuggingFace) models are supported for now."
149+
"classifier, MOSS-Transcribe-Diarize, and DiariZen (from HuggingFace) models are "
150+
"supported for now."
136151
)
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
"""DiariZen diarization via isolated subprocess venv.
2+
3+
DiariZen (BUTSpeechFIT, code MIT / **weights CC BY-NC 4.0 — non-commercial
4+
only**) is a WavLM-Conformer EEND + clustering diarization toolkit built on
5+
top of a *forked* pyannote-audio. The default checkpoint
6+
(``BUT-FIT/diarizen-wavlm-large-s80-md``) uses VBx clustering
7+
(``config.toml``'s ``[clustering.args] method = "VBxClustering"``), a
8+
clustering algorithm that only exists in that fork
9+
(``diarizen/pyannote-audio`` on GitHub) and is absent from the ``pyannote.audio``
10+
package published on PyPI — so this backend installs the fork, not the
11+
upstream package, from the same repo via pip's ``#subdirectory=`` VCS syntax.
12+
13+
The upstream ``diarizen`` and forked ``pyannote-audio`` packages declare no
14+
runtime dependencies at all in their packaging metadata (an unmaintained
15+
``setup.cfg`` for the fork; no ``[project.dependencies]`` for ``diarizen``
16+
itself) — upstream's own install recipe relies on a separate
17+
``requirements.txt`` instead. This backend pins that same dependency set
18+
explicitly here rather than depending on a file fetched over the network at
19+
install time.
20+
21+
No ``transformers`` dependency: unlike MOSS or VibeVoice, DiariZen's WavLM
22+
encoder is its own vendored, torchaudio-style implementation
23+
(``diarizen.models.module.wav2vec2``), not ``transformers.WavLMModel``.
24+
25+
Neither the diarization checkpoint nor its internal embedding-model dependency
26+
(``pyannote/wespeaker-voxceleb-resnet34-LM``, downloaded automatically by
27+
``DiariZenPipeline.from_pretrained``) is gated on Hugging Face, so — unlike
28+
the Pyannote backend already in this package — no ``HF_TOKEN`` is required.
29+
30+
``DiariZenPipeline`` hardcodes its device selection internally (CUDA if
31+
available, else CPU) with no override hook, so the ``device`` argument here
32+
only validates the request isn't impossible before spawning the subprocess;
33+
it can't force a specific device inside the pipeline itself.
34+
"""
35+
36+
from __future__ import annotations
37+
38+
import json
39+
import subprocess
40+
import tempfile
41+
from pathlib import Path
42+
from typing import List, Optional
43+
44+
from senselab.audio.data_structures import Audio
45+
from senselab.utils.data_structures import DeviceType, HFModel, ScriptLine, _select_device_and_dtype
46+
from senselab.utils.subprocess_venv import _clean_subprocess_env, ensure_venv, parse_subprocess_result, venv_python
47+
48+
_DIARIZEN_VENV = "diarizen"
49+
_DIARIZEN_REQUIREMENTS = [
50+
"diarizen @ git+https://github.com/BUTSpeechFIT/DiariZen.git",
51+
"pyannote-audio @ git+https://github.com/BUTSpeechFIT/DiariZen.git#subdirectory=pyannote-audio",
52+
"torch>=2.1,<2.9",
53+
"torchaudio>=2.1.1,<2.9",
54+
# The rest mirror pyannote-audio/requirements.txt in the DiariZen repo (that
55+
# fork's own packaging declares no install_requires, so these must be pinned
56+
# here explicitly rather than resolved transitively).
57+
"asteroid-filterbanks>=0.4",
58+
"einops>=0.6.0",
59+
"huggingface_hub>=0.13.0",
60+
"lightning>=2.0.1",
61+
"omegaconf>=2.1,<3.0",
62+
"pyannote.core==5.0.0",
63+
"pyannote.database==5.1.3",
64+
"pyannote.metrics==3.2.1",
65+
"pyannote.pipeline==3.0.1",
66+
"pytorch_metric_learning>=2.1.0",
67+
"rich>=12.0.0",
68+
"semver>=3.0.0",
69+
"soundfile>=0.12.1",
70+
"speechbrain>=0.5.14",
71+
"tensorboardX>=2.6",
72+
"torch_audiomentations>=0.11.0",
73+
"torchmetrics>=0.11.0",
74+
"scipy",
75+
"toml",
76+
"psutil",
77+
"accelerate",
78+
"numpy<2",
79+
]
80+
_DIARIZEN_PYTHON = "3.12"
81+
_DIARIZEN_DEFAULT_MODEL = "BUT-FIT/diarizen-wavlm-large-s80-md"
82+
83+
# Worker script — runs inside the isolated venv.
84+
_WORKER_SCRIPT = r"""
85+
import json
86+
import sys
87+
88+
try:
89+
args = json.loads(sys.stdin.read())
90+
audio_paths = args["audio_paths"]
91+
model_name = args["model_name"]
92+
93+
import torch
94+
95+
# The DiariZen checkpoint was saved under an older PyTorch/Lightning, whose
96+
# pickled metadata embeds custom objects (torch's own TorchVersion,
97+
# pyannote-audio's Specifications, ...) beyond plain tensors. PyTorch >=2.6
98+
# defaults `torch.load` to `weights_only=True`, which rejects any global not
99+
# explicitly allowlisted, and pyannote-audio's own internal loading code
100+
# doesn't take a weights_only override we could pass through. Force
101+
# `weights_only=False` for this isolated subprocess venv only — reasonable
102+
# here since the checkpoint comes straight from the official BUT-FIT HF
103+
# repo for the exact backend being loaded, same trust level as this
104+
# package's own `trust_remote_code=True` usage elsewhere.
105+
_original_torch_load = torch.load
106+
107+
def _torch_load_full(*load_args, **load_kwargs):
108+
load_kwargs["weights_only"] = False
109+
return _original_torch_load(*load_args, **load_kwargs)
110+
111+
torch.load = _torch_load_full
112+
113+
from diarizen.pipelines.inference import DiariZenPipeline
114+
115+
pipeline = DiariZenPipeline.from_pretrained(model_name)
116+
117+
all_results = []
118+
for audio_path in audio_paths:
119+
annotation = pipeline(audio_path)
120+
segments = [
121+
{"speaker": str(speaker), "start": float(turn.start), "end": float(turn.end)}
122+
for turn, _, speaker in annotation.itertracks(yield_label=True)
123+
]
124+
segments.sort(key=lambda s: s["start"])
125+
all_results.append(segments)
126+
127+
print(json.dumps({"results": all_results}))
128+
except Exception as exc:
129+
import traceback
130+
err = {
131+
"type": type(exc).__name__,
132+
"message": str(exc),
133+
"traceback": traceback.format_exc(limit=5),
134+
}
135+
print(json.dumps({"error": err}))
136+
sys.exit(1)
137+
"""
138+
139+
140+
def diarize_audios_with_diarizen(
141+
audios: List[Audio],
142+
model: Optional[HFModel] = None,
143+
device: Optional[DeviceType] = None,
144+
) -> List[List[ScriptLine]]:
145+
"""Diarize audios with **DiariZen**; returns per-speaker segments per audio.
146+
147+
DiariZen (``BUT-FIT/diarizen-wavlm-large-s80-md`` by default) is a
148+
WavLM-Conformer EEND segmentation model followed by VBx clustering, run
149+
via ``DiariZenPipeline`` (from the ``diarizen`` package and its forked
150+
``pyannote-audio`` dependency, both installed in this backend's isolated
151+
venv). Diarization only — no transcription (unlike MOSS/VibeVoice),
152+
so returned `ScriptLine`s carry `speaker`/`start`/`end` but no `text`.
153+
154+
**License note**: the ``diarizen`` and forked ``pyannote-audio`` code is
155+
MIT, but the pretrained model weights are **CC BY-NC 4.0 — non-commercial
156+
use only**.
157+
158+
Args:
159+
audios (list[Audio]):
160+
Audio clips to diarize.
161+
model (HFModel | None):
162+
Defaults to ``HFModel(path_or_uri="BUT-FIT/diarizen-wavlm-large-s80-md")``.
163+
Other checkpoints from the same org (e.g. ``BUT-FIT/diarizen-wavlm-large-s80-md-v2``,
164+
``BUT-FIT/diarizen-meeting-base``) also work.
165+
device (DeviceType | None):
166+
Preferred device. ``DiariZenPipeline`` selects its device
167+
internally (CUDA if available, else CPU) with no override hook;
168+
this argument only validates the request isn't impossible before
169+
spawning the subprocess.
170+
171+
Returns:
172+
list[list[ScriptLine]]: One list per input audio; each `ScriptLine` carries
173+
`speaker` (e.g. `"SPEAKER_00"`), `start`, and `end`.
174+
175+
Example:
176+
>>> from pathlib import Path
177+
>>> from senselab.audio.data_structures import Audio
178+
>>> from senselab.utils.data_structures import DeviceType
179+
>>> a1 = Audio(filepath=Path("sample1.wav").resolve())
180+
>>> lines = diarize_audios_with_diarizen([a1], device=DeviceType.CPU) # doctest: +SKIP
181+
>>> len(lines) == 1 # doctest: +SKIP
182+
True
183+
"""
184+
if model is None:
185+
model = HFModel(path_or_uri=_DIARIZEN_DEFAULT_MODEL)
186+
187+
_select_device_and_dtype(user_preference=device, compatible_devices=[DeviceType.CUDA, DeviceType.CPU])
188+
189+
venv_dir = ensure_venv(_DIARIZEN_VENV, _DIARIZEN_REQUIREMENTS, python_version=_DIARIZEN_PYTHON)
190+
python = venv_python(venv_dir)
191+
192+
with tempfile.TemporaryDirectory(prefix="senselab-diarizen-") as tmpdir:
193+
tmp = Path(tmpdir)
194+
audio_paths = []
195+
for i, audio in enumerate(audios):
196+
path = str(tmp / f"audio_{i}.wav")
197+
audio.save_to_file(path)
198+
audio_paths.append(path)
199+
200+
input_json = json.dumps(
201+
{
202+
"audio_paths": audio_paths,
203+
"model_name": str(model.path_or_uri),
204+
}
205+
)
206+
207+
env = _clean_subprocess_env()
208+
result = subprocess.run(
209+
[python, "-c", _WORKER_SCRIPT],
210+
input=input_json,
211+
capture_output=True,
212+
text=True,
213+
timeout=1200,
214+
env=env,
215+
)
216+
217+
output = parse_subprocess_result(result, "DiariZen")
218+
219+
results: List[List[ScriptLine]] = []
220+
for segments in output.get("results", []):
221+
script_lines = [
222+
ScriptLine(
223+
speaker=str(seg.get("speaker", "")),
224+
start=float(seg.get("start", 0.0)),
225+
end=float(seg.get("end", 0.0)),
226+
)
227+
for seg in segments
228+
]
229+
results.append(sorted(script_lines, key=lambda x: x.start or 0.0))
230+
231+
return results

src/senselab/model_registry.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,16 @@
205205
training_data: OpenMOSS proprietary
206206
recommended_for: Unified ASR + diarization, lightweight and CPU-plausible (via subprocess venv, transformers>=5.6)
207207

208+
- name: DiariZen
209+
task: speaker_diarization
210+
source: BUT-FIT
211+
model_id: BUT-FIT/diarizen-wavlm-large-s80-md
212+
parameters: WavLM-large + Conformer
213+
training_data: BUT-FIT proprietary
214+
recommended_for: >-
215+
Diarization only, no transcription (via subprocess venv installing DiariZen's
216+
forked pyannote-audio; weights CC BY-NC 4.0 — non-commercial only)
217+
208218
# Speech Emotion Recognition
209219
- name: SUPERB SER (IEMOCAP)
210220
task: speech_emotion_recognition

src/tests/audio/tasks/speaker_diarization_test.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,19 @@ def test_diarize_audios_dispatches_to_moss(monkeypatch: pytest.MonkeyPatch) -> N
163163
mock_fn.assert_called_once()
164164

165165

166+
def test_diarize_audios_dispatches_to_diarizen(monkeypatch: pytest.MonkeyPatch) -> None:
167+
"""diarize_audios routes a BUT-FIT/diarizen model id to the DiariZen backend."""
168+
sentinel = [[ScriptLine(speaker="SPEAKER_00", start=0.0, end=1.0)]]
169+
mock_fn = Mock(return_value=sentinel)
170+
monkeypatch.setattr(diarization_api, "diarize_audios_with_diarizen", mock_fn)
171+
172+
model = HFModel(path_or_uri="BUT-FIT/diarizen-wavlm-large-s80-md")
173+
result = diarize_audios(audios=[], model=model)
174+
175+
assert result is sentinel
176+
mock_fn.assert_called_once()
177+
178+
166179
@pytest.mark.skip(reason="Downloads a 7B model; run manually on a GPU machine")
167180
def test_diarize_audios_with_vibevoice(resampled_mono_audio_sample: Audio) -> None:
168181
"""Test diarizing audios with VibeVoice-ASR-HF."""
@@ -185,6 +198,17 @@ def test_diarize_audios_with_moss(resampled_mono_audio_sample: Audio) -> None:
185198
assert all(isinstance(line, ScriptLine) for line in results[0])
186199

187200

201+
@pytest.mark.skip(reason="Provisions a dedicated venv (forked pyannote-audio) and downloads a model; run manually")
202+
def test_diarize_audios_with_diarizen(resampled_mono_audio_sample: Audio) -> None:
203+
"""Test diarizing audios with DiariZen."""
204+
from senselab.audio.tasks.speaker_diarization.diarizen import diarize_audios_with_diarizen
205+
206+
model = HFModel(path_or_uri="BUT-FIT/diarizen-wavlm-large-s80-md")
207+
results = diarize_audios_with_diarizen(audios=[resampled_mono_audio_sample], model=model)
208+
assert len(results) == 1
209+
assert all(isinstance(line, ScriptLine) for line in results[0])
210+
211+
188212
@pytest.mark.skipif(
189213
not child_adult_venv_present,
190214
reason=f"child-adult-diarization venv not provisioned at {_CHILD_ADULT_VENV_ROOT}",

0 commit comments

Comments
 (0)