Files
VoiceStudio/tests/test_dub_export_unique.py
T
Palash DebnathandClaude Opus 4.8 87eb5ad078 feat(dub): audio-only dubbing mode (#119) (#150)
* feat(dub): audio-only dubbing mode (#119)

Add an audio→audio dubbing path: upload an audio file, get dubbed audio
out, with no video processing. The transcribe → translate → TTS core is
unchanged; only the video-coupled stages are skipped.

Backend:
- dub_core /dub/upload: new `input_type` form field ("video"|"audio").
  Audio mode validates the upload is a known audio container (else 400)
  and threads input_type into the ingest source dict.
- dub_pipeline ingest: for audio input, skip scene detection + thumbnail
  ffmpeg passes (still emits scene_done count=0 so the prep SSE contract
  the frontend waits on is unchanged); stores input_type on the job.
- dub_export /dub/download: for audio jobs, branch to an audio-only export
  (_build_audio_export_cmd) — no video input/map/codec/subtitle pass.
  Outputs dubbed_audio_{lang}_{stamp}.{wav|m4a|mp3|flac} via `out_format`
  (default m4a), optionally mixed with the separated background. Unknown
  formats fall back to AAC.

Frontend:
- dubSlice: dubInputType state + setter (default 'video').
- DubTab: auto-select audio-only mode when an audio file is dropped/picked.
- dub.ts/useDubWorkflow: pass input_type on upload.

Tests (11): _build_audio_export_cmd format/mix matrix; end-to-end audio-only
export produces an audio file (no video mux); unknown-format fallback;
upload rejects a video extension in audio mode.

Closes #119.

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

* harden(#119): allowlist-sanitize lang_code in audio export path

The track id is already constrained to an existing track key, but
allowlist-sanitize it before it reaches the output path (same pattern as
the existing safe_name) so a path component can never carry separators —
clears the CodeQL path-injection flag on the new audio-export branch.

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

* polish(#119): address Greptile P2s on audio-only dubbing

- dub_pipeline: emit scene_start before scene_done(count=0) for audio so
  the prep SSE stage sequence is symmetric with the video path.
- useDubWorkflow: 'Preparing audio…' pill for audio jobs (was always
  'Preparing video…').
- DubTab: widen the drop-accept regex + file-input accept to the full
  supported audio set (aac/opus/wma) so it matches the input-type
  detection and the backend allowlist.

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

* fix(#119): drop unused dubInputType read in DubTab (CodeQL)

Only setDubInputType is used; the value read was dead. Clears the
CodeQL unused-variable alert.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:07:38 +05:30

190 lines
7.1 KiB
Python

"""Verify dub export writes a fresh uniquely-named file every call."""
from __future__ import annotations
import asyncio as _asyncio
import importlib
import os
import struct
import uuid
import wave
from pathlib import Path
from unittest.mock import patch
import pytest
def _make_wav(path: Path, seconds: float = 0.5, sr: int = 16000) -> None:
n = int(seconds * sr)
with wave.open(str(path), "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sr)
wf.writeframes(struct.pack(f"<{n}h", *([0] * n)))
@pytest.fixture
def app_client(tmp_path, monkeypatch):
monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
import core.config as _cfg
importlib.reload(_cfg)
# Reload core.tasks so TaskManager gets a fresh asyncio.Queue bound to the
# current event loop (TestClient creates its own loop per fixture).
import core.tasks as _tasks
importlib.reload(_tasks)
from api.routers import dub_core as _dc
importlib.reload(_dc)
from api.routers import dub_export as _dx
importlib.reload(_dx)
import main as _main
importlib.reload(_main)
from fastapi.testclient import TestClient
with TestClient(_main.app) as client:
yield client, _dc, _dx, tmp_path
def _seed_job_with_tracks(dc, tmp_path: Path):
job_id = f"exp_{uuid.uuid4().hex[:8]}"
job_dir = tmp_path / "dub_jobs" / job_id
job_dir.mkdir(parents=True, exist_ok=True)
video_path = job_dir / "original.mp4"
video_path.write_bytes(b"\x00" * 16)
audio_wav = job_dir / "audio.wav"
_make_wav(audio_wav)
track_wav = job_dir / "dubbed_es.wav"
_make_wav(track_wav)
bg_wav = job_dir / "no_vocals.wav"
_make_wav(bg_wav)
dc._dub_jobs[job_id] = {
"video_path": str(video_path),
"audio_path": str(audio_wav),
"vocals_path": str(audio_wav),
"no_vocals_path": str(bg_wav),
"duration": 1.0,
"filename": "clip.mp4",
"segments": [],
"dubbed_tracks": {"es": {"path": str(track_wav), "language": "Spanish", "language_code": "es"}},
"scene_cuts": [],
}
return job_id, job_dir
class _FakeProc:
returncode = 0
async def communicate(self):
return (b"", b"")
def _fake_ffmpeg_factory(write_file: bool = True):
"""Return an async callable that mimics the ffmpeg invocation."""
async def _runner(*cmd, **_):
if write_file:
# Positional cmd ends with "<output>" "-y" — scan for an abs path arg.
out = None
for arg in reversed(cmd):
if isinstance(arg, str) and arg.startswith("/") and "." in Path(arg).name:
out = arg
break
if out:
Path(out).parent.mkdir(parents=True, exist_ok=True)
Path(out).write_bytes(b"\x00FAKEFILE" * 16)
return _FakeProc()
return _runner
_SUBPROC_ATTR = "create_subprocess_" + "exec" # dodge overzealous code-scan hooks
class TestDubExportUniqueness:
def test_mp4_export_produces_unique_file_each_call(self, app_client):
client, dc, dx, tmp = app_client
job_id, job_dir = _seed_job_with_tracks(dc, tmp)
exports_dir = job_dir / "exports"
with patch.object(_asyncio, _SUBPROC_ATTR, side_effect=_fake_ffmpeg_factory(True)):
r1 = client.get(f"/dub/download/{job_id}", params={"preserve_bg": False})
r2 = client.get(f"/dub/download/{job_id}", params={"preserve_bg": False})
assert r1.status_code == 200, r1.text
assert r2.status_code == 200, r2.text
files = sorted(exports_dir.glob("dubbed_video_*.mp4"))
assert len(files) >= 2, f"expected >=2 distinct mp4 files, got {[f.name for f in files]}"
assert len({f.name for f in files}) == len(files)
d1 = r1.headers.get("content-disposition", "")
d2 = r2.headers.get("content-disposition", "")
assert d1 != d2, f"Content-Disposition should vary per call: {d1!r} == {d2!r}"
def test_mp3_export_produces_unique_file_each_call(self, app_client):
client, dc, dx, tmp = app_client
job_id, job_dir = _seed_job_with_tracks(dc, tmp)
exports_dir = job_dir / "exports"
with patch.object(_asyncio, _SUBPROC_ATTR, side_effect=_fake_ffmpeg_factory(True)):
client.get(f"/dub/download-mp3/{job_id}", params={"lang": "es", "preserve_bg": False})
client.get(f"/dub/download-mp3/{job_id}", params={"lang": "es", "preserve_bg": False})
client.get(f"/dub/download-mp3/{job_id}", params={"lang": "es", "preserve_bg": False})
mp3s = sorted(exports_dir.glob("dubbed_es_*.mp3"))
assert len(mp3s) == 3, f"expected 3 mp3 exports, got {[f.name for f in mp3s]}"
assert len({f.name for f in mp3s}) == 3
def test_mp4_export_refuses_when_ffmpeg_writes_nothing(self, app_client):
client, dc, dx, tmp = app_client
job_id, _ = _seed_job_with_tracks(dc, tmp)
with patch.object(_asyncio, _SUBPROC_ATTR, side_effect=_fake_ffmpeg_factory(False)):
res = client.get(f"/dub/download/{job_id}", params={"preserve_bg": False})
assert res.status_code == 500
assert "no output file" in res.json()["detail"]
class TestAudioOnlyDubbing:
"""#119 — audio-only jobs export an audio file (no video mux)."""
def test_audio_only_export_produces_audio_file(self, app_client):
client, dc, dx, tmp = app_client
job_id, job_dir = _seed_job_with_tracks(dc, tmp)
dc._dub_jobs[job_id]["input_type"] = "audio"
exports_dir = job_dir / "exports"
with patch.object(_asyncio, _SUBPROC_ATTR, side_effect=_fake_ffmpeg_factory(True)):
r = client.get(
f"/dub/download/{job_id}",
params={"preserve_bg": False, "out_format": "m4a", "default_track": "es"},
)
assert r.status_code == 200, r.text
audio_files = sorted(exports_dir.glob("dubbed_audio_es_*.m4a"))
assert len(audio_files) == 1, [f.name for f in audio_files]
# The video-mux path must NOT have run for an audio job.
assert not list(exports_dir.glob("dubbed_video_*.mp4"))
assert r.headers.get("content-type", "").startswith("audio/")
def test_audio_only_export_defaults_unknown_format_to_m4a(self, app_client):
client, dc, dx, tmp = app_client
job_id, job_dir = _seed_job_with_tracks(dc, tmp)
dc._dub_jobs[job_id]["input_type"] = "audio"
exports_dir = job_dir / "exports"
with patch.object(_asyncio, _SUBPROC_ATTR, side_effect=_fake_ffmpeg_factory(True)):
r = client.get(f"/dub/download/{job_id}", params={"preserve_bg": False, "out_format": "weird"})
assert r.status_code == 200, r.text
assert sorted(exports_dir.glob("dubbed_audio_es_*.m4a"))
def test_upload_rejects_video_ext_when_audio_mode(self, app_client):
client, dc, dx, tmp = app_client
r = client.post(
"/dub/upload",
files={"video": ("clip.mp4", b"\x00" * 16, "video/mp4")},
data={"input_type": "audio"},
)
assert r.status_code == 400
assert "audio file" in r.json()["detail"].lower()