Skip to content

feat(dub): optional speaker-count hint for diarization (#274) - #275

Merged
debpalash merged 1 commit into
mainfrom
fix/274-speaker-count-hint
Jun 3, 2026
Merged

feat(dub): optional speaker-count hint for diarization (#274)#275
debpalash merged 1 commit into
mainfrom
fix/274-speaker-count-hint

Conversation

@debpalash

@debpalash debpalash commented Jun 3, 2026

Copy link
Copy Markdown
Owner

Problem

Issue #274 — with multiple speakers in a video, OmniVoice detects only one ("Speaker 1"): the transcript merges everyone's lines and the dub mixes voices. Reporter is on v0.3.5 with pyannote diarization now loading (after #270).

I traced the full path. The consumption side is correct — assign_speakers_from_diarization overlap-weights each segment and emits distinct Speaker N ids (pinned by a new unit test). So the collapse comes from pyannote's auto-detect returning a single speaker for this audio — which is exactly what an explicit count hint fixes (and what the reporter asked for).

Change

Optional speaker-count hint, end to end:

  • Backend: /dub/transcribe-stream/{job_id}?num_speakers=N (clamped 1–20; None → auto-detect) threaded to diar_pipe(audio, num_speakers=N). Omitted entirely when unset, so we don't depend on the kwarg existing in every pyannote build.
  • Frontend: dubNumSpeakers store field + a compact "Speakers" number input (placeholder Auto) in the dub panel + i18n keys; transcribeStreamUrl appends the param; the SSE hook reads the hint at stream-open time.

Tests

  • tests/test_assign_speakers_from_diarization.py — multi-speaker split, overlap weighting, non-underscore label kept verbatim, empty-result safety.
  • dub.transcribeUrl.test.ts — param appended only for a positive int (floored), omitted for null/0/neg/NaN.
  • Full backend diarization suite + frontend vitest (196) pass; typecheck:ci ✅; build ✅; CJK i18n guard ✅.

Scope / honesty

Default behavior unchanged (blank = auto-detect, exactly as today). I can't run pyannote on the reporter's clip without a CUDA box, so this is the verifiable plumbing + the control they requested — #274 stays open until they confirm setting the count resolves it. If pyannote still under-segments with an explicit count, that's a model-level follow-up.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Users can now optionally specify the number of speakers when transcribing to improve speaker detection accuracy.
    • Added a numeric speaker count input field in the transcription interface (range 1–20).
    • Auto-detection remains available when no speaker count is specified.
  • Tests

    • Added comprehensive test coverage for speaker detection and URL parameter generation.

When a clip has multiple speakers, pyannote's auto-detect sometimes collapses
them into a single "Speaker 1" — so the transcript merges turns and the dub
mixes voices. The diarization-consumption side is correct (overlap-weighted,
distinct Speaker N ids — pinned by a new test), so the collapse comes from
auto-detect itself.

Add an optional speaker-count hint (the reporter's own suggestion):
- backend: `/dub/transcribe-stream/{job_id}?num_speakers=N` (clamped 1–20;
  None → auto-detect) threaded to `diar_pipe(audio, num_speakers=N)`. Omitted
  entirely when unset so we don't depend on the kwarg in every pyannote build.
- frontend: `dubNumSpeakers` store field + a compact "Speakers" number input
  in the dub panel (placeholder "Auto") + i18n; `transcribeStreamUrl` appends
  the param; the SSE hook reads the hint at stream-open time.

Tests: tests/test_assign_speakers_from_diarization.py (multi-speaker split,
overlap weighting, label robustness, empty-result safety) +
dub.transcribeUrl.test.ts (param appended only for a positive int). Full
backend diarization + frontend suites pass; CJK i18n guard passes.

Does NOT close #274 — pending the reporter confirming that setting the count
resolves the collapse on their video (can't verify pyannote behaviour without
a CUDA box + the clip).

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

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds optional speaker-count hinting for dub transcription diarization. The backend endpoint accepts a clamped num_speakers query parameter and forwards it to the diarization pipeline. The frontend provides a numeric input control in DubTab, stores the value in state, and passes it to the transcription stream URL. Comprehensive test coverage for diarization assignment logic is included.

Changes

Speaker count hint feature and diarization tests

Layer / File(s) Summary
Backend endpoint parameter and diarization integration
backend/api/routers/dub_core.py
The /dub/transcribe-stream/{job_id} endpoint now accepts an optional num_speakers query parameter, validates and clamps it to the range 1–20 (treating invalid values as None), and conditionally forwards the hint to the diarization pipeline when present.
Frontend URL generation for stream with speaker parameter
frontend/src/api/dub.ts, frontend/src/api/dub.transcribeUrl.test.ts
transcribeStreamUrl function now accepts an optional numSpeakers parameter and conditionally appends ?num_speakers=<floored> to the base stream URL when provided as a positive finite value; includes test coverage for omission, inclusion, and value flooring.
Frontend state management for speaker hint
frontend/src/store/dubSlice.ts
DubSlice store adds a transient dubNumSpeakers field (initialized to null) to hold the user's optional speaker-count hint, with corresponding setter setDubNumSpeakers implemented via the resolve helper and wired into the slice initialization and type definitions.
Frontend UI control and hook integration
frontend/src/pages/DubTab.jsx, frontend/src/pages/DubTab.css, frontend/src/i18n/locales/en.json, frontend/src/hooks/useDubWorkflow.js
DubTab renders a numeric input (1–20, step 1) bound to dubNumSpeakers, wired to the store setter, disabled during transcription phases, with icon import, i18n labels, and CSS styling for the control and hint text; useDubWorkflow reads the store value at stream-open time and passes it to transcribeStreamUrl.
Diarization assignment test suite and test doubles
tests/test_assign_speakers_from_diarization.py
Comprehensive test coverage for assign_speakers_from_diarization using a fake diarization double that mimics pyannote's itertracks behavior, validating segment-to-speaker overlap logic, midpoint fallbacks, label preservation, and empty-diarization edge cases.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

  • debpalash/OmniVoice-Studio#197: Both PRs modify diarization flow in dub_core.py—this one adds speaker-count forwarding to pyannote, while the other changes speaker assignment to optionally use FunASR cam++ speaker turns instead.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.75% 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 title clearly and concisely describes the main change: adding an optional speaker-count hint for diarization in the dub module.
Description check ✅ Passed The description is comprehensive and follows the template structure with Problem, Change, Tests, and Scope sections, addressing all key aspects of the PR.
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/274-speaker-count-hint

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.

@greptile-apps

greptile-apps Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an optional num_speakers hint that flows end-to-end from a new number input in the Dub panel through the transcribeStreamUrl helper and the /dub/transcribe-stream/{job_id} SSE endpoint to pyannote's apply() call, addressing cases where pyannote's auto-detect collapses a multi-speaker clip to a single speaker.

  • Backend: The route accepts Optional[int] num_speakers, clamps it to [1\u201320] (leaving None for auto-detect), and conditionally passes num_speakers=N to diar_pipe only when set.
  • Frontend: A compact \u201cSpeakers\u201d number input (placeholder Auto) is added to the Dub panel, its value stored in dubNumSpeakers (Zustand), and read via useAppStore.getState() at SSE open-time so all three upload/ingest/retry code paths pick it up without threading the value through call signatures.
  • Tests: New unit tests cover both the transcribeStreamUrl param-append logic and the assign_speakers_from_diarization overlap-weighting logic; default behavior (blank = auto-detect) is unchanged.

Confidence Score: 4/5

Safe to merge; the change is purely additive with no impact on existing default behaviour (blank = auto-detect).

The end-to-end plumbing is correct: the store, URL builder, SSE hook, and backend route all handle the null/positive-int split consistently. The two minor issues are a redundant try/except block in the route handler (FastAPI's own type coercion makes it dead code) and a truthiness check that happens to be safe only because of the clamping performed a few lines earlier. Neither affects runtime behaviour.

backend/api/routers/dub_core.py — the validation block and diarization call site.

Important Files Changed

Filename Overview
backend/api/routers/dub_core.py Adds num_speakers query param to the transcribe-stream endpoint; clamped to [1-20] with a redundant try/except (FastAPI already coerces Optional[int]), and the diarization call site uses a truthiness check instead of is not None.
frontend/src/hooks/useDubWorkflow.js Reads dubNumSpeakers from the Zustand store via getState() at SSE open-time inside _waitForTranscribe; correctly uses the imperative accessor so all three call sites pick up the user's current choice without dependency-array changes.
frontend/src/api/dub.ts Extends transcribeStreamUrl to accept an optional numSpeakers hint; appends the floored integer as a query param only when positive and finite.
frontend/src/store/dubSlice.ts Adds dubNumSpeakers: number
frontend/src/pages/DubTab.jsx Adds a compact number input (min 1, max 20, placeholder Auto) wired to dubNumSpeakers; disabled during upload/transcribe steps; onChange correctly nullifies out-of-range values.
frontend/src/api/dub.transcribeUrl.test.ts New unit tests cover omit-when-absent, omit-for-null/0/negative/NaN, append-for-positive-int, and floor-fractional behaviours.
tests/test_assign_speakers_from_diarization.py New backend test suite covering multi-speaker assignment, overlap weighting, midpoint fallback, non-underscore label passthrough, and empty-diarization safety.
frontend/src/i18n/locales/en.json Adds three i18n keys for the speaker-count input: label, placeholder, and tooltip help text.
frontend/src/pages/DubTab.css Adds two CSS classes for the speakers hint container and its number input; uses CSS custom properties for theming.

Sequence Diagram

sequenceDiagram
    participant User
    participant DubTab
    participant dubSlice
    participant useDubWorkflow
    participant dubAPI
    participant Backend
    participant pyannote

    User->>DubTab: Enter num_speakers (or leave blank)
    DubTab->>dubSlice: "setDubNumSpeakers(N | null)"
    User->>DubTab: Click Upload and Transcribe
    DubTab->>useDubWorkflow: handleDubUpload()
    useDubWorkflow->>dubSlice: getState().dubNumSpeakers
    dubSlice-->>useDubWorkflow: "N | null"
    useDubWorkflow->>dubAPI: transcribeStreamUrl(jobId, N)
    dubAPI-->>useDubWorkflow: "URL with ?num_speakers=N if N > 0"
    useDubWorkflow->>Backend: "EventSource /dub/transcribe-stream/job_id?num_speakers=N"
    Backend->>Backend: Clamp N to 1-20 or None
    alt num_speakers provided
        Backend->>pyannote: "diar_pipe(audio, num_speakers=N)"
    else auto-detect
        Backend->>pyannote: diar_pipe(audio)
    end
    pyannote-->>Backend: Annotation
    Backend->>Backend: assign_speakers_from_diarization(segments, diar)
    Backend-->>useDubWorkflow: SSE final event with diarized segments
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "feat(dub): optional speaker-count hint f..." | Re-trigger Greptile

Comment on lines +384 to +389
if num_speakers is not None:
try:
num_speakers = int(num_speakers)
num_speakers = num_speakers if 1 <= num_speakers <= 20 else None
except (TypeError, ValueError):
num_speakers = None

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.

P2 Dead validation block — FastAPI already coerces Optional[int]

Because the parameter is declared as Optional[int], FastAPI validates and converts the query string before the function body runs. If a caller passes ?num_speakers=abc, FastAPI returns a 422 Unprocessable Entity before this code is ever reached. The int() cast is therefore a no-op on an already-int value, and the (TypeError, ValueError) handler is unreachable. Only the range clamp (1 ≤ n ≤ 20) does real work here — the try/except wrapper around it is misleading dead code.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Comment on lines +692 to +696
if num_speakers:
logger.info("Diarizing with num_speakers=%d (user hint)", num_speakers)
diar = diar_pipe(asr_audio_target, num_speakers=num_speakers)
else:
diar = diar_pipe(asr_audio_target)

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.

P2 Using truthiness (if num_speakers:) instead of if num_speakers is not None: is correct only because the clamping block above guarantees the value is either in [1, 20] or None0 is never reachable here. However, is not None makes the intent explicit and is the conventional Python idiom for optional sentinel checks, making the code easier to audit at a glance.

Suggested change
if num_speakers:
logger.info("Diarizing with num_speakers=%d (user hint)", num_speakers)
diar = diar_pipe(asr_audio_target, num_speakers=num_speakers)
else:
diar = diar_pipe(asr_audio_target)
if num_speakers is not None:
logger.info("Diarizing with num_speakers=%d (user hint)", num_speakers)
diar = diar_pipe(asr_audio_target, num_speakers=num_speakers)
else:
diar = diar_pipe(asr_audio_target)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

@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.

🧹 Nitpick comments (2)
tests/test_assign_speakers_from_diarization.py (1)

34-81: 💤 Low value

Optional: Replace en-dashes with hyphens in comments.

Static analysis (RUF003) flags several en-dash characters (–) in comments where hyphens (-) are expected. This is a cosmetic linter compliance issue with no functional impact.

Examples flagged
  • Line 35: 0–5s0-5s
  • Line 48: 2–82-8, 2–52-5
  • Line 56: 4–54-5, 5–115-11
🤖 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 `@tests/test_assign_speakers_from_diarization.py` around lines 34 - 81, Replace
the cosmetic en-dash characters (–) used in comments with standard hyphens (-)
to satisfy linter RUF003; update the comment texts inside the test functions
(e.g., test_two_speakers_yield_two_distinct_ids, test_overlap_weighted_winner,
test_midpoint_fallback_when_no_overlap) so phrases like "0–5s", "2–8", "2–5",
"4–5", and "5–11" use hyphens ("0-5s", "2-8", "2-5", "4-5", "5-11") instead,
leaving all code and assertions unchanged.
backend/api/routers/dub_core.py (1)

692-696: 💤 Low value

Consider using explicit None check for clarity.

The truthiness check if num_speakers: is functionally correct (validation ensures it's either None or 1–20), but if num_speakers is not None: would make the intent more explicit and robust against future changes.

♻️ Proposed refactor
-            if num_speakers:
+            if num_speakers is not None:
                 logger.info("Diarizing with num_speakers=%d (user hint)", num_speakers)
                 diar = diar_pipe(asr_audio_target, num_speakers=num_speakers)
             else:
🤖 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 692 - 696, The truthy check on
num_speakers should be made explicit: replace the conditional `if num_speakers:`
with `if num_speakers is not None:` so the intent is clear and future
falsy-but-valid values won't break behavior; ensure you keep the existing
logger.info("Diarizing with num_speakers=%d (user hint)", num_speakers) and the
call to diar_pipe(asr_audio_target, num_speakers=num_speakers) in that branch,
and call diar_pipe(asr_audio_target) in the else branch.
🤖 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.

Nitpick comments:
In `@backend/api/routers/dub_core.py`:
- Around line 692-696: The truthy check on num_speakers should be made explicit:
replace the conditional `if num_speakers:` with `if num_speakers is not None:`
so the intent is clear and future falsy-but-valid values won't break behavior;
ensure you keep the existing logger.info("Diarizing with num_speakers=%d (user
hint)", num_speakers) and the call to diar_pipe(asr_audio_target,
num_speakers=num_speakers) in that branch, and call diar_pipe(asr_audio_target)
in the else branch.

In `@tests/test_assign_speakers_from_diarization.py`:
- Around line 34-81: Replace the cosmetic en-dash characters (–) used in
comments with standard hyphens (-) to satisfy linter RUF003; update the comment
texts inside the test functions (e.g., test_two_speakers_yield_two_distinct_ids,
test_overlap_weighted_winner, test_midpoint_fallback_when_no_overlap) so phrases
like "0–5s", "2–8", "2–5", "4–5", and "5–11" use hyphens ("0-5s", "2-8", "2-5",
"4-5", "5-11") instead, leaving all code and assertions unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 943d1993-f7a7-4d3c-9d2c-d6e4d61a5447

📥 Commits

Reviewing files that changed from the base of the PR and between 5fbc654 and a8b989d.

📒 Files selected for processing (9)
  • backend/api/routers/dub_core.py
  • frontend/src/api/dub.transcribeUrl.test.ts
  • frontend/src/api/dub.ts
  • frontend/src/hooks/useDubWorkflow.js
  • frontend/src/i18n/locales/en.json
  • frontend/src/pages/DubTab.css
  • frontend/src/pages/DubTab.jsx
  • frontend/src/store/dubSlice.ts
  • tests/test_assign_speakers_from_diarization.py

@debpalash
debpalash merged commit c427ffa into main Jun 3, 2026
15 checks passed
@debpalash
debpalash deleted the fix/274-speaker-count-hint branch June 3, 2026 08:40
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.

1 participant