The diarization problem on Windows in 2026
Speaker diarization — answering “who is talking when” inside a multi-speaker audio track — has been a solved research problem for years. pyannote.audio is the state of the art in the open-source space, maintained by Hervé Bredin and a large community of contributors. The models are good. The library is well-documented. The papers are clear.
On Windows in 2026, the gap between “the research works” and “a podcaster on a laptop can use it” is roughly ten to fifteen install steps:
- Install the right version of Python (not 3.13, which still has gaps in the audio-ML ecosystem; not 3.11+ in some specific combinations; settle on 3.10).
- Install the right CUDA toolkit and cuDNN matching your driver and your PyTorch wheel set.
- Create a venv or conda env; choose. Activate it. Stay in it.
- Install PyTorch — but the right one for your GPU generation. The wrong CUDA wheel set fails late, deep inside the first inference call.
- Make a HuggingFace account; accept the conditions on the two gated pyannote models (segmentation-3.0, speaker-diarization-3.1); generate an access token.
- Download the three models (segmentation-3.0, wespeaker-voxceleb, speaker-diarization-3.1) with your token.
- Add FFmpeg to the system
PATHso thattorchaudiocan decode.m4a/.mp3/.aac/.oggfiles. Without it, the library quietly errors out only on non-WAV inputs. - Write a short Python script that ties the pipeline together, parses the RTTM output, and splits the audio per-speaker.
Each step is small. Together, they push a podcaster, a journalist, or a researcher in a linguistics lab — none of whom signed up to be a Python distribution expert — into the kind of all-day yak-shaving that ends with “maybe I’ll just transcribe it manually.”
ExpSoft Diarizer is the personal-use wrapper that turns the ten-to-fifteen-step install into one click. The engineering choices below are what make that click work reliably across machines.
The install-order subtlety — pyannote before PyTorch
This is the decision that the source code calls out in a multi-line comment so that whoever touches the file in two years doesn’t silently re-introduce the bug.
pyannote.audio 4.x declares torch>=2.8 in its package metadata. PyTorch ships separate wheel sets per CUDA version: cu124 for the RTX 20xx/30xx/40xx generations, cu128 for RTX 50xx Blackwell, and cpu for systems without a discrete GPU. The wheels live at separate index URLs on download.pytorch.org/whl/. They cannot be selected by pip install torch against the standard PyPI index alone — you have to pass --index-url explicitly.
Now consider what happens if you install in the order most people reach for first — pin the right PyTorch wheel first, then add pyannote:
pip install torch==2.6.0+cu124 torchaudio==2.6.0+cu124 \
--index-url https://download.pytorch.org/whl/cu124
pip install pyannote.audio
The second command sees that pyannote requires torch>=2.8, looks at what’s installed (torch==2.6.0+cu124), decides that doesn’t satisfy the constraint, and pulls a generic CPU-only torch>=2.8 from the standard PyPI index. Your carefully-pinned CUDA wheel is gone. torch.cuda.is_available() returns False at runtime. The user’s 4090 sits idle.
The reverse order works:
pip install pyannote.audio pydub pyyaml
pip install torch==2.6.0+cu124 torchaudio==2.6.0+cu124 \
--force-reinstall --index-url https://download.pytorch.org/whl/cu124
Now pyannote installs first; pip resolves its torch dependency to a generic CPU torch>=2.8 (which is fine for the metadata check). Then the explicit --force-reinstall overwrites that generic torch with the pinned CUDA wheel. pyannote’s metadata is never re-evaluated by pip, so the install stays “valid” from pip’s perspective. At runtime, the loaded torch is the CUDA one, and the GPU gets used.
This is not a workaround for a bug — it’s a consequence of pip’s installed-package strategy and PyTorch’s separate-index distribution model. Spelling it out:
- The order matters because pip resolves transitive dependencies at install time, not at runtime.
--force-reinstallis essential. Without it, pip sees a satisfiedtorch>=2.8constraint and skips the install.- The
--index-urlat the second step is what redirects to the CUDA wheel rather than the generic one.
Diarizer’s SetupService.cs embeds this ordering in step 5, with a paragraph of inline comment explaining why. The CI pipeline’s smoke test asserts torch.cuda.is_available() after Setup, so any future change to the order breaks the build.
Three build targets — same source, three CUDA wheels
The Diarizer ships in three flavours, packed as three separate ZIPs:
- Release-40xx — for RTX 20xx / 30xx / 40xx. Pulls
torch==2.6.0+cu124. - Release-50xx — for RTX 50xx Blackwell. Pulls
torch==2.7.0+cu128. - Release-CPU — for systems without a discrete NVIDIA GPU, or for the legitimate “keep the GPU free for something else” case. Pulls
torch+cpu.
The split lives in the C# project file as three Release configurations with DefineConstants: default (40xx), GPU_50XX, or CPU_ONLY. The Python-facing constants (TorchVersion, TorchCudaIndex, TorchIndexUrl) are #if-gated. On the CPU-only build, the GPU radio button is disabled and the device is hardcoded to "cpu"; on the GPU builds, the GPU detection step at Setup compares nvidia-smi’s reported card series against TargetSeries and surfaces a friendly warning if the user picked the wrong ZIP (40xx build on a Blackwell card, or vice versa). Catching the mismatch up-front is much better than an obscure runtime error after a 90-minute install.
Why not a single mega-installer with runtime probing? Two reasons. First, the three wheel sets total 4-7 GB of download — pre-fetching all of them is wasteful on slow connections, and the user typically knows what GPU they have. Second, the wheels are mutually exclusive at install time. Switching between them after install means pip uninstall + pip install, which is a lot of disk thrash for what is conceptually a different build. ZIP-level split is cleaner.
Offline-first HF cache — and the one step where it’s temporarily off
Speaker-diarization sessions are usually long: an hour-long podcast, a two-hour interview, a multi-speaker meeting recording. The runs are unattended. The least helpful thing the underlying library can do, halfway through inference, is silently try to fetch a model update over HTTPS and time out behind a corporate proxy.
Diarizer’s GetIsolatedEnvVars() sets the following on every Python sub-process it launches:
HF_HUB_OFFLINE=1
TRANSFORMERS_OFFLINE=1
HF_HOME=cache/HF_HOME/
HUGGINGFACE_HUB_CACHE=cache/HF_HOME/
TRANSFORMERS_CACHE=cache/HF_HOME/
TORCH_HOME=cache/TORCH_HOME/
PIP_CACHE_DIR=cache/PIP_CACHE/
XDG_CACHE_HOME=cache/
The first two variables tell HuggingFace’s library to skip any network call and rely entirely on what’s already in the local cache. The rest redirect every cache location under the app’s own folder, so nothing leaks into %USERPROFILE%\.cache or %APPDATA% — uninstall is “delete the folder” with no residue.
The exception is Setup step 6, the model download. For that step alone, the EasyInstaller overrides HF_HUB_OFFLINE=0 and TRANSFORMERS_OFFLINE=0 in extraEnv for the sub-process, injects the user’s HF_TOKEN, and runs huggingface_hub.snapshot_download for each of the three models. Once those land on disk, the override goes away. Every subsequent run is fully offline. A diarization session can complete on a laptop with no network connection at all.
Gated models — never bundled
Two of the three pyannote models are gated on HuggingFace: pyannote/segmentation-3.0 and pyannote/speaker-diarization-3.1. The license terms require each user to individually accept the conditions on the model page before downloading. Bundling those weights in the Diarizer ZIP would be a clear violation of the gating contract.
So Diarizer never bundles them. The Setup flow walks the user through:
- Create an account on HuggingFace if they don’t have one.
- Visit the two gated model pages (links are clickable in the Welcome tab) and accept the terms.
- Generate an access token in their HuggingFace account settings.
- Paste the token into Diarizer’s Settings tab.
Step 6 of Setup uses the token to download the three models locally. From that point on, the user’s acceptance of the gating terms is recorded against their HuggingFace account, the models live in models/ under the install folder, and the offline-first cache configuration kicks in.
The third model — pyannote/wespeaker-voxceleb-resnet34-LM — is CC-BY-4.0 and not gated, but Diarizer downloads it through the same flow for consistency. The CC-BY attribution requirement is satisfied in the Welcome tab’s Licenses card.
RAM watchdog — the hour-long interview problem
Diarizing a one-hour audio file with pyannote can use a surprising amount of RAM, because the library extracts and holds embeddings for every speech segment in the file before clustering. On a 32 GB machine this is invisible. On a 16 GB laptop with a browser, an IDE, and an audio editor open, it’s the difference between “the diarization finishes in 15 minutes” and “the system swaps for an hour then OOM-kills random processes.”
The RAM watchdog runs in parallel with the Python sub-process. Every three seconds it calls Win32 GlobalMemoryStatusEx and reads ullAvailPhys. If the available physical RAM drops below 2 GB, it sends a graceful cancel signal to the sub-process and logs a user-visible message: “Available RAM critically low (XXX MB). Cancelling to protect system stability.” The user sees the reason — clearer than a silent crash.
Batch mode and the progress protocol
The Run tab has a toggle for batch processing — drop a folder instead of a single file, and Diarizer processes every audio file in the folder in sequence. The progress mapping is straightforward: each file gets a 0-100% local progress, and that local progress is rescaled to the global (i / n, (i+1) / n) band so the global progress bar advances continuously across the batch.
The communication protocol between the Python and the C# side is the same minimalist convention used elsewhere in the ExpSoft catalogue — single-line stdout prefixes:
PROGRESS:42.5 (current-file progress, percentage)
[anything else] (informational log, written to the dark-console pane)
The C# side parses each OutputDataReceived line, dispatches by prefix, and updates either the progress bar or the log. tqdm-style stderr lines from pyannote (containing % / it/s / s/it) are recognised by pattern and shown clean in the log without the [ERR] prefix. No JSON-RPC framing, no schema, no version contract to maintain — just stdout.
Drive-portable install — AutoPatchVenvPaths
Like the rest of the ExpSoft catalogue, Diarizer’s install is fully portable. The full chain — Miniconda, the venv, the three models, all caches — lives under the install folder. Nothing in %USERPROFILE% or the registry or PATH.
At every launch, PathService.AutoPatchVenvPaths() reads the venv’s recorded root path from pyvenv.cfg, compares it to the current AppContext.BaseDirectory, and if they differ rewrites every absolute path reference in the venv text files (activate.bat, pip.exe launchers, .pth files, direct_url.json in .dist-info/ folders, conda-meta JSONs). The patch is idempotent — a no-op on a venv that’s already correct.
Drop the install folder on a USB stick, plug into a different Windows machine, double-click. Diarizer launches. No reinstall.
Credits and license
- pyannote.audio — Hervé Bredin and contributors, MIT. The diarization toolkit, the pipeline, the years of research and engineering. All credit for the diarization capability belongs upstream.
- pyannote/segmentation-3.0 — MIT (gated). User-individual acceptance of terms required.
- pyannote/wespeaker-voxceleb-resnet34-LM — CC-BY-4.0. Attribution: WeSpeaker and pyannote contributors; base dataset: VoxCeleb.
- pyannote/speaker-diarization-3.1 — MIT (gated). User-individual acceptance of terms required.
- PyTorch — Meta + community, BSD-3-Clause.
- Miniconda — Anaconda Inc, BSD-3-Clause.
- Portable Git for Windows — GPL-2.0.
- FFmpeg — LGPL-2.1+ / GPL-2.0+. Audio decoding for non-WAV inputs.
- Microsoft.Web.WebView2 — Microsoft, distribution permitted.
The C# / WPF EasyInstaller shell is original ExpSoft work and distributed independently of pyannote.audio. No models are bundled — the user downloads them with their own HF token, which records the individual acceptance of the gating terms against their own HuggingFace account.
Take-aways
- For any Python install that pins PyTorch against a CUDA wheel index, install your other ML dependencies first, then do a
pip install torch --force-reinstall --index-url .... Reverse the order and pip’s metadata resolver will silently downgrade you to CPU torch. - For long unattended ML runs, set
HF_HUB_OFFLINE=1at every sub-process invocation. The only window where it should be off is the explicit model-download step. - Gated models stay user-fetched, never bundled. The Setup flow walks the user through token generation and gating acceptance.
- A 3-second-tick RAM watchdog with a 2 GB safety floor is cheap insurance against OOM-killing the host on long inputs. Better a graceful cancel with a clear message than a hard crash.