fix(dub): auto-assign per-speaker cloned voices to segments (#486) (#576)

Multi-speaker dubbing diarizes the speakers and clones each one from the video
(the Voice dropdown shows "From Video → Speaker 1 / Speaker 2"), but every
segment was left on "Default" — the user had to set the voice on each row by
hand. The clone→segment binding simply never happened: the transcribe `final`
handler stored `speaker_clones` but set the segments without filling their
`profile_id`.

Bind them up front: new `applySpeakerCloneDefaults(segments, speakerClones)`
sets each segment's `profile_id` to its speaker's `auto:<safe>` clone id when a
clone exists and the user hasn't already chosen a voice. The id is computed by
`autoProfileId()`, which mirrors the backend clone-resolution key
(`speaker_id.lower().replace(" ","_")`) and the DubTab dropdown option value, so
all three agree. Only an *empty* profile_id is filled — an explicit per-speaker
or per-segment choice is never clobbered.

Pure helper + unit test (assign-when-cloned, never-clobber, no-clone-stays-
Default, no-op-without-clones).

Note: the issue's second symptom — different speakers' turns merged onto one
line — is a separate diarization/segment-grouping concern (speaker-turn
re-split) tracked as a follow-up; this fixes the per-speaker voice assignment.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-20 21:16:18 +05:30
committed by GitHub
co-authored by mergetest Claude Opus 4.8
parent 0e17caa52a
commit 21b0b1f0b2
3 changed files with 65 additions and 3 deletions
+6 -3
View File
@@ -6,7 +6,7 @@ import {
transcribeStreamUrl, dubImportSrt,
} from '../api/dub';
import { dialectMatchesLang } from '../api/dialects';
import { segmentGenInputs } from '../utils/segments';
import { segmentGenInputs, applySpeakerCloneDefaults } from '../utils/segments';
import { apiPost } from '../api/client';
import { API } from '../api/client';
import { playPing, isTauri } from '../utils/media';
@@ -100,11 +100,14 @@ export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHist
try {
const m = JSON.parse(e.data);
gotFinal = true;
setDubSegments((m.segments || []).map((s, i) => ({
const normalized = (m.segments || []).map((s, i) => ({
...s,
id: s.id != null ? String(s.id) : String(i),
text_original: s.text_original || s.text || '',
})));
}));
// #486: bind each segment to its detected speaker's clone up front, so
// a 2-speaker dub doesn't land every row on "Default".
setDubSegments(applySpeakerCloneDefaults(normalized, m.speaker_clones));
setDubTranscript(m.full_transcript || '');
if (m.speaker_clones && typeof m.speaker_clones === 'object') {
setSpeakerClones(m.speaker_clones);
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { applySpeakerCloneDefaults, autoProfileId } from '../utils/segments';
// #486: multi-speaker dub must auto-bind each segment to its detected
// speaker's cloned voice instead of leaving every row on "Default".
describe('applySpeakerCloneDefaults (#486)', () => {
const clones = { 'Speaker 1': { duration: 4.2 }, 'Speaker 2': { duration: 3.1 } };
it('assigns the auto: clone id to segments whose speaker was cloned', () => {
const segs = [
{ id: '0', speaker_id: 'Speaker 1', profile_id: '' },
{ id: '1', speaker_id: 'Speaker 2', profile_id: '' },
];
const out = applySpeakerCloneDefaults(segs, clones);
expect(out[0].profile_id).toBe('auto:speaker_1');
expect(out[1].profile_id).toBe('auto:speaker_2');
expect(autoProfileId('Speaker 2')).toBe('auto:speaker_2');
});
it('never clobbers a profile_id the user already chose', () => {
const segs = [{ id: '0', speaker_id: 'Speaker 1', profile_id: 'preset:narrator' }];
expect(applySpeakerCloneDefaults(segs, clones)[0].profile_id).toBe('preset:narrator');
});
it('leaves a segment on Default when its speaker has no clone', () => {
const segs = [{ id: '0', speaker_id: 'Speaker 9', profile_id: '' }];
expect(applySpeakerCloneDefaults(segs, clones)[0].profile_id).toBe('');
});
it('is a no-op when there are no clones', () => {
const segs = [{ id: '0', speaker_id: 'Speaker 1', profile_id: '' }];
expect(applySpeakerCloneDefaults(segs, {})[0].profile_id).toBe('');
expect(applySpeakerCloneDefaults(segs, null)).toEqual(segs);
});
});
+24
View File
@@ -34,3 +34,27 @@ export function segmentGenInputs(s) {
effect_preset: s.effect_preset || undefined,
};
}
/** The `auto:<safe>` profile id for a diarized speaker. Mirrors the backend's
* clone-resolution key (`speaker_id.lower().replace(" ", "_")`) and the
* Voice-dropdown option value in DubTab, so the three always agree. */
export function autoProfileId(speakerId) {
return `auto:${(speakerId || '').toLowerCase().replace(/\s+/g, '_')}`;
}
/**
* #486: auto-assign each diarized segment to its detected speaker's cloned
* voice instead of leaving it on "Default". When the backend cloned a speaker
* from the video (`speakerClones[speaker_id]` present) and the segment has no
* voice chosen yet, default its `profile_id` to that speaker's `auto:` clone.
* The user can still override per-speaker or per-segment afterwards — we only
* fill an *empty* profile_id, never clobber an explicit choice.
*/
export function applySpeakerCloneDefaults(segments, speakerClones) {
const clones = (speakerClones && typeof speakerClones === 'object') ? speakerClones : {};
if (!Array.isArray(segments) || !Object.keys(clones).length) return segments || [];
return segments.map((s) => {
if (!s || s.profile_id || !s.speaker_id || !clones[s.speaker_id]) return s;
return { ...s, profile_id: autoProfileId(s.speaker_id) };
});
}