OmniVoice-Studio / backend /services /incremental.py
Lê Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
2.73 kB
"""
Incremental re-dub — Phase 4.1 (ROADMAP.md), the other defensible bet.
Given the current dub state + a prior state, compute the minimum set of
segments whose audio needs regenerating. A segment needs re-gen when any of
its *generation inputs* changed:
text · target_lang · profile_id · instruct · speed · direction
Deterministic content hash per segment is the key. We store the hash on the
segment after every successful generation; when re-dubbing, we hash again and
only queue segments whose hash differs from the stored one.
This isn't full crossfade-at-the-edges yet — that comes with Phase 4.5. For
now the caller re-runs `/dub/generate` with a filtered segments list.
"""
from __future__ import annotations
import hashlib
import json
_GEN_INPUT_FIELDS = ("text", "target_lang", "profile_id", "instruct", "speed", "direction")
def segment_fingerprint(seg: dict) -> str:
"""Deterministic hash of the inputs that actually affect TTS output.
Any change to `_GEN_INPUT_FIELDS` flips the hash and the segment becomes
a re-gen candidate. Changes to position / selection state / lip-sync
badge don't trigger regen, which is what we want.
"""
payload = {k: (seg.get(k) if seg.get(k) is not None else "") for k in _GEN_INPUT_FIELDS}
blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha1(blob.encode("utf-8")).hexdigest()[:16]
def plan_incremental(
segments: list[dict],
*,
stored_hashes: dict[str, str] | None = None,
) -> dict:
"""Return `{stale, fresh, total, fingerprints}` where:
• `stale` : list of segment ids whose generation inputs changed
since the last successful generate (i.e. need re-dub).
• `fresh` : ids whose stored hash still matches current inputs —
safe to reuse the prior audio.
• `fingerprints`: {id: sha1} for every segment, for caller to persist
after a successful regen.
`stored_hashes` may come from the caller's own bookkeeping (e.g. the
`dub_history.job_data["seg_hashes"]` we'll start writing in Phase 4.5).
When missing, every segment is considered stale (first run).
"""
stored = stored_hashes or {}
stale: list[str] = []
fresh: list[str] = []
fingerprints: dict[str, str] = {}
for seg in segments:
sid = str(seg.get("id", ""))
if not sid:
continue
fp = segment_fingerprint(seg)
fingerprints[sid] = fp
prev = stored.get(sid)
if prev == fp:
fresh.append(sid)
else:
stale.append(sid)
return {
"stale": stale,
"fresh": fresh,
"total": len(segments),
"fingerprints": fingerprints,
}