* feat(dub): underrun fill — short dubbed lines are slowed toward their slot instead of leaving dead air The dub pipeline has always handled audio that is too LONG for its slot (atempo compression, Smart Fit's audio/video split, trims). Audio that is too SHORT was start-aligned and abandoned — and that is the common case, not the corner: translations routinely speak faster than the source delivery. Measured on a real 4-segment dub, 8.8 of 18.7 seconds of original speech time had no dubbed voice. What fills those holes is the separated bed's under-speech residue (37% of the original energy, measured), so the user hears them as BOTH "little silences" AND "the music is numbed" — and sees them as lip-sync failure, since the mouth keeps moving after the dub stopped. The fill: when a line's natural duration covers less than UNDERRUN_TOLERANCE (95%) of its slot, slow it toward the slot with the same pitch-preserving atempo pipe the compression path uses, bounded at min_audio_rate (default 0.85x — comfortably natural; atempo handles <1 natively). Wired into both fitting strategies: - fit_planner._fit_one: need < 1 now resolves to audio_rate=max(need, floor), status "audio_slowed" — planner stays a pure function; golden fixtures regenerated per their own instructions (10 substantive lines: five underrun segments across four scenarios flip to audio_slowed@0.85). - dub_generate smart_fit branch: applies the rate in both directions (the target formula was already direction-agnostic). - dub_generate strict_slot branch: mirror of its compression arm. - stretch_video and concise strategies deliberately untouched (natural-rate by design / never-intervene by design). OMNIVOICE_UNDERRUN_MIN_RATE overrides the floor (1.0 disables; clamped to atempo's sane range). The per-segment fit badge shows "slowed N.NNx" with a tooltip, translated in all 21 locales. Tests: planner contracts (fill bounded by floor, tolerance zone untouched, disable switch, empty-audio guard), the flipped unit/golden/integration expectations updated with the rationale, and the existing smart_fit integration test now exercises the fill through the real mix loop (its seg0 comes out audio_slowed@0.85 end to end). Full suite: 2987 backend + 1236 frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(dub): strict-slot slow-downs report themselves honestly (review); ru pitch wording Review round on #1137: - Greptile P1 "slowdown reports fits" — REAL: the strict_slot underrun fill fell through to the unconditional {"status": "fits"} entry, so a slowed segment's badge hid the applied rate (and compression_applied mislabeled it). The branch now emits {"status": "audio_slowed", "audio_rate": …} like the smart_fit path — same honesty contract everywhere. - Greptile P1 "padded audio hides underruns" — REFUTED with evidence: nothing pads strict-slot audio before the check (_load_entry_wav returns the natural-length WAV; only error/silence slots are slot-sized, and those are synthetic silence by design). On-disk segment WAVs measure both shorter and longer than their slots, which pre-padding would make impossible. - CodeRabbit: Russian tooltip now says "высота тона сохранена" (pitch), not "высота сохранена" (height). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: mergetest <nizam4103@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
232 lines
9.9 KiB
Python
232 lines
9.9 KiB
Python
"""Smart Fit planner — dub-length fitting v2, Phase A.
|
||
|
||
Pure planning functions for the ``smart_fit`` timing strategy: given the
|
||
original segment timeline and the *natural-rate* duration of each dubbed
|
||
segment's TTS audio, decide per segment how to reconcile the two by
|
||
splitting the burden between a mild pitch-preserving audio speed-up and a
|
||
mild per-segment video slow-down.
|
||
|
||
Clean-room note: this is a reimplementation from a *published description*
|
||
of the audio-speedup + video-slowdown fitting approach (see
|
||
docs/competitive-analysis.md, "Dub-length fitting"). No GPL source was
|
||
consulted.
|
||
|
||
Algorithm per segment (defaults in :class:`FitParams`):
|
||
|
||
1. **Slack absorption** — the usable slot extends past the segment's
|
||
original end into the silent gap before the next segment, keeping a
|
||
small ``gap_guard_s`` clear of the next onset (the last segment may run
|
||
to the end of the video). ``need = natural_dur / slot``.
|
||
2. ``need <= 1.0`` — fits as-is; nothing to do.
|
||
3. ``1.0 < need <= max_audio_only_rate`` — audio-only speed-up at exactly
|
||
``need`` (imperceptible up to ~1.2×).
|
||
4. ``need > max_audio_only_rate`` — geometric 50/50 split:
|
||
``audio_rate = min(sqrt(need), audio_rate_cap)`` and
|
||
``video_ratio = min(need / audio_rate, video_slow_cap)``. Whatever the
|
||
caps can't absorb becomes ``overflow_s`` (trimmed at mix time).
|
||
5. ``allow_video_retime=False`` — audio-only mode: rate capped at the
|
||
legacy ``MAX_AUDIO_RATE_HARD`` (1.8, matching dub_generate's
|
||
MAX_STRETCH_RATIO guard rail), residual overflows.
|
||
6. **Timeline cursor** — mirrors the existing ``stretch_video`` layout
|
||
loop: pre-roll and inter-segment gaps pass through at 1.0×; each
|
||
segment's video chunk ``[start, effective_end]`` occupies
|
||
``slot * video_ratio`` on the new timeline.
|
||
|
||
This module is deliberately I/O-free and torch-free so it can be unit- and
|
||
golden-tested without a model, ffmpeg, or an event loop.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from dataclasses import dataclass, field
|
||
|
||
# Hard ceiling for audio-only compression when video retiming is disabled.
|
||
# Matches dub_generate.MAX_STRETCH_RATIO — above ~1.8× speech becomes a
|
||
# garbled stream no DSP can rescue.
|
||
MAX_AUDIO_RATE_HARD = 1.8
|
||
|
||
# Underrun fill: a dubbed line that finishes well before its slot leaves a
|
||
# hole — on screen the mouth keeps moving while the dub has gone quiet, and
|
||
# what the listener hears in the hole is the thin under-speech residue of the
|
||
# separated background (measured at ~37% of the original's energy), which
|
||
# reads as dead air. Translations routinely run shorter than the source
|
||
# delivery (measured live: 8.8s of holes across 18.7s of speech), so this is
|
||
# the common case, not a corner. Slots filled to within this fraction are
|
||
# left alone — a <5% hole is imperceptible and not worth an ffmpeg pass.
|
||
UNDERRUN_TOLERANCE = 0.95
|
||
|
||
_EPS = 1e-9
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class FitParams:
|
||
"""Tunable knobs for the Smart Fit planner.
|
||
|
||
All defaults are deliberately conservative: ≤1.2× audio-only is
|
||
imperceptible to most listeners; 1.5× audio is the intelligibility
|
||
cap; 2.0× video slow-down is the limit before motion looks syrupy.
|
||
"""
|
||
max_audio_only_rate: float = 1.2
|
||
audio_rate_cap: float = 1.5
|
||
video_slow_cap: float = 2.0
|
||
gap_guard_s: float = 0.05
|
||
allow_video_retime: bool = True
|
||
# Underrun fill floor: a segment shorter than its slot is slowed toward it
|
||
# (pitch-preserving), never below this rate — 0.85× stays comfortably
|
||
# natural-sounding. 1.0 disables the fill entirely.
|
||
min_audio_rate: float = 0.85
|
||
|
||
|
||
@dataclass
|
||
class SegmentFit:
|
||
"""Planner verdict for one segment."""
|
||
index: int
|
||
seg_id: str
|
||
audio_rate: float # pitch-preserving rate: >1 speeds up (fit), <1 slows down (fill)
|
||
video_ratio: float # ≥ 1.0 — setpts slow-down applied to the video chunk
|
||
new_start: float # placement on the fitted (possibly longer) timeline
|
||
new_end: float # end of the video chunk on the fitted timeline
|
||
orig_start: float
|
||
orig_end: float
|
||
effective_end: float # orig_end + absorbed slack (≤ next start − gap guard)
|
||
status: str # "fits" | "audio_stretched" | "hybrid" | "overflow_trimmed"
|
||
overflow_s: float # seconds of (stretched) audio that still don't fit
|
||
|
||
|
||
@dataclass
|
||
class FitPlan:
|
||
"""Full plan for one dub track."""
|
||
segments: list[SegmentFit] = field(default_factory=list)
|
||
# EXACT dict shape consumed by dub_export._build_video_stretch_filter_graph.
|
||
video_plan: list[dict] = field(default_factory=list)
|
||
total_duration: float = 0.0
|
||
orig_duration: float = 0.0
|
||
params: FitParams = field(default_factory=FitParams)
|
||
|
||
@property
|
||
def needs_video_retime(self) -> bool:
|
||
return any(s.video_ratio > 1.0 + 1e-6 for s in self.segments)
|
||
|
||
|
||
def _fit_one(need: float, params: FitParams) -> tuple[float, float, str]:
|
||
"""Resolve one segment's need ratio into (audio_rate, video_ratio, status)."""
|
||
if need <= 1.0 + _EPS:
|
||
# Underrun fill: slow the audio toward the slot so the dub keeps
|
||
# speaking while the on-screen mouth does. Bounded by min_audio_rate;
|
||
# near-full slots (within UNDERRUN_TOLERANCE) and degenerate needs
|
||
# (empty audio) stay untouched.
|
||
if need > _EPS and need < UNDERRUN_TOLERANCE and params.min_audio_rate < 1.0 - _EPS:
|
||
return max(need, params.min_audio_rate), 1.0, "audio_slowed"
|
||
return 1.0, 1.0, "fits"
|
||
if need <= params.max_audio_only_rate + _EPS:
|
||
return need, 1.0, "audio_stretched"
|
||
if not params.allow_video_retime:
|
||
audio_rate = min(need, MAX_AUDIO_RATE_HARD)
|
||
status = "audio_stretched" if audio_rate >= need - _EPS else "overflow_trimmed"
|
||
return audio_rate, 1.0, status
|
||
# Geometric 50/50 split: equal perceptual burden on audio and video.
|
||
audio_rate = min(math.sqrt(need), params.audio_rate_cap)
|
||
video_ratio = min(need / audio_rate, params.video_slow_cap)
|
||
if audio_rate * video_ratio >= need - _EPS:
|
||
return audio_rate, video_ratio, "hybrid"
|
||
return audio_rate, video_ratio, "overflow_trimmed"
|
||
|
||
|
||
def plan_fit(
|
||
segments: list[dict],
|
||
natural_durs_s: list[float],
|
||
total_dur_s: float,
|
||
params: FitParams | None = None,
|
||
) -> FitPlan:
|
||
"""Plan the Smart Fit layout for a dub track.
|
||
|
||
``segments``: original-timeline segments in chronological order, each a
|
||
dict with ``id``, ``start``, ``end`` (seconds). ``natural_durs_s``: the
|
||
natural-rate TTS audio duration for each segment (parallel list).
|
||
``total_dur_s``: original video duration (0/unknown tolerated — the last
|
||
segment then gets no tail slack).
|
||
|
||
Pure function: no I/O, no torch, deterministic.
|
||
"""
|
||
params = params or FitParams()
|
||
n = len(segments)
|
||
if len(natural_durs_s) != n:
|
||
raise ValueError(
|
||
f"segments ({n}) and natural_durs_s ({len(natural_durs_s)}) must be parallel"
|
||
)
|
||
|
||
plan = FitPlan(params=params, orig_duration=round(float(total_dur_s), 4))
|
||
if n == 0:
|
||
plan.total_duration = round(max(0.0, float(total_dur_s)), 4)
|
||
return plan
|
||
|
||
cursor = 0.0
|
||
for i, seg in enumerate(segments):
|
||
start = float(seg["start"])
|
||
end = float(seg["end"])
|
||
natural = max(0.0, float(natural_durs_s[i]))
|
||
|
||
# (a) Slack absorption. Extend-only: the slot never shrinks below
|
||
# the original [start, end] even when segments are back-to-back.
|
||
if i + 1 < n:
|
||
next_start = float(segments[i + 1]["start"])
|
||
effective_end = max(end, next_start - params.gap_guard_s)
|
||
# Never bleed past the next segment's onset (overlapping or
|
||
# near-touching source segments).
|
||
effective_end = min(max(effective_end, start), max(next_start, end))
|
||
else:
|
||
effective_end = max(end, float(total_dur_s)) if total_dur_s > 0 else end
|
||
slot = max(effective_end - start, 1e-3)
|
||
|
||
need = natural / slot if natural > 0 else 0.0
|
||
audio_rate, video_ratio, status = _fit_one(need, params)
|
||
|
||
# (f) Timeline cursor — mirror the stretch_video layout loop:
|
||
# pre-roll and gaps at 1.0×, the segment's video chunk
|
||
# [start, effective_end] occupies slot × video_ratio.
|
||
if i == 0:
|
||
cursor = start # pre-roll preserved at native rate
|
||
new_start = cursor
|
||
new_end = new_start + slot * video_ratio
|
||
cursor = new_end
|
||
if i + 1 < n:
|
||
# Unretimed sliver between this chunk and the next chunk's
|
||
# start (the gap guard, or more if extend-only clamped).
|
||
cursor += max(0.0, float(segments[i + 1]["start"]) - effective_end)
|
||
|
||
# Residual overflow after both knobs: stretched audio length vs the
|
||
# segment's new video slot.
|
||
stretched = natural / audio_rate if audio_rate > 0 else natural
|
||
overflow_s = max(0.0, stretched - slot * video_ratio)
|
||
if overflow_s <= 1e-6:
|
||
overflow_s = 0.0
|
||
elif status != "overflow_trimmed":
|
||
status = "overflow_trimmed"
|
||
|
||
plan.segments.append(SegmentFit(
|
||
index=i,
|
||
seg_id=str(seg.get("id", f"seg_{i}")),
|
||
audio_rate=round(audio_rate, 6),
|
||
video_ratio=round(video_ratio, 6),
|
||
new_start=round(new_start, 4),
|
||
new_end=round(new_end, 4),
|
||
orig_start=round(start, 4),
|
||
orig_end=round(end, 4),
|
||
effective_end=round(effective_end, 4),
|
||
status=status,
|
||
overflow_s=round(overflow_s, 4),
|
||
))
|
||
plan.video_plan.append({
|
||
"orig_start": round(start, 4),
|
||
"orig_end": round(effective_end, 4),
|
||
"new_start": round(new_start, 4),
|
||
"new_end": round(new_end, 4),
|
||
"stretch_ratio": round(video_ratio, 4),
|
||
})
|
||
|
||
# Tail (anything after the last segment's effective end) at 1.0×.
|
||
last_eff = float(plan.segments[-1].effective_end)
|
||
cursor += max(0.0, float(total_dur_s) - last_eff)
|
||
plan.total_duration = round(max(cursor, float(total_dur_s)), 4)
|
||
return plan
|