Skip to content

fix(dub): auto-assign per-speaker voices in multi-speaker dubbing (#486) - #490

Merged
debpalash merged 1 commit into
mainfrom
fix/486-multispeaker-voice-assign
Jun 16, 2026
Merged

fix(dub): auto-assign per-speaker voices in multi-speaker dubbing (#486)#490
debpalash merged 1 commit into
mainfrom
fix/486-multispeaker-voice-assign

Conversation

@debpalash

@debpalash debpalash commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Problem

Dubbing a multi-speaker video: diarization detects both speakers and per-speaker clones get created (the Voice dropdown shows From Video → Speaker 1 / Speaker 2), but most segments are left on Default voice instead of being auto-bound to their detected speaker's clone. A row correctly labelled Speaker 1 reads Voice = Default. Inconsistent across runs — short lines sometimes show the cloned voice, long lines don't. (Sub-bug 1 of #486.)

Root cause

After diarization, backend/api/routers/dub_core.py defaults each segment's profile_id. With per-segment refs on (the default), every line ≥3s was stamped auto-seg:{id}. The dub editor's Voice <select> (and the Cast panel) in DubSegmentRow.jsx / DubTab.jsx only render auto:{speaker} options ("From Video → Speaker N") — an auto-seg: value matches no <option>, so the row silently falls back to displaying "Default". Short lines (<3s) had no per-segment ref and fell through to auto:{speaker}, which does render — exactly the "sometimes it's picked" inconsistency. The frontend has zero handling of the auto-seg: prefix.

Fix

  • dub_core.py: bind every segment to the UI-visible auto:{speaker} whenever its detected speaker has a clone. Only fall back to auto-seg:{id} when the speaker has no per-speaker clone at all (rare: too little usable audio overall, but a single long line still has its own ref).
  • dub_generate.py: the per-segment-ref quality win is preserved transparently — the auto: resolution branch now prefers THIS segment's own per-segment ref (segment_clones[seg_id]) when present, else the per-speaker clone. So a row shown as "Speaker 1" still clones from its own line's source audio when long enough.

Backward-compatible: manual overrides and the no-clone path are untouched; existing jobs that already persisted auto-seg: ids still resolve via the retained auto-seg: branch. No schema change, no version bump, no user-facing strings (no i18n/docs impact).

Sub-bug 2 (speaker turns merged onto one line) — investigated, not fixed here

Root cause is clear but the fix is not low-risk, so it's documented rather than shipped: segmentation runs before diarization (segment_transcript_build_segments_from_words groups words by sentence/duration only), and assign_speakers_from_diarization only relabels existing segments by majority overlap — it never re-splits a segment that spans two speakers. When the merge/stitch passes run, every segment still carries the placeholder speaker_id="Speaker 1", so the "never merge across a speaker boundary" rule is a no-op. A correct fix needs diarization turns fed back as split boundaries (or a speaker-aware re-split pass), which touches the broadcast-grade segmentation rules and risks regressing single-speaker timing. Left for a dedicated change.

Tests

tests/test_dub_multispeaker_voice_486.py (top-level, asyncio.run):

  • assignment binds to auto:{speaker} (not auto-seg:) when a speaker clone exists
  • never clobbers a manual override
  • falls back to auto-seg: only when the speaker has no clone; leaves Default when no clones exist
  • generate-time resolution prefers the per-segment ref, then the per-speaker clone

Green alongside test_segment_refs, test_smart_fit_generate, test_redub_incremental, test_assign_speakers_*, test_segmentation, test_dub_transcribe, test_dub_timing_strategy.

🤖 Generated with Claude Code

Summary

Fixes automatic voice assignment in multi-speaker dubbing workflows where segments were inconsistently bound to the "Default" voice despite having detected speaker clones available.

The Problem

When diarization detects multiple speakers and creates per-speaker voice clones:

  • Segments longer than 3 seconds received auto-seg:{segment_id} as their profile_id (per-segment reference)
  • The dub editor's Voice dropdown only renders speaker-level options: auto:{speaker} ("From Video → Speaker N")
  • Since auto-seg: doesn't match any rendered <option>, the UI silently fell back to "Default"
  • Shorter segments correctly used auto:{speaker}, which renders properly
  • Result: unpredictable "sometimes picked" behavior—long lines showed "Default" while short lines showed the cloned voice

The Fix

Two coordinated changes restore correct behavior while preserving per-segment voice quality:

1. Segment Binding (dub_core.py)

After diarization completes, the assignment loop now:

  • For each segment without a manual profile_id override:
    • If the segment's detected speaker has a per-speaker clone → bind to auto:{speaker} (UI-visible)
    • Else if the segment has a per-segment reference → bind to auto-seg:{segment_id} (fallback)
    • Else → leave unset

This ensures every segment carries a renderable auto:{speaker} binding when its speaker has a clone.

2. Generate-Time Resolution (dub_generate.py)

When an auto:{speaker} binding is encountered during voice synthesis:

  • First: check job["segment_clones"][segment_id] for a per-segment reference
    • If present, use it (this segment's own audio → better prosody match)
  • Second: fall back to the per-speaker clone in job["speaker_clones"]
    • If present, use it

This preserves the Wave 3.2 per-segment-reference quality advantage while keeping the UI-visible binding stable.

Resolution Flow

┌─────────────────────────────────────────────────────────────────┐
│ BINDING (dub_core.py after diarization)                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  For each segment s:                                           │
│  ┌───────────────────────────────────────────────────────────┐ │
│  │ Has manual profile_id? ──Yes──> Skip (preserve override)  │ │
│  └─────────────────────────────────────────────────────────┘ │
│        │ No                                                    │
│        ↓                                                       │
│  ┌───────────────────────────────────────────────────────────┐ │
│  │ Speaker in speaker_clones? ──Yes──> Bind to auto:{spk}   │ │
│  │                                                            │ │
│  │            (UI-visible, renders in Voice dropdown)        │ │
│  └─────────────────────────────────────────────────────────┘ │
│        │ No                                                    │
│        ↓                                                       │
│  ┌───────────────────────────────────────────────────────────┐ │
│  │ Segment in segment_clones? ──Yes──> Bind to auto-seg:{id}│ │
│  │                                                            │ │
│  │     (Fallback: editor can't render, but generation works) │ │
│  └─────────────────────────────────────────────────────────┘ │
│        │ No                                                    │
│        ↓                                                       │
│  ┌───────────────────────────────────────────────────────────┐ │
│  │ Leave unset (no clone available)                           │ │
│  └─────────────────────────────────────────────────────────┘ │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
                                ↓
┌─────────────────────────────────────────────────────────────────┐
│ RESOLUTION (dub_generate.py during synthesis)                   │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  For profile_id = "auto:{speaker}":                            │
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐ │
│  │ Check segment_clones[segment_id]? ──Present──> Use it    │ │
│  │                                                            │ │
│  │      (This segment's own audio: best prosody match)      │ │
│  └─────────────────────────────────────────────────────────┘ │
│        │ Not present                                          │
│        ↓                                                       │
│  ┌───────────────────────────────────────────────────────────┐ │
│  │ Check speaker_clones[speaker]? ──Present──> Use it       │ │
│  │                                                            │ │
│  │             (Per-speaker clone: fallback)                │ │
│  └─────────────────────────────────────────────────────────┘ │
│        │ Not present                                          │
│        ↓                                                       │
│  ┌───────────────────────────────────────────────────────────┐ │
│  │ Generate without voice reference (no clone available)     │ │
│  └─────────────────────────────────────────────────────────┘ │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Backward Compatibility

  • Manual voice overrides are never touched
  • Existing jobs with persisted auto-seg: IDs continue to resolve correctly in generation
  • No-clone paths and fallback logic remain unchanged
  • All existing multi-speaker and single-speaker workflows unaffected

Testing

New test suite (tests/test_dub_multispeaker_voice_486.py) validates:

  • Long segments with per-segment refs bind to auto:{speaker} (not auto-seg:)
  • Manual profile_id overrides are preserved
  • Speakers without per-speaker clones fall back to per-segment refs
  • No clones → profile_id remains unset (default behavior)
  • Generate-time resolution prefers per-segment ref, then per-speaker clone
  • Integration tests through the real dub_generate path with patched model

Multi-speaker dubs detected speakers and built per-speaker clones (Voice
dropdown showed "From Video → Speaker N"), but most segments stayed on
"Default" voice and had to be set by hand — inconsistently across runs.

Root cause: after diarization, dub_core stamped each long line (the
default-on per-segment-ref path) with `auto-seg:{id}` as its profile_id.
The dub editor's Voice <select> (and the Cast panel) only render `auto:`
options, so an `auto-seg:` value matched no <option> and silently showed
"Default". Short lines (<3s) fell through to `auto:{speaker}`, which DID
render — hence "sometimes the cloned voice is picked".

Fix: bind every segment to the UI-visible `auto:{speaker}` whenever its
detected speaker has a clone; only fall back to `auto-seg:{id}` when the
speaker has no per-speaker clone at all. The per-segment-ref quality win
is preserved: dub_generate's `auto:` branch now transparently prefers
THIS segment's own per-segment ref (segment_clones[seg_id]) when present,
else the per-speaker clone. Manual overrides and the no-clone path are
untouched; existing jobs that persisted `auto-seg:` ids still resolve.

Tests: tests/test_dub_multispeaker_voice_486.py — assignment binds to
auto:{speaker} (not auto-seg:), never clobbers manual overrides, falls
back to auto-seg: only when the speaker has no clone; generate-time
resolution prefers per-segment ref then per-speaker clone. Green
alongside the existing dub generate/incremental/segmentation suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Fixes multi-speaker auto-clone profile_id assignment in two places: dub_core.py now assigns auto:{speaker} first when a speaker clone exists (falling back to auto-seg:{id} only when not), and dub_generate.py now prefers per-segment segment_clones refs before speaker-level clone lookup. A new hermetic test file adds unit and integration coverage for all resolution paths.

Changes

Multi-speaker auto-voice profile resolution fix

Layer / File(s) Summary
Post-diarization profile_id assignment
backend/api/routers/dub_core.py
Rewrites the final_segs defaulting block (lines 802–830): speaker clone presence gates auto:{speaker} binding; auto-seg:{id} is now a true fallback only when the speaker has no clone. Removes the previous unconditional per-segment-ref preference.
Generate-time auto: ref resolution
backend/api/routers/dub_generate.py
Inside _gen, the auto: branch now checks job["segment_clones"][str(seg_id)] first for ref_audio/ref_text, then falls back to the job["speaker_clones"] safe-name/id lookup. The old code had no per-segment ref path here.
Unit and integration regression tests
tests/test_dub_multispeaker_voice_486.py
Adds _assign_default_profiles (local mirror of dub_core logic), four unit tests (priority, no-clobber, fallback, no-clone), _RefCapturingModel, patched_generate fixture, and two end-to-end assertions through the real dub_generate path.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

  • debpalash/OmniVoice-Studio#369 — Introduced the profile_id clone-resolution logic in dub_core.py and dub_generate.py that this PR directly modifies to fix priority ordering.

Panel review — specific line-level concerns:

ML inference (dub_generate.py, lines 218–242)

The new per-segment lookup does job["segment_clones"][str(seg_id)] with a bare dictionary access. If segment_clones key is absent from job (e.g., older job schema or a job created before this migration), this raises KeyError before reaching the speaker-clone fallback. The fix: job.get("segment_clones", {}).get(str(seg_id)).

Audio DSP (dub_core.py, lines 802–830)

auto_profile_id(speaker_id) is called unconditionally inside the loop whenever clones is non-empty, even for segments whose speaker has no entry in clones. Confirm the guard is if speaker_id in clones before calling auto_profile_id; if the function itself is doing that check, the fallback-else branch still runs correctly, but silent no-ops on mismatched speaker ids should be tested explicitly — no test in the new suite covers a segment whose speaker is absent from clones but is also absent from seg_clones.

Desktop systems / product polish (tests/test_dub_multispeaker_voice_486.py, lines 108–185)

patched_generate monkeypatches the task manager and filesystem at the module level but does not reset _RefCapturingModel state between parametrized invocations if the fixture is reused across tests that call dub_generate more than once. Confirm the fixture yields a fresh instance per test (it appears to — fixture scope looks function-level — but this should be stated explicitly in the docstring so future parametrize additions don't silently share state).

🚥 Pre-merge checks | ✅ 9
✅ Passed checks (9 passed)
Check name Status Explanation
Title check ✅ Passed Title follows conventional-commit style with scope and references issue #486; clearly summarizes the main change.
Description check ✅ Passed Description comprehensively covers problem statement, root cause, fix strategy, and test coverage; matches template structure with clear sections.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cross-Platform Default Parity ✅ Passed PR changes default voice assignment logic but uses only platform-agnostic dict operations and string matching with no OS-specific code paths, imports, or conditionals.
I18n Completeness (21 Locales) ✅ Passed PR contains zero frontend modifications and no new t('...') i18n keys. All changes are backend-only (dub_core.py, dub_generate.py, test suite). No hardcoded user-facing strings in frontend code.
Local-First Guarantee ✅ Passed PR adds no required cloud calls, API keys, telemetry, or external credentials. Changes are local dict manipulation in dub_core.py (profile assignment) and dub_generate.py (ref resolution), with her...
Backward Compatibility ✅ Passed No DB schema changes, alembic migrations, or model state modifications. Old auto-seg: profile_ids explicitly supported in dub_generate.py. Manual overrides preserved by skipping segments with exist...

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/486-multispeaker-voice-assign

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes the multi-speaker dubbing bug where segments were stamped with auto-seg:{id} — a profile ID the dub editor's Voice <select> cannot render — causing every long line to silently display "Default" instead of the detected speaker's clone. The fix reorders the assignment loop in dub_core.py to prefer the UI-visible auto:{speaker} ID whenever a per-speaker clone exists, and upgrades the auto: resolution branch in dub_generate.py to still prefer the per-segment reference audio (for prosody fidelity) before falling back to the per-speaker clone.

  • dub_core.py: assignment loop now binds segments to auto:{speaker} first; auto-seg:{id} is only emitted when the speaker has no per-speaker clone at all. Manual overrides are never touched.
  • dub_generate.py: the auto: resolution branch gains a segment-level ref lookup (segment_clones[seg_id]) before the existing speaker-clone fallback, preserving the Wave 3.2 per-segment quality win while keeping all segment rows renderable in the editor.
  • tests/test_dub_multispeaker_voice_486.py: hermetic tests covering assignment binding, manual-override preservation, no-clone fallback, and generate-time ref resolution (stub TTS model, no disk I/O).

Confidence Score: 4/5

Safe to merge. The reordering is a straightforward priority swap in a pure dict-mutation loop; manual overrides and the no-clone path are untouched, and the retained auto-seg: branch ensures existing persisted jobs continue to resolve correctly.

The core logic change is well-scoped and correct: the assignment loop in dub_core.py produces a renderable ID in every case that previously caused the silent "Default" display, and the generate-time lookup in dub_generate.py correctly layers segment-level ref preference on top of the speaker-level fallback. The only issues found are a misleading closure comment inside _gen and the test helper that duplicates rather than calls the production assignment loop.

The test helper in test_dub_multispeaker_voice_486.py (Part 1) mirrors the dub_core loop manually — if that loop is refactored, the four assignment-binding tests will pass on stale code. The generate-path tests (Part 2) call real production code and provide the stronger safety net.

Important Files Changed

Filename Overview
backend/api/routers/dub_core.py Assignment loop reordered to prefer auto:{speaker} over auto-seg:{id} when a per-speaker clone exists; per-segment fallback preserved for the no-clone edge case. Logic is correct and backward-compatible.
backend/api/routers/dub_generate.py New auto: branch transparently prefers segment_clones[seg_id] before falling back to the per-speaker clone; closure over seg_id is safe due to immediate await. One misleading comment (loop direction) flagged.
tests/test_dub_multispeaker_voice_486.py Good hermetic coverage of both the assignment and generate-time resolution paths; Part 1 helpers duplicate production code (acknowledged risk), Part 2 exercises real dub_generate path with a stub model.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[diarization complete\nspeaker_clones + seg_clones] --> B{segment has\nmanual profile_id?}
    B -- yes --> Z[keep as-is]
    B -- no --> C{spk in\nspeaker_clones?}
    C -- yes --> D["assign auto:{speaker}\n← NEW default"]
    C -- no --> E{seg_id in\nseg_clones?}
    E -- yes --> F["assign auto-seg:{id}\n(renders as Default)"]
    E -- no --> G[no profile assigned]
    D --> H[dub_generate auto: branch]
    H --> I{segment_clones\nhas this seg_id?}
    I -- yes --> J[use per-segment ref\nhigh-prosody clone]
    I -- no --> K[use per-speaker ref\nfallback clone]
    style D fill:#22c55e,color:#fff
    style H fill:#3b82f6,color:#fff
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[diarization complete\nspeaker_clones + seg_clones] --> B{segment has\nmanual profile_id?}
    B -- yes --> Z[keep as-is]
    B -- no --> C{spk in\nspeaker_clones?}
    C -- yes --> D["assign auto:{speaker}\n← NEW default"]
    C -- no --> E{seg_id in\nseg_clones?}
    E -- yes --> F["assign auto-seg:{id}\n(renders as Default)"]
    E -- no --> G[no profile assigned]
    D --> H[dub_generate auto: branch]
    H --> I{segment_clones\nhas this seg_id?}
    I -- yes --> J[use per-segment ref\nhigh-prosody clone]
    I -- no --> K[use per-speaker ref\nfallback clone]
    style D fill:#22c55e,color:#fff
    style H fill:#3b82f6,color:#fff
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(dub): auto-assign per-speaker voices..." | Re-trigger Greptile

Comment on lines +56 to +68
continue
sid = str(s.get("id", ""))
if sid and sid in seg_clones:
s["profile_id"] = f"auto-seg:{sid}"
return final_segs


def test_segments_bind_to_speaker_clone_not_auto_seg():
"""Long lines with a per-segment ref still get the UI-visible auto:{spk}."""
clones = {
"Speaker 1": {"ref_audio": "/v/spk1.wav", "ref_text": "one"},
"Speaker 2": {"ref_audio": "/v/spk2.wav", "ref_text": "two"},
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test helper mirrors production code rather than calling it

_assign_default_profiles is a verbatim copy of the loop in dub_core.py, not a call to it. As the comment acknowledges, if the production loop is refactored (e.g., the continue ordering changes or a third profile prefix is added), these four unit tests will keep passing on stale logic while the real path regresses. The generate-time tests in Part 2 do hit real code, so the user-visible path is guarded, but the dub_core assignment tests give false confidence. Consider extracting the assignment loop into a standalone helper that both dub_core.py and the test can import directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
backend/api/routers/dub_generate.py (1)

227-227: 💤 Low value

[DSP/Product] Closure captures loop variable seg_id by reference—works today, breaks on refactor.

Line 227 references seg_id from the outer loop (line 140). The await on line 404 currently guarantees _gen completes before the next iteration, so this is not a runtime bug. However, if a future change removes the await (e.g., to parallelize segment generation), the closure will silently see the wrong segment id.

Bind the variable at definition time to make the code future-proof:

♻️ Defensive fix for closure binding
-        def _gen(text, lang, instruct_str, dur_s, nstep, cfg, spd, profile_id, effect_preset):
+        def _gen(text, lang, instruct_str, dur_s, nstep, cfg, spd, profile_id, effect_preset, *, _seg_id=seg_id):
             ref_audio = None
             ref_text = None
             used_seed = None
@@ -224,7 +224,7 @@
                 # `auto:` id the dub editor's Voice dropdown can actually
                 # render ("From Video → Speaker N"). `seg_id` is closed over
                 # from the per-segment loop below.
-                seg_ref = (job.get("segment_clones") or {}).get(str(seg_id))
+                seg_ref = (job.get("segment_clones") or {}).get(str(_seg_id))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/routers/dub_generate.py` at line 227, The closure on line 227
that references `seg_id` from the outer loop (line 140) captures the variable by
reference. While the await on line 404 currently prevents issues, this will
break silently if the code is refactored to parallelize segment generation. Bind
`seg_id` at definition time by using a default argument pattern (e.g., in a
lambda or factory function) rather than capturing it from the enclosing scope,
so that each closure iteration has its own independent copy of the segment id
value.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@backend/api/routers/dub_generate.py`:
- Line 227: The closure on line 227 that references `seg_id` from the outer loop
(line 140) captures the variable by reference. While the await on line 404
currently prevents issues, this will break silently if the code is refactored to
parallelize segment generation. Bind `seg_id` at definition time by using a
default argument pattern (e.g., in a lambda or factory function) rather than
capturing it from the enclosing scope, so that each closure iteration has its
own independent copy of the segment id value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c48d0e3a-b05b-4bde-857e-4e032f914ef5

📥 Commits

Reviewing files that changed from the base of the PR and between 4bcbc74 and 13a3a64.

📒 Files selected for processing (3)
  • backend/api/routers/dub_core.py
  • backend/api/routers/dub_generate.py
  • tests/test_dub_multispeaker_voice_486.py

@debpalash
debpalash merged commit 1650a12 into main Jun 16, 2026
15 checks passed
@debpalash
debpalash deleted the fix/486-multispeaker-voice-assign branch June 16, 2026 10:09
debpalash added a commit that referenced this pull request Jun 22, 2026
…) (#616)

Segmentation groups words into sentences BEFORE diarization, so a two-speaker
exchange can land in one segment; assign_speakers_* then only relabels it with
the majority speaker, losing the turn boundary (the second half of #486 — the
per-speaker voice auto-assign was fixed in #490).

Add a post-diarization pass that re-splits any segment whose words span >1
speaker at the word-level boundary, assigning each piece its speaker:
- backend/services/segmentation.py: resplit_segments_by_diarization /
  resplit_segments_by_turns + a pure _resplit_core. Single-speaker segments are
  returned BYTE-FOR-BYTE UNCHANGED (same dict/id/text/start/end) — the
  no-single-speaker-regression guarantee. Pieces keep the segment's outer
  start/end (preserving onset-snap) and use word times for interior splits, so
  they exactly cover the original span. A lone mis-attributed word is smoothed,
  not split (diarization noise).
- backend/api/routers/dub_core.py: accumulate global-timeline words alongside
  segments; apply the re-split after both the pyannote and FunASR-turns assign.
  Heuristic fallback (no word-speaker data) is untouched.

8 regression tests pin the invariant + the split/3-way/noise-smoothing/label
behaviour. Full suite: 1836 passed.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant