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 (one that every pinned dependency has a wheel for; Diarizer 1.2.0.0 pins Python 3.12.13 exactly).
- 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 — a PyTorch above pyannote’s floor, installed first
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, cu128, cpu and others. Diarizer 1.2.0.0 uses a single cu128 set for every card from RTX 20xx to RTX 50xx (the cu124 index stops at torch 2.6, below pyannote 4’s floor), and cpu for systems without an NVIDIA 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.
The failure most people hit first is real: pin a CUDA PyTorch that is older than pyannote’s floor, 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 torch==2.6.0+cu124 does not satisfy torch>=2.8 and pulls a generic CPU-only torch from the standard PyPI index. The CUDA wheel is gone, and torch.cuda.is_available() returns False at runtime.
Earlier versions of Diarizer worked around this by installing pyannote first, then force-reinstalling PyTorch from the CUDA index. That puts a CUDA torch back on disk, but one below pyannote’s declared floor, so the environment no longer passes pip check. Version 1.2.0.0 removes the conflict at the source. It pins a PyTorch that already satisfies the floor and installs it first, from its own index. Then it installs pyannote with exact pins:
uv pip install torch==2.11.0 torchaudio==2.11.0 \
--index-url https://download.pytorch.org/whl/cu128
uv pip install -r requirements.txt -c constraints.txt
The resolver sees that the installed torch 2.11.0 satisfies torch>=2.8 and keeps it. There is no --force-reinstall and no version conflict. Three rules keep it that way:
- PyTorch goes in first, from its own index, at a version at or above every floor the other packages declare.
requirements.txtnever liststorchortorchaudio: a PyPI torch there would replace the CUDA wheel.constraints.txtfreezes every transitive dependency to the validated set, so two installs months apart get the same stack.
Diarizer’s SetupService.cs embeds this ordering in the Dependencies step of Setup, with an inline comment explaining why. The step ends with an import check of the engine, PyTorch and pyannote that logs the torch version and whether CUDA is available.
Three packages — same source, two PyTorch stacks
The Diarizer ships in three flavours, packed as three separate ZIPs:
- Release-40xx — for RTX 20xx / 30xx / 40xx. Pulls
torch==2.11.0from thecu128index. - Release-50xx — for RTX 50xx Blackwell. Pulls
torch==2.11.0from thecu128index, the same stack as the 40xx package. - Release-CPU — for systems without a discrete NVIDIA GPU, or for the legitimate “keep the GPU free for something else” case. Pulls
torch==2.11.0from thecpuindex.
The split lives in the C# project file as three Release configurations with DefineConstants: default (40xx), GPU_50XX, or CPU_ONLY. Only TargetSeries and TorchIndexUrl are #if-gated; TorchVersion is 2.11.0 in all three. On the CPU-only build, the GPU radio button is disabled and the device is hardcoded to "cpu"; on the GPU builds, the GPU & RAM step of Setup reads the card through nvidia-smi, shows its name, VRAM and the build target, and warns when the card has less than 6 GB of VRAM or the machine less than 8 GB of RAM. The 40xx and 50xx packages now install the same cu128 stack, so on any RTX 20xx to 50xx card the choice between them no longer changes the wheels.
Why not a single mega-installer with runtime probing? Two reasons. First, a GPU install downloads about 3.5 GB and a CPU install about 1 GB — pre-fetching both stacks is wasteful on slow connections, and the user typically knows what hardware they have. Second, the CUDA and CPU 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 models — and the one step that goes online
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.
The diarization engine sets the first two variables itself, before it imports anything from HuggingFace, and Diarizer’s GetIsolatedEnvVars() redirects every cache on every Python sub-process it launches:
HF_HUB_OFFLINE=1
TRANSFORMERS_OFFLINE=1
HF_HOME=cache/huggingface/
TORCH_HOME=cache/torch/
UV_CACHE_DIR=cache/uv/
UV_PYTHON_INSTALL_DIR=runtime/python/
MPLCONFIGDIR=cache/matplotlib/
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 the Models step of Setup. It runs a separate script that sets HF_HUB_OFFLINE=0 for itself and receives the user’s HF_TOKEN through the environment, never on the command line. It runs huggingface_hub.snapshot_download for each of the three repositories into plain folders under models\. The diarization runs keep the offline default. 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 Settings tab) and accept the terms.
- Generate an access token in their HuggingFace account settings.
- Paste the token into Diarizer’s Settings tab.
The Models step 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.
Long recordings — a streamed split instead of a RAM watchdog
Earlier versions relied on a RAM watchdog beside the Python sub-process. Version 1.2.0.0 removes the cause instead: the per-speaker tracks are written in a stream. The decoded source is a memory-mapped file. Blocks of about 20 seconds are masked per speaker and fed to one FFmpeg encoder per speaker, all running in parallel, so RAM stays at a few blocks whatever the length of the recording. Setup checks for 8 GB of system RAM, and Cancel stops a run cleanly, keeping the files already finished.
Batch mode and the progress protocol
The DIARIZE tab takes a queue of files and folders, audio and video. The whole batch runs in one engine process, so the pyannote pipeline is loaded only once. Each file’s .diarization.json is written last. A batch relaunched on the same folder skips the files that already have one, so it resumes where it stopped. The engine rescales each file’s progress into its share of the batch and reports the whole-batch figure in thousandths, so the progress bar advances continuously.
The communication protocol between the Python and the C# side is the same minimalist convention used elsewhere in the ExpSoft catalogue — single-line text prefixes:
[file] 2/5 <input path> (a file starts)
[progress] 412/1000 (whole-batch progress, in thousandths)
[cli] OK <first track> (12.3s, seed 0) (a file is done)
[cli] SKIP <input path> (already processed)
[anything else] (informational log)
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 kept out of the on-screen log (they stay in the run log file). Other stderr lines are tagged by level, and only real errors get the [ERR] prefix. No JSON-RPC framing, no schema, no version contract to maintain — just text lines.
Drive-portable install — AutoPatchVenvPaths
Like the rest of the ExpSoft catalogue, Diarizer’s install is fully portable. The full chain — uv, a portable Python, the venv, FFmpeg, the three models, all caches — lives under the install folder. Nothing goes into %USERPROFILE%, the registry or PATH; the only system-wide prerequisite is the .NET 8 Desktop Runtime.
At every launch, PathService.AutoPatchVenvPaths() reads the install root recorded in pyvenv.cfg and compares it with the current folder. Only if the install has moved does it rewrite the absolute paths in the venv text files (pyvenv.cfg, the non-binary files of Scripts\, .pth files, direct_url.json in .dist-info/ folders), point the venv at the portable Python, and replace the launcher stubs with real copies of the interpreter. On a venv that has not moved it does nothing.
The repair is designed so that the install folder can move to another drive, or to another Windows machine with the .NET 8 Desktop Runtime, and be repaired at the next launch instead of reinstalled.
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.
- uv and python-build-standalone — MIT / Apache-2.0 / PSF. Portable Python runtime and package installer.
- FFmpeg — LGPL-2.1+ build by BtbN, downloaded at runtime. Reads any audio or video input, writes the WAV / MP3 tracks.
- 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, pin a version at or above every floor your other ML dependencies declare, install it first from its own index, and keep torch out of
requirements.txt. A pin below a floor makes the resolver silently swap in a CPU-only 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.
- On long inputs, stream the heavy step instead of guarding it: a memory-mapped source and one encoder per output keep RAM flat whatever the duration.