Feat/diarization multi speaker uncertainty - #537
Conversation
…ckends VibeVoice-ASR-HF joins the multi-speaker vote as a 4th, architecturally distinct diarizer (in-process, transformers>=5.3). The USC-SAIL child-adult classifier is a separate signal (adult_voice_detected) for flagging a parent prompting/assisting a child mid-recording -- a role question the speaker-count vote doesn't answer. CUDA-only: upstream hardcodes .cuda() with no clean CPU path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a subprocess-venv backend for OpenMOSS-Team/MOSS-Transcribe-Diarize (0.9B, Apache 2.0), a lightweight unified ASR+diarization model that's CPU-plausible, wired into diarize_audios() dispatch alongside the existing Pyannote/Sortformer/VibeVoice/child-adult backends. Also wires it into analyze_audio.py's model dispatch/CLI help and registers it in model_registry.yaml, matching the other diarization backends. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
…comment model_registry.md and the diarize_audios dispatch comment in compatibility.py hadn't been regenerated/updated after the MOSS and DiariZen backends were added, and compatibility-matrix.md's isolated backends table was missing both. Also picks up an unrelated stale line in model_registry.md (YAMNet's description) that had drifted from the YAML source before this session. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
mypy --extra-checks flags `model = HFModel(...)` assignments needing an explicit annotation; add `: HFModel` across all nine occurrences in speaker_diarization_test.py (five predate this branch's other commits, four are the MOSS/DiariZen tests added here). Also picks up an unrelated ruff-format line collapse in child_adult.py and an end-of-file fix in model_registry.md, both surfaced by running `pre-commit run --all-files` rather than ad hoc ruff/mypy invocations. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…/child-adult/VibeVoice Each backend's subprocess worker (or in-process VibeVoice load) previously resolved its internal model dependencies (base Whisper repos, DiariZen's embedding model, the main checkpoints) via per-call Hub lookups, which is the source of 429s under parallel batch load. Route them through hf_subprocess_env/load_hf_resilient so the commit SHA is resolved once (cross-process heartbeat lock) and the worker runs fully offline. Manually verified locally (both tests are HF-download-heavy and stay skip-marked in CI): test_diarize_audios_with_diarizen and test_diarize_audios_with_moss both pass. VibeVoice/child-adult require a working CUDA stack this machine doesn't currently have, consistent with their existing "run manually on a GPU machine" skip reasons. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
a627683 to
f17c6b0
Compare
Code review —
|
- child_adult.py: cap the Stage-1 PyTorch index at cu121 (max_cuda_version=(12,1)) so the torch==2.3.0 pin can build on hosts with CUDA >= 12.4, which otherwise raise SenselabCudaCompatibilityError on every call (brouhaha.py precedent). - vibevoice.py: cast input tensors to the model's actual dtype (not just device) before generation — previously only .to(device), which crashes CUDA inference with a dtype mismatch (CPU worked by coincidence since fp32 == fp32). - child_adult.py: clone the upstream repo to a sibling temp dir + os.replace under an flock, instead of cloning straight into repo_dir. An interrupted clone no longer wedges the guard permanently, and concurrent jobs sharing $HOME no longer race into the same directory. - api.py/vibevoice.py/moss.py: thread max_new_tokens through the public diarize_audios() API (previously unreachable), and log a distinct warning when generation hits the token budget without an end token, so truncation is no longer indistinguishable from "no speech detected" in the result. - vibevoice.py: widen the parse guard from json.JSONDecodeError alone to also catch KeyError/IndexError/TypeError/ValueError, since truncated output is more likely to hit those than a clean decode error (JSONDecodeError is itself a ValueError subclass, so this is a pure widening). Verified: ruff/mypy clean, both worker-script string literals compile standalone, and test_diarize_audios_with_diarizen + test_diarize_audios_with_moss both pass after the change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…2,13) - child_adult.py/moss.py: forward model.revision through to the worker's from_pretrained/hf_hub_download calls and the hf_subprocess_env staging call, instead of hardcoding "main" — a caller pinning a specific revision was silently getting a different (latest) one. - diarizen.py: DiariZenPipeline.from_pretrained() has no revision parameter upstream at all (always resolves the latest snapshot via a bare snapshot_download), so warn when a non-default revision is requested instead of silently ignoring it. - child_adult.py: docstring now states precisely what upstream's chunking loop drops — not just "a trailing partial window" but zero windows for any clip <= 10s, and the final 10s block even on longer clips (strict `start + 10 < length`) — and calls out that an empty result is indistinguishable from "no adult present." - speaker_diarization_test.py: fix the child-adult skip gate two ways — honor SENSELAB_VENV_CACHE via subprocess_venv._cache_dir() instead of hardcoding ~/.cache/senselab/venvs (so it actually matches where the venv gets built), and skip on !CUDA too (previously only gated on venv presence, so it would run-and-fail rather than skip on a CPU host sharing $HOME with a GPU node). Also make the test capable of catching a broken backend at all: the 4.9s fixture was under the 10s window, so both `all(...)` assertions vacuously passed over an empty list regardless of correctness — now uses a ~14.8s concatenated clip and asserts the result is non-empty. - dependencies.py: hf_subprocess_env's staging fallback now logs a warning instead of silently reverting to online Hub loading — that revert is exactly the per-call 429 path this function exists to remove, and previously nothing recorded when it happened. - api.py/model.py: narrow the VibeVoice prefix match from "microsoft/VibeVoice" to "microsoft/VibeVoice-ASR" — the bare prefix also matched the 1.5B/-Large TTS checkpoints, which would reach VibeVoiceAsrForConditionalGeneration.from_pretrained and fail with an opaque config error. - api.py: warn (rather than silently drop) when num_speakers/min_speakers/ max_speakers are passed to VibeVoice/child-adult/MOSS/DiariZen, which only Pyannote honors. Verified: ruff/mypy clean across all touched files; full speaker_diarization_test.py + hf_load_coverage_test.py + dependencies_test.py pass (24 passed, 6 skipped — the expected GPU/manual-only tests); DiariZen + MOSS backend tests re-verified end-to-end after the revision-plumbing change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…14-16) - vibevoice.py: load with dtype="auto" instead of the shared device-selector's fp16 default — this checkpoint's config declares bfloat16 and every upstream example uses dtype="auto"; forcing fp16 on a bf16-trained 7B model loses exponent range. The input-dtype-cast fix from the prior commit already reads the model's actual loaded dtype, so this isn't tied to fp16 either way. - vibevoice.py: close the rest of the parse-guard finding — float(seg["Start"])/ seg["End"] conversion is now per-segment try/except, so one malformed segment is skipped (with a warning) instead of aborting the whole audio the same way a decode()-level failure wouldn't. - vibevoice.py/stages.py: add VibeVoiceDiarization.release_all() (clears the class-level model cache + torch.cuda.empty_cache()) and call it from stage_diarization right after a VibeVoice outcome is recorded. VibeVoice is a 7B model with no natural eviction point, unlike the other three new backends, which are subprocess-hosted and free memory on process exit — left resident, it risks OOM alongside another large in-process model. - clustering.py/identity.py: exclude role-label backends (child-adult's CHILD/ADULT/OVERLAP) from both the embedding-clustering path and its no-embedding raw-label fallback, AND from identity.py's diar_ok (so they don't participate in cross-diar-model agreement voting at all). Without this, wiring child-adult into --diarization-models would corrupt the identity-clustering axis (a CHILD centroid blending two children; OVERLAP snapping to whichever centroid is nearest) and read as spurious disagreement in __cross_diar_label_disagreement__ against every real diar model. child-adult isn't in the default --diarization-models list today, so this is a guard against future misconfiguration, not a live default-config bug. - stages.py/stage_context.py: document (rather than silently redesign) the two remaining architectural findings — joint ASR+diar models (VibeVoice/MOSS) wasting their transcript and double-paying if registered under both --diarization-models and --asr-models, and STAGE_VERSIONS["diarization"]'s cache-invalidation blast radius now being shared across six backends instead of two. Both are genuine contract changes to shared stage-pipeline infrastructure, not something to rush without a design discussion — noted in-place so a future maintainer sees the tradeoff before hitting it. NOT fixed by this commit: #15 and #16 remain open, documentation-only. Verified: ruff/mypy clean across all touched files; stages_test.py + stage_context_test.py (40 passed) and speaker_diarization_test.py (11 passed, 6 skipped) show no regressions; manually exercised cluster_speaker_labels_by_embedding (both the embedding and no-embedding fallback paths) and harvest_identity_votes with a synthetic pass_summary containing both a real diar model and child-adult, confirming child-adult is excluded from clustering output and identity votes while the real model's entries are preserved. Caveat: VibeVoice and child-adult were NOT actually run end-to-end on this machine — the NVIDIA driver here is too old for CUDA (child-adult requires CUDA outright; VibeVoice is a 7B model impractical on CPU), so the dtype="auto", release_all(), and segment-guard changes to vibevoice.py are verified only via ruff/mypy, a standalone import + release_all() smoke test, and manual code review, not a real inference run. Running the (currently skip-marked) test_diarize_audios_with_vibevoice and test_diarize_audios_with_child_adult on a working CUDA machine is the one remaining gap before calling this fix set fully verified. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-up review —
|
| # | Status | Evidence |
|---|---|---|
| 1 torch pin | fixed | _CHILD_ADULT_MAX_CUDA_VERSION = (12, 1) (child_adult.py:59) passed at :232; ensure_venv accepts it at subprocess_venv.py:198 |
| 2 dtype cast | fixed, improved | model_dtype = next(vv_model.parameters()).dtype, cast gated on v.is_floating_point() — ties to what actually loaded rather than assuming fp16, which is better than the model.dtype I proposed |
| 3 clone wedge | fixed | fcntl.flock + mkdtemp + os.replace (see finding 4 below for the residue) |
| 4 max_new_tokens | fixed, exceeded | Exposed at api.py:44, forwarded at :146/:162 — and you added the truncation warning at vibevoice.py:180-186, which closes the silent-truncation half I flagged but didn't ask for |
| 5 fp16 vs bf16 | fixed | dtype="auto" at :86 with the rationale in-comment; _select_device_and_dtype's dtype now discarded |
| 6 parse guard | fixed | Widened at :193 (see finding 5 below) |
| 7 revision dropped | fixed | child_adult threads hf_revision → hf_hub_download; moss forwards model_revision to both from_pretrained calls; diarizen can't forward (upstream DiariZenPipeline.from_pretrained has no revision param) so it now warns explicitly at :193-200 instead of dropping silently — good resolution |
| 8 window loop | documented, not fixed | :183-200 now states the strict <, that a ≤10 s clip analyzes zero windows, and that the final 10 s block is always dropped. Behavior unchanged — which matters for finding 2 below |
| 9 test gate | fixed | _cache_dir() at :27, CUDA gate added (see finding 7 below) |
| 10 7B cache | fixed, but see finding 1 | release_all() added, wrong call site |
| 11 silent staging | fixed | Warning at dependencies.py:576 before returning the online env. The no-HF_TOKEN claim also checks out — I queried the Hub: BUT-FIT/diarizen-wavlm-large-s80-md and pyannote/wespeaker-voxceleb-resnet34-LM are both gated=False, versus gated="auto" for pyannote/speaker-diarization-community-1, so the contrast the docstring draws is accurate |
| 12 prefix breadth | fixed | ("microsoft/VibeVoice-ASR",) at api.py:16 and model.py:334 |
| 13 speaker hints | fixed, incomplete | See finding 3 below |
| 14 role labels | fixed, incomplete | See finding 2 below |
| 15 joint ASR+diar | not fixed | Unchanged, and reasonably so — it's a stages.py contract change, not a patch |
| 16 shared counter | documented, not fixed | A 10-line note explaining the grown blast radius and deferring the per-backend-family split until a concrete bump forces it. Agreed as the right call |
New findings
1. stages.py:175 — medium: release_all() is called per clip, so the model cache is dead code.
stage_diarization runs once per pass/clip via run_pass, so the class-level cache is emptied after every clip and the 7B (~14 GB) model is fully re-loaded on the next one. A 500-clip run with --diarization-models microsoft/VibeVoice-ASR-HF does 500 from_pretrained + .to(cuda) cycles instead of 1, and VibeVoiceDiarization's documented "repeated calls with the same configuration reuse the initialized model" never holds in the only in-repo caller. It also fires on a run_task_cached hit, where the model was never loaded — a sign the release is keyed to the wrong event. The OOM concern is real; the release belongs at end-of-run, or gated on a subsequent large in-process stage.
2. presence.py:201 — medium: the role-label guard missed the third diar consumer.
ROLE_LABEL_ONLY_PREFIXES was applied to clustering.py (both paths) and identity.py's diar_ok, but not presence.py's diar_ok, which also treats every status-ok diar model as a peer voter (:314, via diar_speaks_in_window). Combined with finding 8 above — child-adult emits zero segments for any clip ≤ 10 s and always drops the final 10 s block — and harvesters.py:32 mapping "no overlapping segment" to speaks=False, wiring child-adult into --diarization-models makes it actively vote "no speech" over regions that contain speech, rather than abstaining. Same misconfiguration the other two guards were added to prevent.
3. api.py:138 — low: _warn_if_speaker_hints_ignored skips the pre-existing Sortformer branch.
It was added to all four new backends, but diarize_audios_with_nvidia_sortformer (nvidia.py:93) also accepts no speaker-hint parameters, so diarize_audios(..., model=HFModel("nvidia/diar_sortformer_4spk-v1"), num_speakers=2) still drops the hint silently — the exact behavior the warning was introduced to eliminate. The max_speakers docstring ("NVIDIA Sortformer is limited to 4 speakers") compounds it by reading as though the hint were honored.
4. child_adult.py:112 — low: the atomic clone leaks a temp dir per failed attempt.
mkdtemp(prefix=".child-adult-clone-", dir=repo_dir.parent) is followed by sp.run([...git clone...], check=True); on clone failure the raise propagates while finally releases only the flock. The partial .child-adult-clone-XXXX directory stays in the venv dir and one more accumulates per retry. Wrap the clone and shutil.rmtree(tmp_clone_dir, ignore_errors=True) on failure.
5. vibevoice.py:193 — low: the widened guard now also swallows API-contract breakage.
Catching KeyError/IndexError/TypeError/ValueError around processor.decode covers truncated output, but if a future transformers revision changes decode's signature or the return_format="parsed" contract, every audio yields segments = [], the stage records a status-ok outcome with an empty result, and run_task_cached persists it — so a total backend break presents as "no speech detected" and survives subsequent runs. Consider re-raising when every audio in a batch fails to parse.
6. clustering.py:37 — low: the role-only prefix list is now duplicated in four places.
ROLE_LABEL_ONLY_PREFIXES restates api.py's _CHILD_ADULT_PREFIXES as an independent literal, and stages.py:175 / model.py:335 similarly hand-copy "microsoft/VibeVoice-ASR". The plural name invites a second role-only backend; when one arrives, a maintainer wiring dispatch in api.py gets no signal that clustering.py, identity.py and presence.py also need updating — silently reintroducing the identity-axis corruption this fix set removed. Deriving the workflow-side guard from the dispatch constants would make the coupling explicit.
7. speaker_diarization_test.py:27 — low: _cache_dir() added an import-time filesystem side effect.
_cache_dir() calls cache.mkdir(parents=True, exist_ok=True), which the old Path.home() / ".cache" / … expression did not. On a host where the venv-cache root isn't writable (read-only HOME, sandboxed CI), collection of the whole module raises instead of the tests skipping. The gate only needs the path, not its creation.
Also verified, no problem found
hf_subprocess_env's new warning (logger is defined at dependencies.py:14; rid/rev are always bound because repos always contains the primary repo); MOSS's new result["generated_tokens"] truncation check (upstream generate_transcription does return that key, counting new tokens only); VibeVoice's output_ids[0, inputs["input_ids"].shape[1]:] slice and the Start/End/Speaker/Content keys (both match the official model-card example); concatenate_audios reachable via the star-import in preprocessing/__init__.py; uv.lock pins transformers 5.5.4, consistent with the >=5.3 floor; MOSS's own metadata pins transformers>=5.6,<6 so the un-pinned venv requirement resolves correctly; ruff check passes on all touched paths.
The test suite was not run — both interpreters available in the review environment have incomplete dependency sets (.venv missing typing_extensions, the py3.10 env missing dotenv), so the CUDA-gated and venv-gated tests remain unexercised here.
Closes all 7 new findings from the post-85947c9a review: release VibeVoice's 7B-param cache once per file instead of per-clip (was forcing a reload between raw/enhanced passes), extend the child-adult role-label guard to presence.py (it only covered clustering/identity), warn on ignored speaker hints for the pre-existing Sortformer branch too, clean up a leaked temp dir on a failed child-adult clone, raise instead of silently caching an empty result when VibeVoice's output fails to parse for an entire batch, collapse the role-label prefix list to one source of truth in api.py, and drop an import-time mkdir side effect from a test's skip-gate helper.
Description
Related Issue(s)
Motivation and Context
How Has This Been Tested?
Screenshots (if appropriate):
Types of changes
Checklist: