Files
VoiceStudio/backend/services/translator.py
T
Palash DebnathandClaude Opus 4.7 a1ef66c321 Stability pass: DB leaks, App.jsx hooks refactor, desktop bootstrap (#49)
* fix: eliminate DB connection leaks, race conditions, and deprecated asyncio API

## DB Connection Leaks (P0)
- Convert 38 raw get_db() calls to db_conn() context manager across 14 router files
- Connections are now guaranteed to close even when exceptions are raised
- profiles.py create_profile: clean up orphaned audio file if DB insert fails
- profiles.py lock_profile: consolidate 3 separate conn.close() error paths

## Race Condition (P1)
- Add _dub_jobs_lock (threading.Lock) to protect _dub_jobs dict in dub_pipeline.py
- get_job/put_job now thread-safe for concurrent dub sessions

## asyncio Deprecation (P2)
- Replace 23 asyncio.get_event_loop() calls with asyncio.get_running_loop()
- Prevents DeprecationWarning on Python 3.12+ and future breakage on 3.14

## Quick Fixes
- gallery.py preview_voice: remove filesystem path from error response (P2)
- dub_pipeline.py parse_vtt_segments: remove redundant `import re` inside loop (P3)
- gallery.py _init_gallery_db: use db_conn() context manager (P2)

* refactor: extract hooks, centralize isTauri, add pytest-cov

## Frontend
- Extract useTTS hook (150 LOC) — TTS generation, streaming, audio ingestion
- Extract useProfiles hook (219 LOC) — voice profile CRUD, lock/unlock, preview
- Centralize isTauri detection: dialog.js, VoiceGallery.jsx, Settings.jsx
  now import from utils/media.js instead of 4 different detection patterns

## Backend
- Add pytest-cov to dev dependencies
- Baseline coverage: 39% across backend/ (214 tests pass)
- Add .coverage to .gitignore

* feat: add Vitest + checkJs, extract useDubWorkflow + useAppData hooks

## Frontend Testing (new)
- Set up Vitest with jsdom environment + @testing-library/react
- 11 tests: utils (isTauri, formatTime, constants) + Zustand store (mode, text, dubStep, pill)
- Scripts: 'test' (vitest run), 'test:watch' (vitest), 'test:legacy' (node runner)

## App.jsx Decomposition (continued)
- Extract useDubWorkflow hook (387 LOC) — upload, ingest, transcribe SSE,
  translate, generate SSE, abort, stop, cleanup
- Extract useAppData hook (181 LOC) — data loading, localStorage persistence,
  WebSocket real-time updates, model-status pill management

## TypeScript checkJs
- Enable checkJs: true in tsconfig.json for IDE-level type checking
- 947 existing errors (informational, not blocking builds)
- noImplicitAny remains false to avoid blocking

* ci: add Vitest step, fix useProfiles duplicate state

## CI
- Add 'Run Vitest (frontend)' step — runs 11 unit tests
- Override --checkJs false in CI typecheck to avoid 947 pre-existing errors
- Rename legacy test step for clarity

## Hooks
- Fix useProfiles to accept loadProfiles from parent (useAppData)
  instead of managing its own duplicate profiles array

* refactor: wire hooks into App.jsx — 2067 → 1129 LOC (-45%)

App.jsx now delegates to extracted hooks instead of inline logic:
- useAppData: data loading, localStorage, WebSocket, model pill
- useProfiles: voice profile CRUD, lock/unlock, preview
- useTTS: generation, streaming, audio ingestion
- useDubWorkflow: upload, transcribe SSE, translate, generate SSE

988 lines removed. All handler logic lives in focused,
independently testable hooks. Store selectors and render
JSX stay in App.jsx as the shell.

Verified: vite build clean, 11 frontend + 214 backend tests pass.

* feat: show real-time percentage on model loading pill

Backend: register hf_progress listener during _load_model_sync()
so download/weight-loading tqdm events update _loading_detail with
a progress percentage (0-99%). get_model_status() now includes a
'progress' field that the frontend polls.

Frontend: useAppData reads msQuery.data.progress and calls
setPillProgress() — the FloatingPill already renders the percentage
text and progress bar width from this value.

* fix: prevent FileNotFoundError in desktop bundle during model init

transformers >=4.52 calls _can_set_experts_implementation() and
_can_set_attn_implementation() during PreTrainedModel.__init__,
which open the class source file via open(class_file). In a Tauri
desktop bundle, module.__file__ points to a path that doesn't
exist on disk, causing:

  FileNotFoundError: .../omnivoice/models/omnivoice.py

Override both classmethods on OmniVoice to return static values
without filesystem access. OmniVoice doesn't use MoE experts
(return False), but does support flex/flash attn (return True).

* fix: sync source dirs on every bootstrap, not just first run

The Tauri bootstrap previously only copied omnivoice/ and backend/
to Application Support on the first run. Subsequent app updates
kept using stale source files, preventing bug fixes from landing.

Now ensure_venv_ready() always syncs both directories from the
bundle resources before returning, even when the venv is healthy.
This fixes the FileNotFoundError crash where the old omnivoice.py
lacked the _can_set_experts_implementation override.

* ui: premium setup wizard polish

- Primary button: solid gradient fill with hover glow + lift + press
- Stepper nav: connected pills with glow ring on active step
- Welcome cards: glassmorphism with stagger-in animations, lucide icons,
  left-border accent strip, hover translate
- Preflight panel: colored icon pill backgrounds, stagger-slide entrance
- Step transitions: fade+slide animation via keyed wrapper
- Footnote: shortened paths (~/ notation), Reveal in Finder button
- Recommendation banner: gradient background with accent glow
- Compact spacing throughout for denser, professional layout

* fix: kill zombie backend on clean+retry bootstrap

When clean_and_retry_bootstrap removes the project dir, any old
uvicorn process still running from the deleted paths remains alive
on port 3900. The subsequent retry_bootstrap sees the port is
healthy and attaches to the zombie instead of re-bootstrapping.

Now explicitly kill any process on the backend port after cleaning,
before calling retry_bootstrap.

* feat: integrate speaker clones into dubbing interface, sanitize system environment variables for subprocesses, and improve FFMPEG binary path resolution.

* fix: restore docker compose default + drop dead setSeed call

- deploy/docker-compose.yml: remove profiles: ["cpu"] from the default
  service so `docker compose up` matches the comment on line 5. With the
  profile present, no service auto-started.

- frontend/src/App.jsx: drop the setSeed call in restoreHistory. The
  selector was never reintroduced after the App.jsx hooks split, and
  there is no seed state in the store — seeds are generated fresh per
  call in useTTS and only read from history items for display.

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

* fix: address CodeRabbit review — async detection, dub stream, bootstrap fail-fast

- backend/services/tts_backend.py: invert async-context detection in
  _ensure_loaded. The previous code unconditionally caught its own
  diagnostic RuntimeError and then called asyncio.run() inside a
  running loop, masking the intended error message.

- frontend/src/hooks/useDubWorkflow.js: require a terminal `done` event
  before reporting dub success. Without this, a dropped stream after
  partial progress would flip the UI to `done`, refresh history, and
  play the completion ping as if generation finished.

- frontend/src/hooks/useDubWorkflow.js: restore the previous step when
  tasksCancel() fails. The UI was getting stuck in `stopping` forever
  on cancel errors.

- frontend/src-tauri/src/bootstrap.rs: fail-fast when source sync fails
  after the existing directory has already been removed. The previous
  warn-and-continue path could leave the install with no backend/ or
  omnivoice/ sources and defer the failure to backend startup with a
  cryptic error.

- backend/api/routers/generation.py: add `from e` to the ValueError →
  HTTPException re-raise (Ruff B904).

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

* fix: preserve % suffix in TTS generation timer

The 100ms timer in useTTS was rewriting generationTime to a plain
elapsed-seconds string, which immediately wiped the "(xx%)" download
suffix written on the next iteration of the response-body loop. The
real-time percentage was flickering on/off as a result.

Read the previous value inside the setter and reattach any existing
percent suffix.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:49:04 +05:30

315 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Cinematic translation pipeline — Phase 1.1 (ROADMAP.md).
Takes the literal translation of a segment (from any provider — Argos, Google,
NLLB, OpenAI, …) and runs it through a 3-step LLM chain:
1. LITERAL — already done by the provider caller; passed in as input.
2. REFLECT — LLM critiques the literal against tone, idiom, length,
pacing, and any project glossary.
3. ADAPT — LLM rewrites for cinematic delivery using the critique.
Output contract per segment:
{
"id": seg.id,
"text": final adapted text, ← what the dub uses
"literal": step-1 text, ← kept for UI "3-column view"
"critique": step-2 text, ← kept for UI "3-column view"
}
Graceful degradation: if the LLM is unreachable / unconfigured, each segment
falls back to the literal text with a `translate_error` marker so the UI can
surface "Cinematic unavailable — showing Fast result for N segments".
The reflect + adapt calls go through an OpenAI-compatible client, configurable
via env:
TRANSLATE_BASE_URL # default: https://api.openai.com/v1
TRANSLATE_API_KEY # or OPENAI_API_KEY
TRANSLATE_MODEL # default: gpt-4o-mini
OMNIVOICE_LLM_TIMEOUT=45 # seconds per LLM call
Works with real OpenAI, Ollama (base_url=http://localhost:11434/v1), LM Studio,
Together, Anyscale — anything that speaks the OpenAI chat-completion shape.
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Any, Iterable, Optional
logger = logging.getLogger("omnivoice.translator")
# ── Prompts ──────────────────────────────────────────────────────────────────
# Kept short + direct. These run N × 2 times per dub, so verbosity = wall time.
_REFLECT_PROMPT = """\
You are a professional dubbing script editor. The user will give you a source
line and its literal translation. Critique the literal translation in 2-3
crisp sentences, focusing on:
- natural idiom in the target language
- emotional tone (does it match what the speaker would convey?)
- length (will it fit in the same time slot as the source?)
- any proper nouns or recurring terms that should stay consistent
Reply ONLY with the critique — no headers, no bullet points, no code fences."""
_ADAPT_PROMPT = """\
You are a cinematic dubbing writer. Rewrite the literal translation using the
editor's critique so it sounds natural, in-character, and fits the speaker's
time slot. Keep meaning faithful but prefer native idiom over word-for-word
accuracy. The output MUST be written in the same target language and script
as the literal translation — never switch language or transliterate.
Reply ONLY with the adapted translation — no quotes, no headers, no code
fences, no commentary."""
# Per-language script ranges, mirrored from dub_translate.LANG_REQUIRED_SCRIPT
# so the cinematic refine path can reject LLM outputs that drifted off the
# target script. Kept local instead of imported because the routers package
# also imports this services module — circular-import risk otherwise.
_SCRIPT_RANGES = {
"hi": (0x0900, 0x097F),
"ar": (0x0600, 0x06FF),
"zh": (0x4E00, 0x9FFF),
"zh-CN": (0x4E00, 0x9FFF),
"ja": (0x3040, 0x30FF),
"ko": (0xAC00, 0xD7AF),
"th": (0x0E00, 0x0E7F),
"ru": (0x0400, 0x04FF),
"uk": (0x0400, 0x04FF),
}
def _looks_like_target_script(text: str, code: str, threshold: float = 0.5) -> bool:
rng = _SCRIPT_RANGES.get(code)
if not rng:
return True
lo, hi = rng
letters = [c for c in text if c.isalpha()]
if not letters:
return True
inside = sum(1 for c in letters if lo <= ord(c) <= hi)
return (inside / len(letters)) >= threshold
def _llm_client():
"""Lazy-build the OpenAI-compatible client. Returns None if no key + no local base_url."""
try:
from openai import OpenAI
except ImportError:
logger.warning("openai package not installed — cinematic mode unavailable.")
return None
base_url = os.environ.get("TRANSLATE_BASE_URL")
api_key = (
os.environ.get("TRANSLATE_API_KEY")
or os.environ.get("OPENAI_API_KEY")
or ("local" if base_url else None) # local providers often accept any key
)
if not api_key:
return None
kw = {"api_key": api_key}
if base_url:
kw["base_url"] = base_url
return OpenAI(**kw)
def _llm_model() -> str:
return os.environ.get("TRANSLATE_MODEL", "gpt-4o-mini")
def _llm_timeout() -> float:
try:
return float(os.environ.get("OMNIVOICE_LLM_TIMEOUT", "45"))
except ValueError:
return 45.0
def _glossary_text(glossary: Iterable[dict] | None) -> str:
"""Format the project glossary as a preamble for the LLM prompts.
Empty / None → empty string. Otherwise one "SRC → TGT" per line.
"""
if not glossary:
return ""
lines = []
for entry in glossary:
src = (entry.get("source") or "").strip()
tgt = (entry.get("target") or "").strip()
if not src or not tgt:
continue
note = (entry.get("note") or "").strip()
lines.append(f"- {src} → {tgt}" + (f" (note: {note})" if note else ""))
if not lines:
return ""
return (
"Project glossary — every occurrence of a source term must be rendered "
"as its target, unless the critique explicitly overrides it:\n"
+ "\n".join(lines)
)
def _chat(client, *, system: str, user: str) -> str:
"""One-shot chat completion. Raises on failure."""
res = client.chat.completions.create(
model=_llm_model(),
timeout=_llm_timeout(),
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
)
return (res.choices[0].message.content or "").strip()
# ── Public API ──────────────────────────────────────────────────────────────
def cinematic_available() -> bool:
"""Cheap check so callers can warn early rather than after a full translate run."""
return _llm_client() is not None
def cinematic_refine_sync(
source_text: str,
literal_text: str,
*,
source_lang: str,
target_lang: str,
glossary: Iterable[dict] | None = None,
direction: Optional[str] = None,
) -> dict:
"""Blocking: run REFLECT + ADAPT on a single segment.
Returns `{"text", "literal", "critique"}` on success. On LLM failure,
returns `{"text": literal_text, "literal": literal_text, "critique": "",
"error": "…"}` so the caller can keep going and surface a warning.
Meant to run in a threadpool; the async wrapper below handles dispatch.
"""
result_ok = {
"text": literal_text,
"literal": literal_text,
"critique": "",
}
if not literal_text or not literal_text.strip():
return result_ok
client = _llm_client()
if client is None:
return {**result_ok, "error": "no-llm"}
glossary_preamble = _glossary_text(glossary)
# Phase 4.2 — if a direction was supplied, compute a translate hint that
# feeds into both reflect and adapt prompts. Parser picks up taxonomy
# tokens via LLM when configured, falls back to a keyword heuristic.
direction_hint = ""
if direction and direction.strip():
try:
from services.director import parse as _parse_direction
d = _parse_direction(direction)
direction_hint = d.translate_hint()
except Exception as e:
logger.debug("director parse skipped: %s", e)
def _with_preamble(base: str) -> str:
out = base
if glossary_preamble:
out = out + "\n\n" + glossary_preamble
if direction_hint:
out = out + "\n\nDirection: " + direction_hint
return out
# Step 2 — reflect
try:
reflect_user = (
f"Source ({source_lang}): {source_text}\n"
f"Literal translation ({target_lang}): {literal_text}"
)
critique = _chat(client, system=_with_preamble(_REFLECT_PROMPT), user=reflect_user)
except Exception as e:
logger.warning("cinematic reflect failed: %s", e)
return {**result_ok, "error": f"reflect: {e}"}
# Step 3 — adapt
try:
adapt_user = (
f"Source ({source_lang}): {source_text}\n"
f"Literal translation ({target_lang}): {literal_text}\n"
f"Editor's critique: {critique}"
)
adapted = _chat(client, system=_with_preamble(_ADAPT_PROMPT), user=adapt_user)
except Exception as e:
logger.warning("cinematic adapt failed: %s", e)
return {
"text": literal_text,
"literal": literal_text,
"critique": critique,
"error": f"adapt: {e}",
}
final = (adapted or "").strip() or literal_text
# Refuse adaptations that drifted off the target script (e.g. local LLM
# rewrote a Devanagari line in Latin/German). Caller still gets the
# critique so the UI can show what happened, but the live text falls
# back to the literal translation rather than corrupting the dub.
if final is not literal_text and not _looks_like_target_script(final, target_lang):
logger.warning(
"cinematic adapt produced wrong-script output for %s — falling back to literal",
target_lang,
)
return {
"text": literal_text,
"literal": literal_text,
"critique": critique,
"error": f"adapt-wrong-script:{target_lang}",
}
return {
"text": final,
"literal": literal_text,
"critique": critique,
}
async def cinematic_refine_many(
pairs: list[tuple],
*,
source_lang: str,
target_lang: str,
glossary: Iterable[dict] | None = None,
directions: Optional[dict[str, str]] = None,
executor=None,
concurrency: int | None = None,
) -> list[dict]:
"""Fan out REFLECT + ADAPT across N segments on `executor`.
`pairs`: list of `(id, source_text, literal_text)`.
`directions`: optional `{seg_id: "natural-language direction"}` — when
present, the matching segment's reflect/adapt prompts get the parsed
direction hint prepended.
Returns a list of dicts keyed the same length + order, each carrying
`id`, `text`, `literal`, `critique`, optional `error`.
"""
loop = asyncio.get_running_loop()
directions = directions or {}
# Bound concurrency so we don't fan out 500 simultaneous requests.
sem = asyncio.Semaphore(concurrency or int(os.environ.get("OMNIVOICE_LLM_CONCURRENCY", "6")))
async def _one(seg_id: str, src: str, lit: str) -> dict:
async with sem:
res = await loop.run_in_executor(
executor,
lambda: cinematic_refine_sync(
src, lit,
source_lang=source_lang,
target_lang=target_lang,
glossary=glossary,
direction=directions.get(seg_id),
),
)
return {"id": seg_id, **res}
return await asyncio.gather(*(_one(sid, src, lit) for sid, src, lit in pairs))