Files
VoiceStudio/backend/services/incremental.py
T
Palash Debnathand4shil 1edd35cfd0 Per-segment audio effects DSP preset selector (closes #67, rebased from #68) (#109)
* Add per-segment audio effects DSP preset selector to dub pipeline

* Add shape assertions to podcast, warm, and bright preset tests

* Fix raw preset semantics, add preset validation, update docs, remove duplicate sys.path

* Narrow OOM catch to model.generate only in dub_generate

* Preserve original OOM exception context in dub_generate

* Bind effect_preset to _gen via explicit parameter to avoid loop capture

* Catch RuntimeError instead of torch.mps.MPSError for MPS OOM

---------

Co-authored-by: 4shil <166588383+4shil@users.noreply.github.com>
2026-05-20 13:40:16 +05:30

80 lines
2.8 KiB
Python

"""
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", "effect_preset")
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.
Currently includes: text, target_lang, profile_id, instruct, speed,
direction, effect_preset.
"""
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,
}