diff --git a/backend/api/routers/dub_core.py b/backend/api/routers/dub_core.py index 52c3287e..879a9bae 100644 --- a/backend/api/routers/dub_core.py +++ b/backend/api/routers/dub_core.py @@ -366,14 +366,28 @@ _prep_event_helper = dub_pipeline.prep_event # alias; we keep the module-local @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. Pre-flight checks (missing job, missing audio, ASR not loaded) are emitted 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 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) preflight_error: Optional[str] = None @@ -671,7 +685,15 @@ async def dub_transcribe_stream(job_id: str): }, ) 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 except Exception as e: logger.error(f"Diarization failed: {e}") diff --git a/frontend/src/api/dub.transcribeUrl.test.ts b/frontend/src/api/dub.transcribeUrl.test.ts new file mode 100644 index 00000000..6966e72e --- /dev/null +++ b/frontend/src/api/dub.transcribeUrl.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/api/dub.ts b/frontend/src/api/dub.ts index da3fb059..77bf2b34 100644 --- a/frontend/src/api/dub.ts +++ b/frontend/src/api/dub.ts @@ -39,8 +39,14 @@ export async function dubIngestUrl( ); } -export function transcribeStreamUrl(jobId: string): string { - return `${API}/dub/transcribe-stream/${jobId}`; +export function transcribeStreamUrl(jobId: string, numSpeakers?: number | null): string { + 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 { diff --git a/frontend/src/hooks/useDubWorkflow.js b/frontend/src/hooks/useDubWorkflow.js index 05b521a5..be6f0e18 100644 --- a/frontend/src/hooks/useDubWorkflow.js +++ b/frontend/src/hooks/useDubWorkflow.js @@ -68,7 +68,11 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist // ── SSE: wait for transcription stream ── 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; const close = () => { try { evt.close(); } catch {} }; const onAbortSignal = () => { close(); reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); }; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 41c933e1..2279e3d8 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -450,6 +450,9 @@ "export_wav": "WAV", "export_srt": "SRT", "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", "prep_download": "Downloading video…", "prep_extract": "Extracting audio…", diff --git a/frontend/src/pages/DubTab.css b/frontend/src/pages/DubTab.css index 2dd729a9..c9291863 100644 --- a/frontend/src/pages/DubTab.css +++ b/frontend/src/pages/DubTab.css @@ -352,6 +352,8 @@ .dub-change-row { display: flex; gap: 8px; margin-top: 8px; align-items: center; } .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 */ .input-base--xs { font-size: 0.65rem; } diff --git a/frontend/src/pages/DubTab.jsx b/frontend/src/pages/DubTab.jsx index 6b26be63..30aefc88 100644 --- a/frontend/src/pages/DubTab.jsx +++ b/frontend/src/pages/DubTab.jsx @@ -2,7 +2,7 @@ import React, { Suspense, lazy, useState, useEffect, useCallback, useRef } from import { copyText } from "../utils/copyText"; import { useTranslation } from 'react-i18next'; import { - PanelLeftOpen, PanelLeftClose, Film, Save, UploadCloud, Sparkles, Loader, Square, + PanelLeftOpen, PanelLeftClose, Film, Save, UploadCloud, Sparkles, Loader, Square, Users, FileText, Play, DownloadIcon, Volume2, Link2, Languages, ChevronDown, ChevronUp, Wand2, Trash2, Check, Globe, UserSquare2, User, AlertCircle, ExternalLink, Copy, @@ -109,6 +109,8 @@ export default function DubTab(props) { const setDubLang = useAppStore(s => s.setDubLang); const dubLangCode = useAppStore(s => s.dubLangCode); 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 setDubInstruct = useAppStore(s => s.setDubInstruct); const dubTracks = useAppStore(s => s.dubTracks); @@ -392,6 +394,23 @@ export default function DubTab(props) { /> )} +