Files
VoiceStudio/backend/api/routers/tools.py
T
d90cfde1bb feat(dub): per-language translations + per-track caches — switching languages stops destroying work (P1) (#958)
* feat(dub): per-language translations + per-track caches — switching languages stops destroying work (P1)

Multi-language dubbing translated per language (#957) but stored everything
in single-slot state, so tracks silently destroyed each other's work:

P1.2 — per-language translation storage (additive):
- Frontend keeps every translation in s.translations[langCode] alongside the
  legacy s.text slot (still = the shown language). Translate All writes both;
  the new store action switchDubLangCode swaps text through the map on a
  user-driven language switch (non-destructive; restore paths keep the plain
  setter); manual edits / restore-original update the current language's
  entry; merge joins per-language texts, split drops them. Rides project
  save/load inside dubSegments — legacy projects behave exactly as before.
- Backend mirrors it as job["segments_i18n"] = {lang: {segKey: text}}
  (segKey = stable id, index for id-less legacy rows), written by
  _sync_job_segments; job["segments"] stays byte-identical for every existing
  consumer. /dub/srt|vtt?lang= and subtitle burn-in now emit THAT language's
  text when present — ExportModal's "all dubs" batch stops producing N
  identical files. Legacy jobs without the field fall back to today's output.

P1.3 — per-track WAV cache + fingerprints:
- Per-segment WAVs are language-keyed (seg_{lang}_{id}.wav). The partial-regen
  read path falls back to legacy seg_{id}.wav ONLY while the job has no
  other-language track — single-language jobs keep their whole on-disk cache;
  multi-track jobs stop splicing the last-generated language into the current
  track. Read-only endpoints (segment preview, clips zip) gained ?lang= with
  the permissive legacy fallback they always had.
- Fingerprints include the track language (segment_fingerprint(track_lang=…),
  /tools/incremental lang=…) and live in job["seg_hashes_by_lang"]; the flat
  job["seg_hashes"] stays as the current track's mirror so the done event,
  history restore and older frontends read it unchanged. A legacy flat map is
  attributed to the job's last-generated language (dropped when unknown) and
  reads stale once — the safe direction. seg_wav_kind is per-track too.
- The frontend stores fingerprints per language and judges "Regen N changed"
  against the ACTIVE track; project save/load and dub-history restore carry
  all tracks' hashes (segHashesByLang / seg_hashes_by_lang, additive).

Tests: fail-before regression coverage — two-track regen never splices the
other language's audio (sample-level assert on the mixed track), legacy
single-track cache reuse + multi-track gate, per-lang seg_hashes with flat
mirror + migration semantics, /dub/srt|vtt?lang= emitting different text per
track with legacy fallbacks, per-lang burn-in, /tools/incremental lang
scoping, and 14 frontend tests for translations round-trips, per-track
fingerprints and legacy-project behaviour. Full backend + frontend suites,
typecheck, lint and format:check green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add per-language storage + per-track caches under [Unreleased] (#958)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:05:40 +05:30

186 lines
6.3 KiB
Python

"""
Tools router — Phase 4.6 (ROADMAP.md).
Standalone utilities exposed as first-class endpoints, independent of the
dub pipeline. The Tools page UI consumes these. Headless CLI consumers
(omnivoice-dub) will share the same service layer.
Shipped today:
POST /tools/probe → ffprobe-style metadata for a file path.
POST /tools/incremental → plan what segments need regenerating.
POST /tools/direction → parse a natural-language direction into tokens.
POST /tools/rate-fit → LLM-assisted slot-fit for translated text.
More utilities (vocal separation, alignment, merge) are wired through
existing dub helpers and land in follow-up passes.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from services import director, speech_rate, incremental
from services.ffmpeg_utils import find_ffprobe, spawn_subprocess
logger = logging.getLogger("omnivoice.tools")
router = APIRouter()
# ── Probe (ffprobe wrapper) ────────────────────────────────────────────────
class ProbeReq(BaseModel):
path: str
@router.post("/tools/probe")
async def probe(req: ProbeReq):
target = os.path.realpath(os.path.expanduser(req.path))
if not os.path.exists(target):
raise HTTPException(
status_code=404,
detail="File not found. Provide an absolute path to an existing file.",
)
ffprobe = find_ffprobe()
if not ffprobe:
raise HTTPException(
status_code=501,
detail="ffprobe binary not available. Install system ffmpeg or re-run the setup.",
)
proc = await spawn_subprocess(
ffprobe, "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", target,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise HTTPException(
status_code=500,
detail=f"ffprobe failed: {stderr.decode(errors='replace')[:400]}",
)
try:
return json.loads(stdout.decode("utf-8"))
except json.JSONDecodeError:
return {"raw": stdout.decode("utf-8", errors="replace")}
# ── Incremental plan (what needs regenerating) ─────────────────────────────
class IncrementalReq(BaseModel):
segments: list[dict]
stored_hashes: Optional[dict[str, str]] = None
# P1.3 — the ACTIVE track's language code. When set, fingerprints are
# scoped to that language (pass that language's stored hashes alongside);
# omitted → legacy language-agnostic hashing, kept for old callers.
lang: Optional[str] = None
@router.post("/tools/incremental")
def plan_incremental(req: IncrementalReq):
return incremental.plan_incremental(
req.segments,
stored_hashes=req.stored_hashes or {},
track_lang=req.lang,
)
# ── Directorial AI parse ───────────────────────────────────────────────────
class DirectionReq(BaseModel):
text: str = Field(..., description="Natural-language direction, e.g. 'urgent and surprised'")
@router.post("/tools/direction")
def parse_direction(req: DirectionReq):
d = director.parse(req.text)
return {
"tokens": d.tokens,
"instruct_prompt": d.instruct_prompt(),
"translate_hint": d.translate_hint(),
"rate_bias": d.rate_bias(),
"method": d.method,
"error": d.error,
"taxonomy": director.TAXONOMY,
}
# ── Speech-rate fit ────────────────────────────────────────────────────────
class RateFitReq(BaseModel):
text: str
slot_seconds: float
target_lang: str
source_text: Optional[str] = None
@router.post("/tools/rate-fit")
def rate_fit(req: RateFitReq):
return speech_rate.adjust_for_slot(
req.text,
slot_seconds=req.slot_seconds,
target_lang=req.target_lang,
source_text=req.source_text,
)
# ── Audio effects presets ──────────────────────────────────────────────────
@router.get("/tools/effects")
def list_effects():
"""Return available audio effect presets (Broadcast, Cinematic, etc.)."""
from services.audio_dsp import list_effect_presets
return list_effect_presets()
# ── TTS Plugin SDK ─────────────────────────────────────────────────────────
@router.get("/tools/plugins")
def list_tts_plugins():
"""Return all registered TTS engine plugins and their availability."""
from services.plugin_sdk import list_plugins
return list_plugins()
# ── Video context analysis ─────────────────────────────────────────────────
@router.post("/tools/video-context/{job_id}")
async def analyse_video_context(job_id: str):
"""Analyse the source video's visual context for dubbing decisions.
Returns per-segment mood, brightness, and complexity cues that
can be used as TTS instruct hints.
"""
import os
from api.routers.dub_core import _get_job
from core.config import DUB_DIR
from services.video_context import analyse_video
job = _get_job(job_id)
if not job:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Job not found")
video_path = os.path.join(DUB_DIR, job_id, "source.mp4")
if not os.path.exists(video_path):
video_path = job.get("video_path", "")
if not video_path or not os.path.exists(video_path):
return {"error": "Source video not found", "segments": {}}
segments = job.get("segments") or []
ctx = await analyse_video(video_path, segments)
return ctx.to_dict()