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:
co-authored by
Claude Opus 4.8
parent
5fbc654e82
commit
c427ffa62d
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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' })); };
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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'}>
|
||||||
|
|||||||
@@ -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 0–5s, Speaker_1 owns 5–10s.
|
||||||
|
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 2–8 overlaps SPEAKER_00 for 3s (2–5) and SPEAKER_01 for 3s...
|
||||||
|
# but SPEAKER_01 owns 5–9 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 (4–5), SPEAKER_01 = 6s (5–11) → 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"
|
||||||
Reference in New Issue
Block a user