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:
co-authored by
mergetest
Claude Opus 4.8
parent
0e17caa52a
commit
21b0b1f0b2
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user