fix: speaker detection — gated pyannote license surfaces a docs deeplink (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>
This commit is contained in:
debpalash
2026-05-20 13:43:54 +05:30
co-authored by Claude Opus 4.7
parent 1edd35cfd0
commit d6e65868d2
6 changed files with 210 additions and 39 deletions
+80 -21
View File
@@ -529,14 +529,25 @@ async def dub_transcribe_stream(job_id: str):
return
def _diarize():
"""Returns (segments, warning_or_None).
"""Returns (segments, warning_payload_or_None).
Warning is set when we silently fell back to the silence-gap
heuristic (no HF_TOKEN, model unavailable, or pyannote raised).
The heuristic only detects speaker turns from >1.2s silences,
so a rapid-fire man↔woman exchange will read as one speaker.
`warning_payload` is a structured dict
`{detail, error_class, docs_url}` whenever we silently fell back
to the silence-gap heuristic (no HF_TOKEN, model unavailable,
license not accepted, or pyannote raised). The heuristic only
detects speaker turns from >1.2s silences, so a rapid-fire
man↔woman exchange will read as one speaker. Issue #78 — we
attach an `error_class` so the front-end's errorDocsMap can
render a "See docs" deeplink instead of a dead-end toast.
"""
diar_pipe = get_diarization_pipeline()
from services.model_manager import (
DIARIZATION_ERR_LICENSE,
DIARIZATION_ERR_LOAD,
DIARIZATION_ERR_NO_TOKEN,
)
from core import error_docs_map
diar_pipe, err_sentinel = get_diarization_pipeline(return_error=True)
if not diar_pipe:
# Phase 1 AUTH-01: ask the resolver (App → Env → HF-CLI),
# not just the env var. This is the #35 fix — users who
@@ -545,8 +556,9 @@ async def dub_transcribe_stream(job_id: str):
# read the token. Now the cascade is honoured.
from services import token_resolver
resolved = token_resolver.resolve()
if not resolved:
reason = (
if err_sentinel == DIARIZATION_ERR_NO_TOKEN or not resolved:
detail = (
"Speaker diarization is disabled because no HuggingFace token "
"was found in any source (Settings → API Keys, the HF_TOKEN "
"env var, or ~/.cache/huggingface/token from `huggingface-cli "
@@ -556,28 +568,70 @@ async def dub_transcribe_stream(job_id: str):
"heuristic — turns with no audible pause between them will "
"be merged into one speaker."
)
else:
error_class = "HF_AUTH_FAILED"
elif err_sentinel == DIARIZATION_ERR_LICENSE:
who = resolved.username or "(whoami suppressed)"
reason = (
detail = (
f"Speaker diarization model is gated — the "
f"pyannote/speaker-diarization-3.1 license has not been "
f"accepted on HuggingFace by this account "
f"(source={resolved.source}, user={who}). Visit "
f"huggingface.co/pyannote/speaker-diarization-3.1 AND "
f"huggingface.co/pyannote/segmentation-3.0 while signed "
f"in and click 'Agree and access repository' on both, "
f"then restart this dub job. Falling back to a "
f"silence-gap heuristic; rapid speaker turns may be "
f"merged into one speaker."
)
error_class = "PYANNOTE_LICENSE_REQUIRED"
else:
# err_sentinel == DIARIZATION_ERR_LOAD (or unexpected None
# with a resolved token — historical safety net).
who = resolved.username or "(whoami suppressed)"
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"
return (
assign_speakers_heuristic(all_segments),
{
"detail": detail,
"error_class": error_class,
"docs_url": error_docs_map.lookup(error_class),
},
)
try:
diar = diar_pipe(asr_audio_target)
return assign_speakers_from_diarization(all_segments, diar), None
except Exception as e:
logger.error(f"Diarization failed: {e}")
# Mid-run failure — classify against the same sentinels so a
# post-load 401 (rare but possible after a token rotation)
# still gets the right docs deeplink.
from services.model_manager import _classify_diarization_error
err_class_post = _classify_diarization_error(e)
error_class = (
"PYANNOTE_LICENSE_REQUIRED"
if err_class_post == DIARIZATION_ERR_LICENSE
else "PYANNOTE_LICENSE_REQUIRED" # LOAD failures land here too
)
return (
assign_speakers_heuristic(all_segments),
f"Speaker diarization crashed mid-run ({type(e).__name__}); "
"falling back to a silence-gap heuristic. Rapid speaker turns "
"may be merged."
{
"detail": (
f"Speaker diarization crashed mid-run "
f"({type(e).__name__}); falling back to a silence-gap "
f"heuristic. Rapid speaker turns may be merged."
),
"error_class": error_class,
"docs_url": error_docs_map.lookup(error_class),
},
)
fut_diar = loop.run_in_executor(_gpu_pool, _diarize)
@@ -590,8 +644,13 @@ async def dub_transcribe_stream(job_id: str):
break
yield _sse_event("ping", {})
if diar_warning:
logger.warning("diarization fallback: %s", diar_warning)
yield _sse_event("warning", {"detail": diar_warning, "source": "diarization"})
logger.warning("diarization fallback: %s", diar_warning.get("detail"))
yield _sse_event("warning", {
"detail": diar_warning.get("detail"),
"source": "diarization",
"error_class": diar_warning.get("error_class"),
"docs_url": diar_warning.get("docs_url"),
})
job["segments"] = final_segs
+13 -5
View File
@@ -4,7 +4,7 @@ Used by the React ErrorBoundary's "Open docs for this error" button (via the
TypeScript mirror at `frontend/src/utils/errorDocsMap.ts`) and by the Phase 5
bug-reporter for "this error has a docs page" links.
The 4-class taxonomy below is the contract — Phase 5 reporter consumes it,
The 5-class taxonomy below is the contract — Phase 5 reporter consumes it,
the TS map mirrors it, and `test_error_docs_map.test_keys_match_taxonomy`
locks the key set. To add a new class:
@@ -20,10 +20,18 @@ from core import links
_BASE = links.PROJECT_REPO_BLOB_MAIN
ERROR_DOCS: dict[str, str] = {
"GATEKEEPER_QUARANTINE": f"{_BASE}/docs/install/macos.md#gatekeeper-quarantine",
"APPIMAGE_WEBKIT_WHITESCREEN":f"{_BASE}/docs/install/linux.md#appimage-white-screen-on-fedora-44--ubuntu-2404",
"PKG_RESOURCES_MISSING": f"{_BASE}/docs/install/troubleshooting.md#pkg_resources-missing",
"HF_AUTH_FAILED": f"{_BASE}/docs/setup/huggingface-token.md",
"GATEKEEPER_QUARANTINE": f"{_BASE}/docs/install/macos.md#gatekeeper-quarantine",
"APPIMAGE_WEBKIT_WHITESCREEN": f"{_BASE}/docs/install/linux.md#appimage-white-screen-on-fedora-44--ubuntu-2404",
"PKG_RESOURCES_MISSING": f"{_BASE}/docs/install/troubleshooting.md#pkg_resources-missing",
"HF_AUTH_FAILED": f"{_BASE}/docs/setup/huggingface-token.md",
# Issue #78 — pyannote/speaker-diarization-3.1 + pyannote/segmentation-3.0
# are both gated on HuggingFace. A valid HF_TOKEN by itself isn't enough:
# the user must also click "Agree and access repository" on both model
# pages. `docs/features/diarization.md` walks through that flow in its
# "License acceptance flow" section, so the deeplink targets that anchor
# directly. Distinct from HF_AUTH_FAILED (which is the more general
# token-missing-or-invalid case pointing at the token-setup doc).
"PYANNOTE_LICENSE_REQUIRED": f"{_BASE}/docs/features/diarization.md#license-acceptance-flow",
}
DEFAULT_DOCS: str = f"{_BASE}/docs/install/troubleshooting.md"
+60 -7
View File
@@ -472,18 +472,68 @@ def restore_tts_after_asr():
_diar_pipeline = None
def get_diarization_pipeline():
# Sentinel error classes used by callers (dub_core) to decide whether to
# emit a structured SSE warning with a docs deeplink. Kept as module-level
# constants so tests can pin them — they cross the SSE wire and the
# frontend's errorDocsMap classifies on the same strings.
DIARIZATION_ERR_NO_TOKEN = "NO_TOKEN"
DIARIZATION_ERR_LICENSE = "PYANNOTE_LICENSE_REQUIRED"
DIARIZATION_ERR_LOAD = "LOAD_FAILED"
def _classify_diarization_error(exc: BaseException) -> str:
"""Map a pyannote/HF-hub exception to one of the diarization error
sentinels above.
The 401/403 path is the canonical "user hasn't accepted the model
license on huggingface.co" symptom — both `Pipeline.from_pretrained`
and `huggingface_hub` raise distinct exception classes for it
depending on the installed versions, so we sniff on both the class
name and the stringified message rather than importing the
`HfHubHTTPError` symbol directly (which is not stable across
huggingface_hub majors).
"""
name = type(exc).__name__.lower()
msg = str(exc).lower()
if (
"401" in msg
or "403" in msg
or "unauthorized" in msg
or "gated" in msg
or "accept" in msg and ("license" in msg or "terms" in msg or "user conditions" in msg)
or "hfhubhttperror" in name
or "gatedrepoerror" in name
or "repositorynotfounderror" in name and "gated" in msg
):
return DIARIZATION_ERR_LICENSE
return DIARIZATION_ERR_LOAD
def get_diarization_pipeline(return_error: bool = False):
"""Load (or return the cached) pyannote speaker-diarization-3.1 pipeline.
Default return: the pipeline instance, or `None` if anything went
wrong (no token, license not accepted, model load crashed). Existing
callers (dub_core legacy `_transcribe`) rely on the `None` sentinel.
When `return_error=True`, returns a 2-tuple
`(pipeline | None, error_sentinel | None)` where `error_sentinel` is
one of the `DIARIZATION_ERR_*` constants. This shape is what the
streaming `_diarize` path uses to emit a structured SSE warning with
a docs deeplink — issue #78.
"""
global _diar_pipeline
if _diar_pipeline is not None:
return (_diar_pipeline, None) if return_error else _diar_pipeline
# Phase 1 AUTH-01: 3-source resolver (App → Env → HF-CLI). Per
# Pitfall #1 in 01-RESEARCH.md — exactly one place in the backend
# reads HF tokens, and that place is `token_resolver.resolve()`.
from services import token_resolver
resolved = token_resolver.resolve()
if not resolved:
return None
return (None, DIARIZATION_ERR_NO_TOKEN) if return_error else None
hf_token = resolved.token
if _diar_pipeline is not None:
return _diar_pipeline
try:
torch = _lazy_torch()
from pyannote.audio import Pipeline
@@ -494,7 +544,10 @@ def get_diarization_pipeline():
if device in ("cuda",):
_diar_pipeline.to(torch.device(device))
logger.info("Pyannote Diarization Pipeline loaded on %s.", device)
return _diar_pipeline
return (_diar_pipeline, None) if return_error else _diar_pipeline
except Exception as e:
logger.error(f"Failed to load Pyannote pipeline: {e}")
return None
err_class = _classify_diarization_error(e)
logger.error(
"Failed to load Pyannote pipeline (class=%s): %s", err_class, e,
)
return (None, err_class) if return_error else None
+17 -2
View File
@@ -35,8 +35,8 @@ describe('errorDocsMap', () => {
expect(openExternal).toHaveBeenCalledWith(DEFAULT_DOCS);
});
// Sentinel test — locks the 4-class taxonomy in lockstep with the
// Python map (backend/core/error_docs_map.py). Adding a 5th class is
// Sentinel test — locks the 5-class taxonomy in lockstep with the
// Python map (backend/core/error_docs_map.py). Adding a 6th class is
// a contract change; update both sides + this list.
it('keys match the locked taxonomy (mirror of Python map)', () => {
expect(Object.keys(ERROR_DOCS).sort()).toEqual([...ERROR_CLASS_KEYS].sort());
@@ -46,6 +46,7 @@ describe('errorDocsMap', () => {
'GATEKEEPER_QUARANTINE',
'HF_AUTH_FAILED',
'PKG_RESOURCES_MISSING',
'PYANNOTE_LICENSE_REQUIRED',
].sort(),
);
});
@@ -69,6 +70,20 @@ describe('errorDocsMap', () => {
expect(classifyError(new Error('Got 401 from HuggingFace'))).toBe('HF_AUTH_FAILED');
});
// Issue #78 — diarization-specific 401/gated repo lands on the
// diarization docs deeplink, not the generic token-setup one.
it('classifyError maps pyannote / diarization gated-model errors to PYANNOTE_LICENSE_REQUIRED', () => {
expect(
classifyError(new Error('pyannote/speaker-diarization-3.1 is gated; 401 Unauthorized')),
).toBe('PYANNOTE_LICENSE_REQUIRED');
expect(classifyError(new Error('Speaker diarization model failed to load'))).toBe(
'PYANNOTE_LICENSE_REQUIRED',
);
expect(
classifyError(new Error('You must accept the user conditions for this model')),
).toBe('PYANNOTE_LICENSE_REQUIRED');
});
it('classifyError maps WebKit / white screen to APPIMAGE_WEBKIT_WHITESCREEN', () => {
expect(classifyError(new Error('webkit compositing failed'))).toBe(
'APPIMAGE_WEBKIT_WHITESCREEN',
+18 -1
View File
@@ -20,18 +20,22 @@ export const ERROR_DOCS: Record<string, string> = {
APPIMAGE_WEBKIT_WHITESCREEN: `${BASE}/docs/install/linux.md#appimage-white-screen-on-fedora-44--ubuntu-2404`,
PKG_RESOURCES_MISSING: `${BASE}/docs/install/troubleshooting.md#pkg_resources-missing`,
HF_AUTH_FAILED: `${BASE}/docs/setup/huggingface-token.md`,
// Issue #78 — pyannote gated-model license not accepted on HF.
// Distinct from HF_AUTH_FAILED (which is a missing/invalid token).
PYANNOTE_LICENSE_REQUIRED: `${BASE}/docs/features/diarization.md#license-acceptance-flow`,
};
export const DEFAULT_DOCS = `${BASE}/docs/install/troubleshooting.md`;
// Locked taxonomy keys — Phase 5 bug reporter consumes this exact set.
// Adding a 5th class is a contract change; update the Python map at the
// Adding a 6th class is a contract change; update the Python map at the
// same time (`backend/core/error_docs_map.py`).
export const ERROR_CLASS_KEYS = [
'GATEKEEPER_QUARANTINE',
'APPIMAGE_WEBKIT_WHITESCREEN',
'PKG_RESOURCES_MISSING',
'HF_AUTH_FAILED',
'PYANNOTE_LICENSE_REQUIRED',
] as const;
export type ErrorClass = (typeof ERROR_CLASS_KEYS)[number];
@@ -45,6 +49,19 @@ export function classifyError(error: unknown): ErrorClass | null {
(error as { message?: string } | null | undefined)?.message ?? String(error ?? '');
const lower = message.toLowerCase();
if (/pkg_resources/.test(lower)) return 'PKG_RESOURCES_MISSING';
// Issue #78 — pyannote license + diarization are diagnosed separately
// from generic HF auth, since the fix instructions are different (click
// "Agree" on the model page vs. set/refresh the token). Check this BEFORE
// the HF_AUTH_FAILED branch so a message mentioning both "pyannote" and
// "401" routes to the more specific deeplink.
if (
/pyannote/.test(lower) ||
/\bgated\b/.test(lower) ||
/speaker[- ]?diariz/.test(lower) ||
/accept.*(license|terms|conditions)/.test(lower)
) {
return 'PYANNOTE_LICENSE_REQUIRED';
}
if (/\b401\b/.test(lower) || /hfhub|hfhubhttp/.test(lower) || /unauthorized/.test(lower)) {
return 'HF_AUTH_FAILED';
}
+22 -3
View File
@@ -1,6 +1,6 @@
"""Tests for backend/core/error_docs_map.py — error → docs URL taxonomy.
The 4-class taxonomy is the contract Phase 5's bug reporter consumes and
The 5-class taxonomy is the contract Phase 5's bug reporter consumes and
the TS-side `frontend/src/utils/errorDocsMap.ts` mirrors. These tests pin
both the keys and that every URL points back to the project repo.
"""
@@ -33,13 +33,32 @@ def test_all_urls_resolve_to_repo():
def test_all_keys_match_taxonomy():
"""The 4-class taxonomy is locked here. Adding a 5th class is a contract
change — bump this set + the TS mirror's keys-sync test in lockstep."""
"""The 5-class taxonomy is locked here. Adding a 6th class is a contract
change — bump this set + the TS mirror's keys-sync test in lockstep.
PYANNOTE_LICENSE_REQUIRED was added for issue #78 (speaker detection
fails when the pyannote model license has not been accepted on
huggingface.co — distinct from a missing/invalid token, which is what
HF_AUTH_FAILED covers).
"""
from core import error_docs_map
expected = {
"GATEKEEPER_QUARANTINE",
"APPIMAGE_WEBKIT_WHITESCREEN",
"PKG_RESOURCES_MISSING",
"HF_AUTH_FAILED",
"PYANNOTE_LICENSE_REQUIRED",
}
assert set(error_docs_map.ERROR_DOCS.keys()) == expected
def test_pyannote_license_class_points_at_diarization_docs():
"""Issue #78: the diarization warning toast deeplinks to the
`License acceptance flow` section of `docs/features/diarization.md`,
NOT to the generic token-setup doc — that section is the one with
the click-by-click instructions for accepting the gated-model
license on huggingface.co."""
from core import error_docs_map
url = error_docs_map.lookup("PYANNOTE_LICENSE_REQUIRED")
assert "docs/features/diarization.md" in url
assert "license-acceptance-flow" in url