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

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>
This commit is contained in:
Palash Debnath
2026-06-03 14:10:08 +05:30
committed by GitHub
co-authored by Claude Opus 4.8
parent 5fbc654e82
commit c427ffa62d
9 changed files with 175 additions and 7 deletions
+24 -2
View File
@@ -366,14 +366,28 @@ _prep_event_helper = dub_pipeline.prep_event # alias; we keep the module-local
@router.get("/dub/transcribe-stream/{job_id}") @router.get("/dub/transcribe-stream/{job_id}")
async def dub_transcribe_stream(job_id: str): async def dub_transcribe_stream(job_id: str, num_speakers: Optional[int] = None):
"""Stream per-chunk segments via SSE, then emit diarized final pass. """Stream per-chunk segments via SSE, then emit diarized final pass.
Pre-flight checks (missing job, missing audio, ASR not loaded) are emitted Pre-flight checks (missing job, missing audio, ASR not loaded) are emitted
as in-stream `error` events rather than HTTP errors, because EventSource as in-stream `error` events rather than HTTP errors, because EventSource
on the client can't read non-2xx response bodies — a 503 there surfaces on the client can't read non-2xx response bodies — a 503 there surfaces
as an opaque "network error" instead of the actionable message we want. as an opaque "network error" instead of the actionable message we want.
`num_speakers` is an optional hint passed straight to pyannote. Left unset,
pyannote auto-detects the count — but its auto-detect can collapse a
multi-speaker clip to a single speaker (issue #274). When the user knows
the exact count, supplying it forces pyannote to return that many speakers.
""" """
# Clamp to a sane range; ignore anything non-positive / absurd so a bad
# query string can never break the diarization call. None → auto-detect.
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
job = _get_job(job_id) job = _get_job(job_id)
preflight_error: Optional[str] = None preflight_error: Optional[str] = None
@@ -671,7 +685,15 @@ async def dub_transcribe_stream(job_id: str):
}, },
) )
try: try:
diar = diar_pipe(asr_audio_target) # Pass the user's speaker-count hint through to pyannote when
# provided (#274). pyannote's apply() accepts num_speakers;
# omit it entirely when None so we don't depend on the kwarg
# existing in every pyannote build.
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)
return assign_speakers_from_diarization(all_segments, diar), None return assign_speakers_from_diarization(all_segments, diar), None
except Exception as e: except Exception as e:
logger.error(f"Diarization failed: {e}") logger.error(f"Diarization failed: {e}")
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { transcribeStreamUrl } from './dub';
// #274: the optional speaker-count hint is appended only when it's a positive
// integer; otherwise the backend auto-detects.
describe('transcribeStreamUrl', () => {
it('omits num_speakers when not provided', () => {
expect(transcribeStreamUrl('job1')).toMatch(/\/dub\/transcribe-stream\/job1$/);
});
it('omits num_speakers for null / 0 / negative / NaN', () => {
for (const v of [null, undefined, 0, -3, NaN] as (number | null | undefined)[]) {
expect(transcribeStreamUrl('j', v)).not.toContain('num_speakers');
}
});
it('appends a positive integer hint', () => {
expect(transcribeStreamUrl('j', 3)).toContain('num_speakers=3');
});
it('floors a fractional hint', () => {
expect(transcribeStreamUrl('j', 2.9)).toContain('num_speakers=2');
});
});
+8 -2
View File
@@ -39,8 +39,14 @@ export async function dubIngestUrl(
); );
} }
export function transcribeStreamUrl(jobId: string): string { export function transcribeStreamUrl(jobId: string, numSpeakers?: number | null): string {
return `${API}/dub/transcribe-stream/${jobId}`; const base = `${API}/dub/transcribe-stream/${jobId}`;
// Optional pyannote speaker-count hint (#274). Only appended when a positive
// integer; otherwise the backend auto-detects.
if (numSpeakers && Number.isFinite(numSpeakers) && numSpeakers > 0) {
return `${base}?num_speakers=${Math.floor(numSpeakers)}`;
}
return base;
} }
export async function dubAbort(jobId: string): Promise<void> { export async function dubAbort(jobId: string): Promise<void> {
+5 -1
View File
@@ -68,7 +68,11 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
// ── SSE: wait for transcription stream ── // ── SSE: wait for transcription stream ──
const _waitForTranscribe = useCallback((jobId, ctrl) => new Promise((resolve, reject) => { const _waitForTranscribe = useCallback((jobId, ctrl) => new Promise((resolve, reject) => {
const evt = new EventSource(transcribeStreamUrl(jobId)); // Read the optional speaker-count hint at stream-open time (#274) so the
// user's choice for this job is honoured without threading it through the
// three call sites. null → pyannote auto-detect.
const numSpeakers = useAppStore.getState().dubNumSpeakers;
const evt = new EventSource(transcribeStreamUrl(jobId, numSpeakers));
let gotFinal = false; let gotFinal = false;
const close = () => { try { evt.close(); } catch {} }; const close = () => { try { evt.close(); } catch {} };
const onAbortSignal = () => { close(); reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); }; const onAbortSignal = () => { close(); reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); };
+3
View File
@@ -450,6 +450,9 @@
"export_wav": "WAV", "export_wav": "WAV",
"export_srt": "SRT", "export_srt": "SRT",
"upload_transcribe": "Upload & Transcribe", "upload_transcribe": "Upload & Transcribe",
"num_speakers_label": "Speakers",
"num_speakers_auto": "Auto",
"num_speakers_help": "How many speakers are in this video? Leave blank to auto-detect. Set a number if auto-detect merges multiple speakers into one.",
"multi_lang": "Multi-lang", "multi_lang": "Multi-lang",
"prep_download": "Downloading video…", "prep_download": "Downloading video…",
"prep_extract": "Extracting audio…", "prep_extract": "Extracting audio…",
+2
View File
@@ -352,6 +352,8 @@
.dub-change-row { display: flex; gap: 8px; margin-top: 8px; align-items: center; } .dub-change-row { display: flex; gap: 8px; margin-top: 8px; align-items: center; }
.dub-change-row__cta { flex: 1; margin-top: 0; } .dub-change-row__cta { flex: 1; margin-top: 0; }
.dub-speakers-hint { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--muted, #a89984); white-space: nowrap; }
.dub-speakers-input { width: 52px; margin-left: 4px; padding: 4px 6px; border-radius: 6px; border: 1px solid var(--border, #3c3836); background: var(--input-bg, #282828); color: inherit; font-size: 12px; }
/* Compact disabled inputs in the idle skeleton */ /* Compact disabled inputs in the idle skeleton */
.input-base--xs { font-size: 0.65rem; } .input-base--xs { font-size: 0.65rem; }
+20 -1
View File
@@ -2,7 +2,7 @@ import React, { Suspense, lazy, useState, useEffect, useCallback, useRef } from
import { copyText } from "../utils/copyText"; import { copyText } from "../utils/copyText";
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
PanelLeftOpen, PanelLeftClose, Film, Save, UploadCloud, Sparkles, Loader, Square, PanelLeftOpen, PanelLeftClose, Film, Save, UploadCloud, Sparkles, Loader, Square, Users,
FileText, Play, DownloadIcon, Volume2, Link2, FileText, Play, DownloadIcon, Volume2, Link2,
Languages, ChevronDown, ChevronUp, Wand2, Trash2, Check, Globe, UserSquare2, User, AlertCircle, Languages, ChevronDown, ChevronUp, Wand2, Trash2, Check, Globe, UserSquare2, User, AlertCircle,
ExternalLink, Copy, ExternalLink, Copy,
@@ -109,6 +109,8 @@ export default function DubTab(props) {
const setDubLang = useAppStore(s => s.setDubLang); const setDubLang = useAppStore(s => s.setDubLang);
const dubLangCode = useAppStore(s => s.dubLangCode); const dubLangCode = useAppStore(s => s.dubLangCode);
const setDubLangCode = useAppStore(s => s.setDubLangCode); const setDubLangCode = useAppStore(s => s.setDubLangCode);
const dubNumSpeakers = useAppStore(s => s.dubNumSpeakers);
const setDubNumSpeakers = useAppStore(s => s.setDubNumSpeakers);
const dubInstruct = useAppStore(s => s.dubInstruct); const dubInstruct = useAppStore(s => s.dubInstruct);
const setDubInstruct = useAppStore(s => s.setDubInstruct); const setDubInstruct = useAppStore(s => s.setDubInstruct);
const dubTracks = useAppStore(s => s.dubTracks); const dubTracks = useAppStore(s => s.dubTracks);
@@ -392,6 +394,23 @@ export default function DubTab(props) {
/> />
</label> </label>
)} )}
<label className="dub-speakers-hint" title={t('dub.num_speakers_help')}>
<Users size={13} /> {t('dub.num_speakers_label')}
<input
type="number"
min={1}
max={20}
step={1}
className="dub-speakers-input"
placeholder={t('dub.num_speakers_auto')}
value={dubNumSpeakers ?? ''}
disabled={dubStep === 'uploading' || dubStep === 'transcribing'}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
setDubNumSpeakers(Number.isFinite(v) && v > 0 ? Math.min(v, 20) : null);
}}
/>
</label>
<button className="btn-primary dub-change-row__cta" <button className="btn-primary dub-change-row__cta"
onClick={handleDubUpload} onClick={handleDubUpload}
disabled={dubStep === 'uploading' || dubStep === 'transcribing'}> disabled={dubStep === 'uploading' || dubStep === 'transcribing'}>
+9 -1
View File
@@ -87,6 +87,11 @@ export interface DubSlice {
dubLang: string; dubLang: string;
dubLangCode: string; dubLangCode: string;
// Optional speaker-count hint passed to pyannote diarization (#274). null =
// let pyannote auto-detect; a positive int forces that many speakers when
// auto-detect collapses a multi-speaker clip to one.
dubNumSpeakers: number | null;
// ── Generation options ──────────────────────────────────────────────── // ── Generation options ────────────────────────────────────────────────
dubInstruct: string; dubInstruct: string;
preserveBg: boolean; preserveBg: boolean;
@@ -136,6 +141,7 @@ export interface DubSlice {
setDubTracks: (v: Updater<string[]>) => void; setDubTracks: (v: Updater<string[]>) => void;
setDubLang: (v: Updater<string>) => void; setDubLang: (v: Updater<string>) => void;
setDubLangCode: (v: Updater<string>) => void; setDubLangCode: (v: Updater<string>) => void;
setDubNumSpeakers: (v: Updater<number | null>) => void;
setDubInstruct: (v: Updater<string>) => void; setDubInstruct: (v: Updater<string>) => void;
setPreserveBg: (v: Updater<boolean>) => void; setPreserveBg: (v: Updater<boolean>) => void;
setDefaultTrack: (v: Updater<string>) => void; setDefaultTrack: (v: Updater<string>) => void;
@@ -152,7 +158,7 @@ const INITIAL: Omit<DubSlice,
| 'setDubPrepProgress' | 'setDubCurrentSegId' | 'setDubPrepProgress' | 'setDubCurrentSegId'
| 'setDubProgress' | 'setDubError' | 'setDubFailure' | 'setIsTranslating' | 'setDubSegments' | 'setDubProgress' | 'setDubError' | 'setDubFailure' | 'setIsTranslating' | 'setDubSegments'
| 'setDubTranscript' | 'setDubFilename' | 'setDubDuration' | 'setDubTracks' | 'setDubTranscript' | 'setDubFilename' | 'setDubDuration' | 'setDubTracks'
| 'setDubLang' | 'setDubLangCode' | 'setDubInstruct' | 'setPreserveBg' | 'setDubLang' | 'setDubLangCode' | 'setDubNumSpeakers' | 'setDubInstruct' | 'setPreserveBg'
| 'setDefaultTrack' | 'setExportTracks' | 'setPreviewSegIds' | 'setSpeakerClones' | 'setDefaultTrack' | 'setExportTracks' | 'setPreviewSegIds' | 'setSpeakerClones'
| 'setSegmentEffectPreset' | 'setAvailableEffectPresets' | 'resetDubState' | 'setSegmentEffectPreset' | 'setAvailableEffectPresets' | 'resetDubState'
> = { > = {
@@ -174,6 +180,7 @@ const INITIAL: Omit<DubSlice,
dubTracks: [], dubTracks: [],
dubLang: 'Auto', dubLang: 'Auto',
dubLangCode: 'en', dubLangCode: 'en',
dubNumSpeakers: null,
dubInstruct: '', dubInstruct: '',
preserveBg: true, preserveBg: true,
defaultTrack: 'original', defaultTrack: 'original',
@@ -205,6 +212,7 @@ export const createDubSlice: StateCreator<DubSlice, [], [], DubSlice> = (set, ge
setDubTracks: (v) => set((s) => ({ dubTracks: resolve(v, s.dubTracks) })), setDubTracks: (v) => set((s) => ({ dubTracks: resolve(v, s.dubTracks) })),
setDubLang: (v) => set((s) => ({ dubLang: resolve(v, s.dubLang) })), setDubLang: (v) => set((s) => ({ dubLang: resolve(v, s.dubLang) })),
setDubLangCode: (v) => set((s) => ({ dubLangCode: resolve(v, s.dubLangCode) })), setDubLangCode: (v) => set((s) => ({ dubLangCode: resolve(v, s.dubLangCode) })),
setDubNumSpeakers: (v) => set((s) => ({ dubNumSpeakers: resolve(v, s.dubNumSpeakers) })),
setDubInstruct: (v) => set((s) => ({ dubInstruct: resolve(v, s.dubInstruct) })), setDubInstruct: (v) => set((s) => ({ dubInstruct: resolve(v, s.dubInstruct) })),
setPreserveBg: (v) => set((s) => ({ preserveBg: resolve(v, s.preserveBg) })), setPreserveBg: (v) => set((s) => ({ preserveBg: resolve(v, s.preserveBg) })),
setDefaultTrack: (v) => set((s) => ({ defaultTrack: resolve(v, s.defaultTrack) })), setDefaultTrack: (v) => set((s) => ({ defaultTrack: resolve(v, s.defaultTrack) })),
@@ -0,0 +1,80 @@
"""`assign_speakers_from_diarization` overlap-weighting + label handling (#274).
When pyannote returns multiple speakers, each transcript segment must get the
speaker whose turns overlap it most — so a 2-speaker diarization yields two
distinct `Speaker N` ids, NOT a collapse to one. (The single-speaker collapse
users see comes from pyannote's *auto-detect*, which the new `num_speakers`
hint addresses; this test pins that the consumption side is correct.)
"""
from dataclasses import dataclass
from services.segmentation import assign_speakers_from_diarization
@dataclass
class _Turn:
start: float
end: float
class _FakeDiarization:
"""Mimics pyannote's `Annotation.itertracks(yield_label=True)`."""
def __init__(self, turns):
self._turns = turns # list of (Turn, track_name, speaker_label)
def itertracks(self, yield_label=False):
for turn, track, spk in self._turns:
yield (turn, track, spk) if yield_label else (turn, track)
def _segs(*spans):
return [{"start": a, "end": b, "speaker_id": "Speaker 1"} for a, b in spans]
def test_two_speakers_yield_two_distinct_ids():
# Speaker_0 owns 05s, Speaker_1 owns 510s.
diar = _FakeDiarization([
(_Turn(0.0, 5.0), "A", "SPEAKER_00"),
(_Turn(5.0, 10.0), "B", "SPEAKER_01"),
])
segs = _segs((0.5, 2.0), (6.0, 9.0))
out = assign_speakers_from_diarization(segs, diar)
assert out[0]["speaker_id"] == "Speaker 1" # SPEAKER_00 -> idx 1
assert out[1]["speaker_id"] == "Speaker 2" # SPEAKER_01 -> idx 2
assert out[0]["speaker_id"] != out[1]["speaker_id"]
def test_overlap_weighted_winner():
# Segment 28 overlaps SPEAKER_00 for 3s (25) and SPEAKER_01 for 3s...
# but SPEAKER_01 owns 59 so overlap is 3s each — tie broken by max(); make
# SPEAKER_01 clearly dominant by extending its turn.
diar = _FakeDiarization([
(_Turn(0.0, 5.0), "A", "SPEAKER_00"),
(_Turn(5.0, 12.0), "B", "SPEAKER_01"),
])
out = assign_speakers_from_diarization(_segs((4.0, 11.0)), diar)
# overlap: SPEAKER_00 = 1s (45), SPEAKER_01 = 6s (511) → winner SPEAKER_01
assert out[0]["speaker_id"] == "Speaker 2"
def test_midpoint_fallback_when_no_overlap():
# Segment sits fully inside a single turn; overlap path still catches it,
# but a zero-length segment exercises the midpoint fallback.
diar = _FakeDiarization([(_Turn(0.0, 10.0), "A", "SPEAKER_02")])
out = assign_speakers_from_diarization([{"start": 3.0, "end": 3.0, "speaker_id": "Speaker 1"}], diar)
assert out[0]["speaker_id"] == "Speaker 3"
def test_non_underscore_label_kept_verbatim():
# A label that isn't `<prefix>_<int>` must not crash — kept as-is.
diar = _FakeDiarization([(_Turn(0.0, 5.0), "A", "narrator")])
out = assign_speakers_from_diarization(_segs((1.0, 2.0)), diar)
assert out[0]["speaker_id"] == "narrator"
def test_empty_diarization_leaves_segment_untouched():
# No turns → no winner → segment keeps whatever it had (heuristic fallback
# upstream owns that case).
diar = _FakeDiarization([])
out = assign_speakers_from_diarization(_segs((1.0, 2.0)), diar)
assert out[0]["speaker_id"] == "Speaker 1"