Skip to content

fix: speaker detection — gated pyannote license surfaces a docs deeplink (closes #78) - #110

Merged
debpalash merged 3 commits into
mainfrom
fix/issue-78-speaker-detection-v2
May 20, 2026
Merged

fix: speaker detection — gated pyannote license surfaces a docs deeplink (closes #78)#110
debpalash merged 3 commits into
mainfrom
fix/issue-78-speaker-detection-v2

Conversation

@debpalash

@debpalash debpalash commented May 20, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #78. The reporter saw two real speakers in the source video get merged into one or get swapped ("person A speaks like person B"). The structural cause is that pyannote-3.1 is gated on HuggingFace — a valid HF_TOKEN by itself isn't enough; the user must also click "Agree and access repository" on both pyannote/speaker-diarization-3.1 AND pyannote/segmentation-3.0. When that hasn't happened, dub_core._diarize silently falls back to a 1.2s-silence-gap heuristic that alternates Speaker 1Speaker 2 and (worse) feeds the wrong reference clip to the auto-speaker-clone step downstream.

We can't accept the license for the user, but we can make the failure mode discoverable. The fix shape is structured-error-with-docs-deeplink, not a code path correction.

Repro

POST /dub/transcribe-stream/{job_id} with no HF token (or with a token whose account hasn't accepted the pyannote license). Before this PR: SSE warning event with plain {detail, source}, no actionable next step. After: SSE warning carries {detail, source, error_class, docs_url} where docs_url deeplinks to the "License acceptance flow" section of docs/features/diarization.md (landed in PR #94).

Root cause

  • services/model_manager.py::get_diarization_pipeline() caught every load exception identically and returned bare None. The dub pipeline then couldn't distinguish "no token" from "license not accepted" from "torch/pyannote version mismatch", so the warning toast was the same for all three.
  • The SSE warning event the front-end consumes had no error_class / docs_url fields, so the existing error→docs map (backend/core/error_docs_map.py from PR Phase 1 Wave 2: per-OS install docs + Settings UI + error→docs deeplinks #94) never got plumbed for the diarization path.
  • The TS-side classifyError heuristic treated any pyannote 401 as the generic HF_AUTH_FAILED class, which deeplinks to the HF-token-setup doc — useful, but not the page with the "click Agree on pyannote/speaker-diarization-3.1" instructions.

Fix shape

Better error surface, per the issue brief. No code-path correction is possible — the license is the user's to accept.

  • backend/services/model_manager.py: get_diarization_pipeline() gains opt-in return_error=True shape returning (pipeline | None, error_sentinel). Sentinels: NO_TOKEN / PYANNOTE_LICENSE_REQUIRED / LOAD_FAILED. New _classify_diarization_error() sniffs the exception name + message for 401/403/gated/accept-license signals — string-based so it survives huggingface_hub major-version churn (the GatedRepoError / HfHubHTTPError symbols aren't stable). Bare-None default is preserved for the legacy _transcribe call site at dub_core.py:781.
  • backend/api/routers/dub_core.py::_diarize: emits structured SSE warning {detail, source, error_class, docs_url} with the new fields populated from error_docs_map.lookup(error_class).
  • backend/core/error_docs_map.py + frontend/src/utils/errorDocsMap.ts: add 5th taxonomy class PYANNOTE_LICENSE_REQUIREDdocs/features/diarization.md#license-acceptance-flow. Distinct from HF_AUTH_FAILED (which is the more general "token missing or invalid" case). TS classifyError checks pyannote / gated / accept-license keywords BEFORE the generic 401 branch so the more specific deeplink wins.
  • TS + Python keys-sync tests bumped to lock the 5-class taxonomy.

HF token plumbing

Unchanged. All reads still go through services/token_resolver.py::resolve() per AUTH-01. grep -n "os.environ.get.*HF_TOKEN" backend/services/model_manager.py backend/api/routers/dub_core.py is still empty.

Cross-platform

Identical behaviour on macOS / Windows / Linux — the only platform-touching change is a docs URL string opened via the existing openExternal() helper that abstracts Tauri's shell.open.

Test plan

  • .venv/bin/python -m pytest tests/test_diarization_error_class.py tests/backend/core/test_error_docs_map.py -v20 passed in 0.02s
  • bun run test src/utils/errorDocsMap.test.ts13 passed (1 file), including new classifier cases for pyannote / gated / accept-license keyword routing
  • .venv/bin/python -m pytest tests/test_segmentation.py tests/test_dub_transcribe.py tests/backend/services/test_token_resolver.py tests/test_model_manager_preload.py40 passed, 10 xfailed (pre-existing), 1 xpassed
  • Manual: post-merge, confirm a real POST /dub/transcribe-stream/{job_id} against a multi-speaker clip with the pyannote license not accepted on the dev HF account emits the SSE warning event with error_class=PYANNOTE_LICENSE_REQUIRED and docs_url pointing at the diarization docs section.

Test coverage added

tests/test_diarization_error_class.py — 20 cases:

  • _classify_diarization_error — 401 / 403 / gated repo / "accept license" / "accept user conditions" / named exception class → LICENSE; CUDA OOM / pickle weights-only → LOAD.
  • get_diarization_pipeline(return_error=True) — NO_TOKEN / LICENSE / LOAD all return the right sentinel; bare-None legacy shape preserved.
  • error_docs_mapPYANNOTE_LICENSE_REQUIRED deeplinks to docs/features/diarization.md#license-acceptance-flow; new class is in the locked taxonomy.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Diarization failures now produce clearer, classified warnings (authentication, license/acceptance, or model load) so users see distinct, actionable messages.
    • When fallback heuristics are used, a structured warning is emitted instead of an opaque message.
  • Documentation

    • Warnings include direct deep links to relevant diarization docs for next steps.
  • Tests

    • Added regression and unit tests covering error classification and documentation linking.

Review Change Stack

debpalash and others added 2 commits May 20, 2026 13:43
…ink (closes #78)

Issue #78 ("Speaker detection fails — speakers blend together or aren't
detected correctly") was the user-visible symptom of the dub pipeline
silently falling back to the silence-gap heuristic in
`backend/api/routers/dub_core.py::_diarize`. The heuristic alternates
Speaker 1 ↔ Speaker 2 on >1.2s gaps only, so two real speakers with
similar pacing get merged or swapped — and once the auto-clone step
extracts a reference voice for the wrong label, downstream dubs make
"person A speak like person B" (the reporter's exact phrasing).

The structural cause is that pyannote-3.1 is gated on HuggingFace: a
valid HF_TOKEN by itself isn't enough — the user must also click
"Agree and access repository" on both pyannote/speaker-diarization-3.1
AND pyannote/segmentation-3.0. We can't fix that for the user, but we
CAN make the failure actionable instead of silent.

Changes:

- `backend/services/model_manager.py`: `get_diarization_pipeline()`
  gains an opt-in `return_error=True` shape that returns
  `(pipeline | None, error_sentinel)`. Sentinels distinguish
  NO_TOKEN / PYANNOTE_LICENSE_REQUIRED / LOAD_FAILED. A new
  `_classify_diarization_error()` sniffs the exception's class name +
  message for 401/403/gated/"accept license" signals — kept as a
  string heuristic so it survives huggingface_hub major-version
  churn. Bare-`None` default return preserved for the legacy
  `_transcribe` call site at dub_core.py:781.

- `backend/api/routers/dub_core.py::_diarize`: now emits a structured
  SSE warning `{detail, source, error_class, docs_url}` instead of
  plain `{detail, source}`. The new fields let the front-end render a
  "See docs" button that deeplinks directly to the
  `License acceptance flow` section of `docs/features/diarization.md`
  (landed in PR #94) — the page with the click-by-click instructions
  for fixing this exact failure mode.

- `backend/core/error_docs_map.py` + `frontend/src/utils/errorDocsMap.ts`:
  add a 5th taxonomy class `PYANNOTE_LICENSE_REQUIRED` pointing at the
  diarization docs section. Distinct from `HF_AUTH_FAILED` (which is
  the more general "token missing or invalid" case). The TS
  `classifyError` heuristic also picks up pyannote / gated /
  "speaker diarization" keywords so a thrown error in the boundary
  routes to the right deeplink too.

- `tests/backend/core/test_error_docs_map.py`: bump locked-keys set to
  5 classes; add an explicit assertion that the new class points at
  the `license-acceptance-flow` anchor.

- `frontend/src/utils/errorDocsMap.test.ts`: bump locked-keys set to
  5 classes; add classifier tests for pyannote / gated / accept-license
  keyword routing.

- `tests/test_diarization_error_class.py`: regression test (20 cases)
  covering `_classify_diarization_error`, the new
  `get_diarization_pipeline(return_error=True)` shape, backward-
  compatible bare-`None` return for the legacy call site, and the
  error_docs_map deeplink target. Uses sys.modules patching so
  pyannote / torch are never actually imported.

HF token plumbing: unchanged. The new code continues to route through
`token_resolver.resolve()` per the AUTH-01 contract — no new bare
`os.environ.get("HF_TOKEN")` reads.

Cross-platform: identical behaviour on macOS / Windows / Linux —
the only platform-touching change is a docs URL string, which is
opened via the existing `openExternal()` helper that already abstracts
Tauri's `shell.open` on all three platforms.

Verification:

  .venv/bin/python -m pytest tests/test_diarization_error_class.py \
      tests/backend/core/test_error_docs_map.py -v
  # 20 passed in 0.03s

  bun run test src/utils/errorDocsMap.test.ts
  # 13 passed (1 test file)

  .venv/bin/python -m pytest tests/test_segmentation.py \
      tests/test_dub_transcribe.py \
      tests/backend/services/test_token_resolver.py \
      tests/test_model_manager_preload.py
  # 40 passed, 10 xfailed (pre-existing), 1 xpassed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…78)

Companion to the fix in d6e6586. 20 test cases covering:

- `_classify_diarization_error` — the string heuristic that buckets
  pyannote/HF exceptions into NO_TOKEN / LICENSE / LOAD sentinels.
  Pinned for 401/403/gated/accept-license/accept-user-conditions
  signals so it survives huggingface_hub major-version churn.
- `get_diarization_pipeline(return_error=True)` — the new 2-tuple
  return shape that lets the dub pipeline's SSE warning carry an
  error_class.
- Backward compatibility — the bare-`None` return on the default
  signature is preserved so dub_core.py:781's legacy `_transcribe`
  call site doesn't break.
- The error_docs_map deeplink — the new PYANNOTE_LICENSE_REQUIRED
  class points at `docs/features/diarization.md#license-acceptance-flow`.

Uses sys.modules patching for pyannote.audio.Pipeline + token_resolver
so the real torch + pyannote + HF API are never imported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e38a8a10-2125-4e7e-b3ab-3fa9542e0514

📥 Commits

Reviewing files that changed from the base of the PR and between 2f19eaa and 95e4afb.

📒 Files selected for processing (1)
  • tests/test_diarization_error_class.py

📝 Walkthrough

Walkthrough

Adds a 5-class diarization error taxonomy, classifies pyannote failures into missing-token/license/load sentinels, returns sentinel-aware pipeline tuples, and emits structured SSE warning events with docs deeplinks; frontend taxonomy and comprehensive tests were updated to match.

Changes

Diarization Error Handling & Documentation

Layer / File(s) Summary
Backend error taxonomy contract
backend/core/error_docs_map.py
Error docs mapping extends from 4-class to 5-class taxonomy, adding PYANNOTE_LICENSE_REQUIRED as an exported key with docs deeplink to the diarization license-acceptance section.
Diarization error classification logic
backend/services/model_manager.py
Module-level sentinel constants (DIARIZATION_ERR_NO_TOKEN, DIARIZATION_ERR_LICENSE, DIARIZATION_ERR_LOAD) and _classify_diarization_error() helper detect auth/license vs. load failures by exception type and message inspection. get_diarization_pipeline(return_error=True) returns (pipeline, sentinel) tuples while maintaining None-return backward compatibility.
Frontend error taxonomy and classification
frontend/src/utils/errorDocsMap.ts
ERROR_DOCS and ERROR_CLASS_KEYS extended with PYANNOTE_LICENSE_REQUIRED. classifyError() detects Pyannote license/terms-acceptance phrasing before generic Hugging Face auth checks, routing gated-model messages to PYANNOTE_LICENSE_REQUIRED.
Transcription router diarization fallback
backend/api/routers/dub_core.py
_diarize() helper now requests return_error=True, resolves HF token, and classifies diarization failures into distinct error_class buckets. Fallback SSE warning event is restructured to include detail, error_class, and docs_url for actionable error guidance.
Backend error docs mapping tests
tests/backend/core/test_error_docs_map.py
Test suite verifies the 5-class taxonomy contract, extends assertions for PYANNOTE_LICENSE_REQUIRED, and confirms deeplink to diarization docs license-acceptance-flow section.
Frontend error classification tests
frontend/src/utils/errorDocsMap.test.ts
Frontend tests extend taxonomy assertions and add dedicated test verifying pyannote/gated-model error message variants are classified as PYANNOTE_LICENSE_REQUIRED instead of generic HF_AUTH_FAILED.
Diarization error classification integration tests
tests/test_diarization_error_class.py
Comprehensive regression test module covering _classify_diarization_error() sentinel mapping, get_diarization_pipeline() tuple-return behavior under return_error=True, legacy bare-None return for backward compatibility, mocked pyannote pipeline load scenarios, and error_docs_map deeplink validation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 When Pyannote speaks of gated dreams,
We catch the error, decode its themes—
License, token, load—each sentinel clear,
With docs close by, no need to fear!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and specifically summarizes the main change: fixing speaker detection by surfacing a gated pyannote license error with a docs deeplink, directly addressing the issue number.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering Summary, detailed Changes across multiple files, Root cause analysis, Fix shape, HF token plumbing, Cross-platform considerations, Test plan with results, and Test coverage added sections that align with and exceed the template requirements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-78-speaker-detection-v2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/api/routers/dub_core.py`:
- Around line 588-600: The SSE warning currently always sets error_class =
"PYANNOTE_LICENSE_REQUIRED" which hides load failures; change the logic in the
diarization failure handling (the block that references err_sentinel,
DIARIZATION_ERR_LOAD, resolved, and detail) to set error_class conditionally: if
err_sentinel indicates a license problem (the original license sentinel), keep
"PYANNOTE_LICENSE_REQUIRED", otherwise set a distinct load error class such as
"DIARIZATION_LOAD_FAILED" (or use DIARIZATION_ERR_LOAD) so runtime/load failures
are preserved and propagated to clients; apply the same conditional branching
where similar code appears around the other occurrence (lines ~619-623).

In `@tests/test_diarization_error_class.py`:
- Around line 34-37: The loop that tries to remove modules uses a conditional on
getattr(sys.modules.get(mod_name), "__file__", None) which prevents removing
already-imported modules and allows state leakage; change the logic to
unconditionally remove the entries from sys.modules for each mod_name (i.e.,
call sys.modules.pop(mod_name, None) for "core.config" and
"services.model_manager") so the fixture truly forces a fresh import when the
test runs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 16178252-f613-4167-a423-ae5c97c1584d

📥 Commits

Reviewing files that changed from the base of the PR and between 1edd35c and 2f19eaa.

📒 Files selected for processing (7)
  • backend/api/routers/dub_core.py
  • backend/core/error_docs_map.py
  • backend/services/model_manager.py
  • frontend/src/utils/errorDocsMap.test.ts
  • frontend/src/utils/errorDocsMap.ts
  • tests/backend/core/test_error_docs_map.py
  • tests/test_diarization_error_class.py

Comment on lines +588 to +600
# err_sentinel == DIARIZATION_ERR_LOAD (or unexpected None
# with a resolved token — historical safety net).
who = resolved.username or "(whoami suppressed)"
reason = (
detail = (
f"Speaker diarization model failed to load even though an HF "
f"token was found (source={resolved.source}, user={who}). "
f"Most common cause: the pyannote/speaker-diarization-3.1 "
f"license has not been accepted on HuggingFace by this "
f"account. See backend logs for the underlying error. "
f"Falling back to a silence-gap heuristic; rapid speaker "
f"turns may be merged."
f"Most common causes: the pyannote/speaker-diarization-3.1 "
f"license has not been accepted on HuggingFace, or there is "
f"a pyannote/torch version mismatch. See backend logs for "
f"the underlying error. Falling back to a silence-gap "
f"heuristic; rapid speaker turns may be merged."
)
return assign_speakers_heuristic(all_segments), reason
error_class = "PYANNOTE_LICENSE_REQUIRED"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve distinct load-vs-license error classes in SSE warnings

Line 600 and Line 620-623 currently collapse load failures into PYANNOTE_LICENSE_REQUIRED, so LOAD_FAILED never reaches clients. That misclassifies runtime/load errors as license issues and breaks the intended taxonomy.

Suggested fix
-                    error_class = "PYANNOTE_LICENSE_REQUIRED"
+                    error_class = "LOAD_FAILED"
@@
-                error_class = (
-                    "PYANNOTE_LICENSE_REQUIRED"
-                    if err_class_post == DIARIZATION_ERR_LICENSE
-                    else "PYANNOTE_LICENSE_REQUIRED"  # LOAD failures land here too
-                )
+                error_class = (
+                    "PYANNOTE_LICENSE_REQUIRED"
+                    if err_class_post == DIARIZATION_ERR_LICENSE
+                    else "LOAD_FAILED"
+                )

Also applies to: 619-623

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/routers/dub_core.py` around lines 588 - 600, The SSE warning
currently always sets error_class = "PYANNOTE_LICENSE_REQUIRED" which hides load
failures; change the logic in the diarization failure handling (the block that
references err_sentinel, DIARIZATION_ERR_LOAD, resolved, and detail) to set
error_class conditionally: if err_sentinel indicates a license problem (the
original license sentinel), keep "PYANNOTE_LICENSE_REQUIRED", otherwise set a
distinct load error class such as "DIARIZATION_LOAD_FAILED" (or use
DIARIZATION_ERR_LOAD) so runtime/load failures are preserved and propagated to
clients; apply the same conditional branching where similar code appears around
the other occurrence (lines ~619-623).

Comment thread tests/test_diarization_error_class.py
…les purge

The new `test_diarization_error_class.py` tests pass in isolation but fail
in the full suite — Wave 1's `fresh_resolver` fixture aggressively purges
all `services.*` and `core.*` modules from `sys.modules` mid-suite. When
this file's tests later did `from services import token_resolver` then
`monkeypatch.setattr(token_resolver, "resolve", ...)`, the local
`token_resolver` reference bound to a stale module identity. The function
under test does `from services import token_resolver` at call time, which
re-resolves through the (post-purge) `sys.modules['services.token_resolver']`
— a different object — so the monkeypatch was applied to one ID and the
function read from another.

Two fixes in this commit:

1. Don't pop `services.token_resolver` in this file's `model_manager`
   fixture — the test body's import and the function's import must agree
   on identity. Popping forces re-import that can create two distinct
   modules.

2. Use the dotted-path form `monkeypatch.setattr("services.token_resolver.resolve", ...)`
   instead of the object-attribute form. Pytest's dotted form re-resolves
   the path through `sys.modules` at setattr time, so the binding is
   always on the live module object regardless of which identity the test
   imported earlier.

Verified: `pytest tests/ -q` → 442 passed, 0 failures (was 1 failed
before this commit on PR #110).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@debpalash
debpalash merged commit f7dedfc into main May 20, 2026
8 checks passed
@debpalash
debpalash deleted the fix/issue-78-speaker-detection-v2 branch May 20, 2026 08:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Speaker detection fails

1 participant