Files
VoiceStudio/backend/services/ffmpeg_utils.py
T
Palash Debnath 73ebd6518d fix(errors): six failures that reached users as raw text (#1262, #1256, #1251, #1247, #1257, #1254) (#1264)
* fix(errors): four failures that reached users as raw OS text (#1262, #1256, #1251, #1252)

#1262 — a voice profile named in any non-latin-1 script 500'd every download
endpoint with "'latin-1' codec can't encode characters in position 22-25".
`attachment; filename="` is exactly 22 characters, so those were the first
four characters of the user's own name. The sanitisers in front of the header
filtered with str.isalnum(), which is True for every alphabetic script — they
stripped punctuation and passed exactly what breaks the header. Ten sites, one
RFC 6266 builder, plus a guard so an eleventh can't be hand-written.

#1256 — a synth died on FileNotFoundError: 'ffprobe' and was reported as "an
error OmniVoice doesn't recognize", on a Mac where the app's own ffprobe was
resolvable the whole time. Our call sites pass explicit paths; a dependency
shelling out by bare name does not. The resolved directories are now published
on PATH, and the failure is classified either way.

#1251 — "The paging file is too small" reached the user as a bare 500. It was
already counted as an OOM, but that remedy (close apps, lighter engine) is
wrong on a 32 GB machine — the fix is a Windows setting, and the hint now says
which. Matched on the code in both the Python and Rust spellings.

#1252/#1253 — deleting a dub mid-import crashed it with `ingest: 'mgw39lx3'`:
str(KeyError) is the repr of the key. The pipeline blind-subscripted a job that
DELETE /dub/history/{id} had popped minutes earlier. It now stops quietly, and
no exception whose str() is a bare value can present itself that way again.

* fix(engines): Unload 400'd, a wrong language said nothing, DRM was retried by hand (#1247, #1257, #1254)

#1247 — list_loaded() advertises in-process engines as `engine:<id>` with
"unloadable": true, but unload() only ever handled tts/diarization/sidecars.
The panel was rendering a button for ids the dispatcher rejected. The engines
already implement unload(); only the routing was missing. The contract test
written for it immediately found a second instance — `capture-asr`, listed the
same way with no branch either — which is why it enumerates the listing rather
than hard-coding ids.

#1257 — MLXAudioBackend.supported_languages() returns ["multi"] on the stated
assumption that "each engine silently ignores languages it doesn't know". It
doesn't; the library raises. So the picker offers all 646 languages and the
rejection arrived as a bare list of 23 codes, naming neither the engine nor the
way out. Enumerating each model's real language set would be a brittle map that
goes stale every engine update — name the engine and the fix instead.

#1254 — reported as intermittent: the same URL failed as DRM-protected, then
succeeded on retry. Real DRM doesn't lapse; the player client varies. That is
the same shape as the 403 case which already escalates through
_YT_PLAYER_CLIENTS, so DRM now routes into it. If every client still refuses,
the failure is classified instead of arriving as a raw yt-dlp line.

* fix(review): close the delete race, narrow the tool match, sanitize the fallback

Greptile P1 + CodeRabbit Major — verified real, and mine: splitting merge from
save left a window where a delete lands between them, so the pending save
UPSERTs the row straight back and a dub the user deleted reappears. Now one
atomic step under _dub_jobs_lock, with both delete endpoints purging rows and
memory under that same lock. That also fixed DELETE /dub/history, which
deleted every row but evicted nothing — an in-flight job survived 'clear
history' outright and re-saved itself on completion.

CodeRabbit Minor (#1256): the media-tool match accepted any message ending in
'ffmpeg'/'ffprobe', so a missing FILE at /tmp/ffmpeg got the 'repair your
media engine' remedy. Now requires the name unquoted-and-unqualified.

CodeRabbit Major (#1262): `fallback` reached the header verbatim whenever the
real name folded away entirely, walking past every guard the name goes
through. Folded like the name.

CodeRabbit Major (#1256): the PATH log printed resolved directories, and a
user-set FFMPEG_PATH sits under their home. Logs a count now.

CodeRabbit Minor (#1262): the subtitle-route assertion also passed against the
pre-fix header; it now asserts filename*= too.

Skipped: 'Highlights bullets must end with (#N)'. CLAUDE.md scopes that to the
### subsections; none of the seven pre-existing highlights carry refs, and
tests/test_changelog_style.py encodes the rule already.

* fix(review): the remaining unlocked save paths, an over-broad signature, two weak tests

Greptile P1 — the mid-pipeline put_job + save_job pairs were still unlocked, so
a clear-history landing between them left a ghost row behind the purge. Both go
through put_and_save_job now; only the final completion gate decides whether a
withdrawn job's work is kept.

CodeRabbit Major (#1257) — 'unsupported language' as a bare prefix also matches
'Unsupported language model configuration', handing a model/config failure
engine-switch advice it has no use for. The loose wordings now require the
rejected thing to be a code or to end there.

CodeRabbit Major (#1257) — the OOM test asserted on SOURCE TEXT, which passes
even if the call is unreachable or its result discarded; #1224 taught this same
lesson on this codebase. Both it and the language rewrite now drive the real
_run_backend_inference with a raising backend.

CodeRabbit Minor (#1257) — 'or "engine" in message' always passed, since the
production template contains the word. Asserts the resolved class name now.

CodeRabbit Major (#1252) — the delete-race test deleted the job BEFORE the
merge, which only re-tested the absent case and would pass with the two steps
still split. It now interleaves a real second thread against a slow save.

CodeRabbit Major (#1256) — a hardcoded /tmp literal trips Ruff S108; built from
tmp_path instead.

* fix(review): a withdrawal must survive the job's first write

CodeRabbit Major — the concern is real, though its suggested fix (gate the
checkpoint on the job already existing) would break creation: an ingest's FIRST
persistence is what creates the entry, so that gate would never pass.

The actual defect is that dict membership cannot express 'withdrawn'. An absent
key means 'not written yet' for a new job and 'deleted' for an established one
— two opposite instructions from one signal. So a clear-history arriving before
the first checkpoint was silently undone by that checkpoint recreating the row,
and the run then persisted its result into history the user had just cleared.

Tombstone it explicitly: the ingest declares itself in flight, a purge marks any
in-flight id withdrawn, and both write paths refuse a withdrawn id. Released in
, so it's bounded by concurrent ingests and can't poison a later run
that reuses the id.

That also fixed clear-history properly: a job with no row yet appears in no id
list, so only an in-flight sweep can catch it.

CodeRabbit Minor — my race test waited on an event that could not be set while
the save held the lock, so it burned its full 2s timeout every run and
synchronised nothing. It now waits for the purge thread to REACH the purge.

* test(dub): the race test was not testing the race

Caught by verifying fail-before rather than trusting the test: splitting merge
from save — the exact resurrection bug — passed all 22 tests.

The assertions checked WHAT happened (the save ran, the row was deleted, the
job left memory) but never WHEN. A save landing after the delete is
indistinguishable from one landing before if you only assert that both
occurred — and 'after' is precisely the resurrection.

Now recorded and asserted as an order. With merge+save atomic the purge cannot
start until the save finishes, so the sequence is always save-then-delete;
split them and it fails with ['delete', 'save'].

That is the second time this test needed rewriting: v1 deleted the job before
the merge and only re-checked the absent case, v2 interleaved a real thread but
asserted the wrong thing. Both looked like tests.

Also documents why the DB write sits inside the lock (atomicity beats a rare
5 s sqlite busy-timeout stall) and that no locked region calls another, so the
non-reentrant lock cannot deadlock — verified by walking every locked region.

* fix(dub): gate the withdrawal at save_job, not at its callers

Greptile P1 — and the same class I'd already fixed, unfixed elsewhere. The
withdrawal check sat in the two ingest helpers, but eight direct save_job call
sites across dub generate / translate / export / core bypass those entirely.
Deleting a dub mid-RENDER therefore still resurrected it, which is at least as
likely as deleting mid-import.

Moved the gate into save_job itself: one choke point, every caller inherits it,
and the ninth cannot forget. That needs a re-entrant lock, since the atomic
helpers call save_job while already holding it — a plain Lock would deadlock
the backend, so a test pins the lock type and another exercises the nested path.

Verified fail-before: removing the gate fails the new test.

* fix(dub): the withdrawal only covered ingests, so it covered almost nothing

Caught by testing the reported scenario directly instead of trusting a green
suite: CI passed, 26 tests passed, and a dub deleted during a RENDER was still
resurrected.

The tombstone was scoped to in-flight ingests. But a dub is imported once and
rendered many times, so the realistic delete lands during a render — long after
its ingest ended — and end_ingest was CLEARING the tombstone at exactly that
point. The rare case was protected and the common one left open.

Now scoped to deletions, not ingests. Kept in a bounded LRU rather than cleared
on completion, because there is no moment at which a delete stops mattering:
any operation still holding that job can persist it. Re-importing an id is the
only thing that legitimately revives it.

Verified fail-before: the previous scoping fails three of the new tests.

* refactor: move the dub delete-resurrection fix to its own PR (#1270)

The six fixes left here are independent error-message changes that needed no
corrections. The dub concurrency change needed five rounds, each finding
something real in work that was already reviewed, tested and CI-green — the
last of them being that the fix did not fix the reported case at all.

Riding a release on that record is a bad trade, so it ships separately as
#1270. This branch keeps #1262, #1256, #1251, #1247, #1257 and #1254; the
KeyError message half goes with the dub PR, since it is that issue's other
half.
2026-07-26 12:39:29 -07:00

755 lines
31 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.
import asyncio
import errno
import logging
import os
import shutil
import subprocess
import sys
# Leaf module (stdlib-only) — safe to import at module top, unlike
# services.dub_pipeline which imports this module and would cycle.
from services.proc_registry import register_proc, unregister_proc
logger = logging.getLogger("omnivoice.api")
# Cap concurrent ffmpeg jobs so macOS posix_spawn can't hit EAGAIN under load.
_FFMPEG_SEMAPHORE: "asyncio.Semaphore | None" = None
_FFMPEG_CONCURRENCY = 2
# ── Background-bed mixing (dub voice over the separated no_vocals stem) ──────
#
# Every dub export mixes the synthesized voice track over the original video's
# separated background (music/ambience). Two fidelity bugs lived in the old
# per-site `amix` strings, and they are exactly what "the background music
# doesn't sound like the original" reports describe:
#
# 1. LEVEL — `amix` NORMALIZES: each input is scaled by weight/sum(weights).
# The old `weights=0.8 1.2` therefore played the music bed at 40% of its
# original level (−8 dB) and the voice at 60%. (batch.py was worse still:
# an explicit volume=0.15 plus amix's ÷2 left the bed at 7.5%.) We keep
# amix for its duration/dropout semantics but multiply the mix by
# sum(weights) afterwards, which cancels the normalization exactly — the
# weights below ARE the absolute gains.
# 2. BANDWIDTH — the voice track is synthesized at 24 kHz and amix
# negotiates one common rate, so the 44.1/48 kHz bed was silently
# downsampled to 24 kHz: everything above 12 kHz (cymbals, air,
# brightness) vanished from the music. Both inputs are now explicitly
# resampled to 48 kHz before the mix, so the bed keeps its top end.
#
# Bed at −0.9 dB (0.9×) keeps the music essentially at the original level
# while letting dialogue sit just above it; the limiter transparently catches
# the rare summed peak that now can exceed full scale (the old normalization
# made clipping impossible by making everything quiet).
BED_MIX_SAMPLE_RATE = 48000
BED_GAIN = 0.9
VOICE_GAIN = 1.1
# Whether the resolved ffmpeg's amix supports `normalize` (added in 5.x).
# Probed once per process; None = not probed yet.
_AMIX_NORMALIZE: "bool | None" = None
def _amix_supports_normalize() -> bool:
"""True when the resolved ffmpeg's ``amix`` accepts ``normalize=0``.
Matters because amix's normalization is DYNAMIC: it rescales whenever an
input ends. A constant post-mix compensation is therefore only exact while
both streams are active — after the (usually marginally shorter) voice
stream ends, the bed's internal scale jumps from w/sum to 1.0 and a fixed
multiply would BOOST the tail music into the limiter. ``normalize=0``
turns amix into a plain sum, immune to stream-end rescaling. Old system
ffmpegs (<5) lack the option and would reject the whole graph, so probe
once and fall back to the compensated form there (its tail quirk is the
lesser evil next to a failed export).
"""
global _AMIX_NORMALIZE
if _AMIX_NORMALIZE is None:
supported = False
try:
ff = find_ffmpeg()
if ff:
res = subprocess.run(
[ff, "-hide_banner", "-h", "filter=amix"],
capture_output=True, timeout=10, check=False,
)
supported = b"normalize" in (res.stdout or b"")
except Exception as e: # noqa: BLE001 — a probe failure must not break exports
logger.debug("amix normalize probe failed: %s", e)
_AMIX_NORMALIZE = supported
return _AMIX_NORMALIZE
def bed_mix_filter(
bed_in: str,
voice_in: str,
*,
out: str = "aout",
duration: str = "longest",
tail: str = "",
uniq: str = "",
) -> str:
"""One ffmpeg filter chain mixing `voice_in` over `bed_in` at original level.
`bed_in`/`voice_in` are filtergraph input labels ("0:a", "1:a", …); `out`
is the output label (without brackets). `tail` appends extra filters after
the gain stage (e.g. ",apad=whole_dur=…"). `uniq` disambiguates internal
labels when several chains share one filtergraph.
"""
b, v = f"bmb{uniq}", f"bmv{uniq}"
# Both legs are forced to STEREO before amix. The synthesized voice is
# mono, and amix negotiates one common layout for all inputs — without
# this, the negotiation collapsed the stereo music bed to mono (measured
# on a real dub: L/R correlation 1.000 vs the original's 0.754 — the
# entire stereo image gone). Upmixing the mono voice duplicates it into
# both channels (dead center, where dubbed dialogue belongs) so the bed
# keeps its width.
stereo = "aformat=channel_layouts=stereo"
if _amix_supports_normalize():
# Gains applied per input, amix reduced to a plain sum: levels are
# exact for the whole timeline, including after either stream ends.
return (
f"[{bed_in}]aresample={BED_MIX_SAMPLE_RATE},{stereo},volume={BED_GAIN:g}[{b}];"
f"[{voice_in}]aresample={BED_MIX_SAMPLE_RATE},{stereo},volume={VOICE_GAIN:g}[{v}];"
f"[{b}][{v}]amix=inputs=2:duration={duration}:dropout_transition=2:"
f"normalize=0,alimiter=level=false:limit=0.98{tail}[{out}]"
)
# Legacy ffmpeg (<5, no `normalize`): cancel amix's normalization with a
# compensating multiply. Exact while both streams run; if one ends early
# the tail is over-boosted into the limiter until the graph ends — a known
# quirk accepted only on old ffmpeg, where the alternative is no export.
total = BED_GAIN + VOICE_GAIN
return (
f"[{bed_in}]aresample={BED_MIX_SAMPLE_RATE},{stereo}[{b}];"
f"[{voice_in}]aresample={BED_MIX_SAMPLE_RATE},{stereo}[{v}];"
f"[{b}][{v}]amix=inputs=2:duration={duration}:dropout_transition=2:"
f"weights={BED_GAIN:g} {VOICE_GAIN:g},volume={total:g},"
f"alimiter=level=false:limit=0.98{tail}[{out}]"
)
def _get_semaphore() -> asyncio.Semaphore:
global _FFMPEG_SEMAPHORE
if _FFMPEG_SEMAPHORE is None:
_FFMPEG_SEMAPHORE = asyncio.Semaphore(_FFMPEG_CONCURRENCY)
return _FFMPEG_SEMAPHORE
def windows_tool_candidates(tool: str) -> "list[str]":
"""Well-known Windows install locations for *tool* (ffmpeg/ffprobe).
Derived from the environment instead of hardcoding ``C:\\`` so machines
whose Windows/Program Files live on another drive still resolve (the
non-system-drive class): ``%ProgramFiles%``/``%ProgramW6432%`` for the
relocatable Program Files, ``%SystemDrive%``+D: for the conventional
``<drive>:\\ffmpeg\\bin`` layout. Empty on non-Windows."""
if os.name != "nt":
return []
out: list[str] = []
drives = {os.environ.get("SystemDrive", "C:"), "C:", "D:"}
for drive in sorted(drives):
out.append(f"{drive}\\ffmpeg\\bin\\{tool}.exe")
pf_dirs = {
os.environ.get("ProgramFiles", "C:\\Program Files"),
os.environ.get("ProgramW6432", "C:\\Program Files"),
}
for pf in sorted(pf_dirs):
out.append(os.path.join(pf, "ffmpeg", "bin", f"{tool}.exe"))
return out
# Candidate paths that exist but won't run (validated once per process).
# Windows users hit this as `[WinError 193] %1 is not a valid Win32
# application` (#360/#361/#362): a corrupt/wrong-arch imageio-ffmpeg
# download or a WindowsApps alias stub passes `os.path.isfile` / `which`
# but explodes at spawn. Probe each candidate with `-version` and fall
# through to the next source instead of returning a time bomb.
_BINARY_OK: dict[str, bool] = {}
def _binary_runs(path: str) -> bool:
cached = _BINARY_OK.get(path)
if cached is not None:
return cached
try:
subprocess.run(
[path, "-version"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=10, check=False,
)
ok = True
except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
logger.warning(
"Rejecting non-runnable ffmpeg/ffprobe candidate %s: %s",
os.path.basename(str(path)), e,
)
ok = False
_BINARY_OK[path] = ok
return ok
def find_ffmpeg():
"""Locate an ffmpeg binary.
Resolution order:
1. ``FFMPEG_PATH`` env var (set by Tauri when a sidecar is bundled, or
by the user's Settings → Audio tools override via prefs).
2. ``imageio-ffmpeg`` pip package (ships a static binary per platform).
3. OmniVoice-acquired static bundle (``services.media_tools``) — the
checksummed build the app downloads itself when nothing else
resolves; the only bundled tier that also ships ffprobe.
4. Common system paths / ``PATH``.
Returns the path string, or ``None`` if nothing found.
"""
# 1. Env var injected by Tauri host
env_path = os.environ.get("FFMPEG_PATH")
if env_path:
resolved = shutil.which(env_path)
if resolved and _binary_runs(resolved):
return resolved
# 2. imageio-ffmpeg bundled static binary
try:
import imageio_ffmpeg
candidate = imageio_ffmpeg.get_ffmpeg_exe()
if candidate and os.path.isfile(candidate) and _binary_runs(candidate):
return candidate
logger.debug("imageio_ffmpeg binary not usable at %s", candidate)
except Exception as e:
logger.debug("imageio_ffmpeg unavailable: %s", e)
# 3. OmniVoice-acquired bundled static binary (never downloads here —
# acquisition is media_tools' background job; this only picks up an
# already-installed build).
candidate = _acquired_bundled("ffmpeg")
if candidate:
return candidate
# 4. Well-known system paths + PATH lookup
common = [
"/opt/homebrew/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
*windows_tool_candidates("ffmpeg"),
"ffmpeg",
]
for path in common:
resolved = shutil.which(path)
if resolved and _binary_runs(resolved):
return resolved
logger.warning("ffmpeg not found (or not runnable) in env, imageio, or system PATH")
return None
def _acquired_bundled(tool: str) -> "str | None":
"""Already-acquired media_tools static binary, validated — or None.
Lazy import: media_tools imports from this module at its top, so this
module must only reach back at call time (no cycle).
"""
try:
from services.media_tools import bundled_tool_path
candidate = bundled_tool_path(tool)
if candidate and _binary_runs(candidate):
return candidate
except Exception as e:
logger.debug("media_tools bundled %s unavailable: %s", tool, e)
return None
def resolve_ffprobe() -> str | None:
"""Resolve an ffprobe binary path.
Resolution order (per issue #76 and 01-03-PLAN.md must_haves):
1. ``OMNIVOICE_FFPROBE_PATH`` env var — the canonical, namespaced path
injected by Tauri pointing at the bundled sidecar (e.g.
``/usr/lib/omnivoice-studio/bin/ffprobe`` on .deb installs).
2. ``FFPROBE_PATH`` env var — legacy alias kept for backward
compatibility with older Tauri shells / dev environments; also the
key Settings → Audio tools persists a user override under.
3. OmniVoice-acquired static bundle (``services.media_tools``) —
imageio-ffmpeg ships no ffprobe, so this is the bundled tier that
closes the source-install gap.
4. ``shutil.which("ffprobe")`` — system ``PATH`` fallback.
Returns the resolved path string, or ``None`` if nothing found. Callers
that need a hard failure should use :func:`find_ffprobe` instead.
"""
for env_key in ("OMNIVOICE_FFPROBE_PATH", "FFPROBE_PATH"):
path = os.environ.get(env_key)
if not path:
continue
# The env var may carry either an absolute path to a file OR a bare
# command name (legacy). Accept both shapes — file first.
if os.path.isfile(path) and _binary_runs(path):
return path
resolved = shutil.which(path)
if resolved and _binary_runs(resolved):
return resolved
bundled = _acquired_bundled("ffprobe")
if bundled:
return bundled
system_probe = shutil.which("ffprobe")
if system_probe and _binary_runs(system_probe):
return system_probe
return None
def find_ffprobe():
"""Locate an ffprobe binary (legacy wrapper around :func:`resolve_ffprobe`).
Falls back to deriving the path from ``find_ffmpeg()`` so the
co-located ffprobe in an ffmpeg-bundle download (e.g. BtbN, evermeet.cx)
is still picked up when only ffmpeg has been resolved.
"""
resolved = resolve_ffprobe()
if resolved:
return resolved
try:
ffmpeg_path = find_ffmpeg()
if ffmpeg_path:
candidate = ffmpeg_path.replace("ffmpeg", "ffprobe")
if os.path.isfile(candidate):
return candidate
except Exception:
pass
return None
def ensure_media_tools_on_path() -> list[str]:
"""Put the resolved ffmpeg/ffprobe on ``PATH`` for third-party code (#1256).
OmniVoice's own call sites always resolve an explicit path, so a bundled
sidecar that was never on ``PATH`` works fine for us. Our dependencies do
not get that courtesy: a library that shells out to ``ffprobe`` by bare
name dies with ``FileNotFoundError: [Errno 2] No such file or directory:
'ffprobe'``. The reporter of #1256 hit that mid-synthesis and was told the
engine had "stopped with an error OmniVoice doesn't recognize", on a Mac
where the app's OWN ffprobe was sitting on disk, resolvable, the whole
time.
Prepending the resolved binaries' directories fixes every such dependency
at once, rather than chasing them one import at a time. Prepended (not
appended) so the copy we validated wins over a broken system one.
Returns the directories added. Idempotent, best-effort, never raises.
"""
added: list[str] = []
try:
directories: list[str] = []
for resolve in (find_ffmpeg, find_ffprobe):
try:
path = resolve()
except Exception:
continue
if not path:
continue
directory = os.path.dirname(os.path.abspath(path))
if directory and directory not in directories:
directories.append(directory)
current = os.environ.get("PATH", "")
entries = current.split(os.pathsep) if current else []
# Case-insensitive comparison on Windows/macOS, where PATH is not
# case-sensitive and "already present" must not depend on casing.
normalize = os.path.normcase
present = {normalize(e) for e in entries if e}
for directory in directories:
if normalize(directory) in present:
continue
entries.insert(0, directory)
present.add(normalize(directory))
added.append(directory)
if added:
os.environ["PATH"] = os.pathsep.join(entries)
# Count, not paths: a user-set FFMPEG_PATH resolves under their home
# directory, and absolute home paths must not reach the log
# (#1256 review). find_ffmpeg/find_ffprobe already log their own
# resolution at debug level when that detail is wanted.
logger.info(
"Published %d media-tool director%s on PATH so dependencies can "
"find ffmpeg/ffprobe (#1256)",
len(added), "y" if len(added) == 1 else "ies",
)
except Exception as e: # diagnosis must never break the thing it helps
logger.debug("ensure_media_tools_on_path failed (non-fatal): %s", e)
return added
async def _spawn_async(cmd, **kwargs):
"""Try asyncio subprocess; fall back to thread-based subprocess on Windows
where ProactorEventLoop may not be available (e.g. under uvicorn --reload)."""
try:
return await asyncio.create_subprocess_exec(*cmd, **kwargs)
except NotImplementedError:
logger.debug("asyncio subprocess not supported, falling back to thread-based subprocess")
return await _spawn_thread_fallback(cmd, **kwargs)
async def _spawn_thread_fallback(cmd, **kwargs):
"""Run a subprocess synchronously in a thread via subprocess.Popen."""
stdout = kwargs.pop("stdout", asyncio.subprocess.PIPE)
stderr = kwargs.pop("stderr", asyncio.subprocess.PIPE)
stdin = kwargs.pop("stdin", None)
loop = asyncio.get_running_loop()
def _run():
return subprocess.Popen(
cmd,
stdout=subprocess.PIPE if stdout == asyncio.subprocess.PIPE else stdout,
stderr=subprocess.PIPE if stderr == asyncio.subprocess.PIPE else stderr,
stdin=subprocess.PIPE if stdin == asyncio.subprocess.PIPE else stdin,
**kwargs, # forward cwd / env / etc. so the fallback matches the async call
)
proc = await loop.run_in_executor(None, _run)
# Wrap the Popen process to match asyncio.subprocess.Process interface
class _AsyncCompatProc:
def __init__(self, popen):
self._popen = popen
self.returncode = popen.returncode
self.stdin = popen.stdin
self.stdout = popen.stdout
self.stderr = popen.stderr
self.pid = popen.pid
# These are plain SYNC pipes (io.BufferedReader), NOT asyncio
# StreamReaders — so callers must not `await proc.stderr.read()` on
# this wrapper. `communicate()`/`wait()` below are the only async
# entry points. run_proc_streaming_stderr checks this flag and
# degrades to communicate() on the fallback loop instead of awaiting
# the sync pipe (which raised "a coroutine or an awaitable is
# required" and crashed the demucs step under uvicorn --reload).
self.uses_sync_pipes = True
async def communicate(self, input=None):
out, err = await loop.run_in_executor(None, self._popen.communicate, input)
self.returncode = self._popen.returncode
return out, err
async def wait(self):
return await loop.run_in_executor(None, self._popen.wait)
def kill(self):
self._popen.kill()
def terminate(self):
self._popen.terminate()
return _AsyncCompatProc(proc)
async def spawn_subprocess(*args, **kwargs):
"""Drop-in replacement for ``asyncio.create_subprocess_exec``.
Falls back to a thread-based ``subprocess.Popen`` (wrapped to match the
asyncio Process interface) on event loops without subprocess support —
notably the Windows ``SelectorEventLoop`` that uvicorn forces under
``--reload``/multi-worker (``use_subprocess=True``), where the native call
raises ``NotImplementedError`` (GH #122). Also inherits the EAGAIN retry.
On loops that DO support subprocesses (Proactor, posix) the native path is
used unchanged, so there is no behavior change off the broken loop.
"""
return await _spawn_with_retry(list(args), **kwargs)
async def _spawn_with_retry(cmd, **kwargs):
"""Spawn a subprocess, retrying briefly on EAGAIN (posix_spawn resource pressure)."""
delay = 0.1
last_err = None
for _ in range(5):
try:
return await _spawn_async(cmd, **kwargs)
except BlockingIOError as e:
last_err = e
if e.errno != errno.EAGAIN:
raise
await asyncio.sleep(delay)
delay *= 2
except OSError as e:
if e.errno == errno.EAGAIN:
last_err = e
await asyncio.sleep(delay)
delay *= 2
continue
raise
except Exception:
raise
raise last_err if last_err else RuntimeError("spawn failed")
def _atempo_chain(ratio: float) -> str:
"""Build an `atempo=…,atempo=…` filter chain for arbitrary ratios.
ffmpeg's atempo filter is limited to [0.5, 2.0] per stage. Chaining
multiple stages multiplies the effective ratio while keeping each
individual stage inside the well-behaved range. Pitch is preserved
(WSOLA-style time-domain stretching). ratio > 1 speeds up, < 1
slows down.
"""
stages: list[str] = []
remaining = ratio
while remaining > 2.0:
stages.append("atempo=2.0")
remaining /= 2.0
while remaining < 0.5:
stages.append("atempo=0.5")
remaining /= 0.5
stages.append(f"atempo={remaining:.6f}")
return ",".join(stages)
async def _pitch_preserving_stretch(wav, target_samples: int, sr: int):
"""Time-stretch a (1, samples) tensor to `target_samples` while
preserving pitch, by piping the audio through `ffmpeg atempo`.
Async so it never blocks the event loop: it's awaited from the dub
generate `_stream` generator, and each ffmpeg call is ~50-100 ms — a
synchronous ``subprocess.run`` here froze health-checks / SSE / every
concurrent request for the whole multi-segment job.
Returns a (1, target_samples) tensor on the same device as input.
Raises RuntimeError when ffmpeg fails — callers should fall back to
naive linear interpolation, accepting the pitch shift, to ensure the
output isn't silent.
"""
# Lazy imports keep this module importable in torch-free contexts
# (setup scripts, smoke probes) — only the stretch path needs them.
import numpy as np
import torch
wl = int(wav.shape[-1])
if target_samples <= 0 or wl == target_samples:
return wav
ratio = wl / target_samples
filter_str = _atempo_chain(ratio)
# Mono float32 via stdin → ffmpeg → stdout. One subprocess per segment,
# run off the event loop so concurrent requests stay responsive.
arr = wav.detach().cpu().to(torch.float32).numpy().reshape(-1).astype(np.float32, copy=False)
proc = await spawn_subprocess(
find_ffmpeg(), "-hide_banner", "-loglevel", "error", "-y",
"-f", "f32le", "-ar", str(sr), "-ac", "1", "-i", "pipe:0",
"-af", filter_str,
"-f", "f32le", "-ar", str(sr), "-ac", "1", "pipe:1",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate(input=arr.tobytes())
if proc.returncode != 0 or not stdout:
raise RuntimeError(
(stderr.decode(errors="replace") or "atempo failed")[:200]
)
out_arr = np.frombuffer(stdout, dtype=np.float32)
# atempo rarely lands exactly on the integer sample count, so
# pad/trim to the requested slot length.
if len(out_arr) < target_samples:
pad = np.zeros(target_samples - len(out_arr), dtype=np.float32)
out_arr = np.concatenate([out_arr, pad])
elif len(out_arr) > target_samples:
out_arr = out_arr[:target_samples]
return torch.from_numpy(out_arr.copy()).unsqueeze(0).to(wav.device)
async def probe_duration(path: str) -> float | None:
"""Return a media file's duration in seconds via ffprobe, or None.
Used by the Smart Fit pipeline to sanity-check source/track lengths
without loading the media. Never raises — probing is best-effort.
"""
ffprobe = find_ffprobe()
if not ffprobe or not os.path.isfile(path):
return None
try:
proc = await spawn_subprocess(
ffprobe, "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0:
return None
return float(stdout.decode().strip())
except Exception as e:
logger.debug("probe_duration failed for %s: %s", os.path.basename(str(path)), e)
return None
async def probe_frame_rates(path: str) -> "tuple[str, str] | None":
"""Return (r_frame_rate, avg_frame_rate) strings for the first video
stream (e.g. ``("30000/1001", "2997/100")``), or None on any failure.
A mismatch between the two is the practical VFR signature — used by the
Smart Fit retime pipeline to decide whether to normalise with ``fps=``
before trim/setpts. Never raises — probing is best-effort.
"""
ffprobe = find_ffprobe()
if not ffprobe or not os.path.isfile(path):
return None
try:
proc = await spawn_subprocess(
ffprobe, "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=r_frame_rate,avg_frame_rate",
"-of", "csv=p=0",
path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0:
return None
parts = stdout.decode().strip().split(",")
if len(parts) < 2:
return None
return parts[0].strip(), parts[1].strip()
except Exception as e:
logger.debug("probe_frame_rates failed for %s: %s", os.path.basename(str(path)), e)
return None
# Windows CreateProcess rejects command lines over 32,767 chars with
# `[WinError 206] The filename or extension is too long`. The dub-export mux
# argv scales with track/segment count (per-track -i/-map/-metadata plus the
# bed-mix/apad -filter_complex graph), so a big multi-language export can hit
# it (#1152). Externalize below this threshold — comfortably under the hard
# limit so the remaining argv always fits.
_WIN_ARGV_SOFT_LIMIT = 30_000
def externalize_long_filter_complex(cmd, limit=_WIN_ARGV_SOFT_LIMIT, tmp_dir=None):
"""If ``cmd``'s total length exceeds ``limit`` and it carries a
-filter_complex graph, move the graph into a temp file and switch the
flag to -filter_complex_script (identical semantics, reads the graph
from a file). Returns ``(cmd, script_path)`` — script_path is None when
nothing changed; the caller deletes it after the run (#1152).
"""
total = sum(len(str(a)) + 1 for a in cmd)
if total <= limit or "-filter_complex" not in cmd:
return cmd, None
idx = cmd.index("-filter_complex")
if idx + 1 >= len(cmd):
return cmd, None
import tempfile
fd, script_path = tempfile.mkstemp(
suffix=".ffgraph", prefix="omnivoice_filter_", dir=tmp_dir
)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(str(cmd[idx + 1]))
out = list(cmd)
out[idx : idx + 2] = ["-filter_complex_script", script_path]
logger.info(
"ffmpeg argv was %d chars — moved the %d-char filter graph to %s "
"to stay under the Windows command-line limit (#1152)",
total, len(str(cmd[idx + 1])), script_path,
)
return out, script_path
def explain_ffmpeg_failure(e, what, cmd=None):
"""Turn an export-time ffmpeg failure into an honest, actionable message.
#1152: a spawn-time `[WinError 206]` used to be concatenated with
"Verify ffmpeg is installed…" — the user was told their (short) filename
was too long AND that a working ffmpeg might be missing. Distinguish the
three real failure modes; never give one mode another mode's advice.
"""
if isinstance(e, OSError):
too_long = (
getattr(e, "winerror", None) == 206
or e.errno in (errno.ENAMETOOLONG, getattr(errno, "E2BIG", None))
or "too long" in str(e).lower()
)
if too_long:
size = f" ({sum(len(str(a)) + 1 for a in cmd)} chars)" if cmd else ""
return (
f"Couldn't {what}: the assembled ffmpeg command line{size} exceeded the "
"Windows 32,767-character limit — this happens on exports "
"with very many tracks/segments, not because of your file's name. "
"Try exporting fewer languages per file, and please report this with "
"the backend log so we can shrink the command further."
)
return (
f"Couldn't {what}: ffmpeg could not be launched ({e}). Verify ffmpeg is "
"installed and runnable (`ffmpeg -version`), or set FFMPEG_PATH to a "
"working binary."
)
return f"Couldn't {what}: ffmpeg reported an error: {e}"
async def run_ffmpeg(cmd, timeout: float = 1800.0, capture: bool = True,
job_id: "str | None" = None):
"""Run an ffmpeg subprocess with concurrency cap, timeout, and proper cleanup.
Returns (returncode, stdout_bytes, stderr_bytes). Raises asyncio.TimeoutError
on hard timeout (after killing + reaping the process).
``job_id`` (optional) registers the process with the dub pipeline's
process tracker (``services.proc_registry``) so ``/dub/abort`` can kill
long export encodes (used by the Smart Fit batched retime).
Path-injection note: every filesystem path placed in ``cmd`` by callers
is realpath-normalised and containment-checked against its workspace
root (e.g. DUB_DIR) at the call site before the argv is assembled —
see api.routers.dub_export and services.video_retime.
"""
stdout = asyncio.subprocess.PIPE if capture else asyncio.subprocess.DEVNULL
stderr = asyncio.subprocess.PIPE
# #1152: on Windows an oversized argv (multi-track mux filter graphs)
# fails CreateProcess with WinError 206 before ffmpeg even starts —
# move a long -filter_complex into a script file first.
script_path = None
if sys.platform == "win32":
cmd, script_path = externalize_long_filter_complex(cmd)
try:
async with _get_semaphore():
proc = await _spawn_with_retry(cmd, stdout=stdout, stderr=stderr)
if job_id:
try:
register_proc(job_id, proc)
except Exception as e:
# Newline-strip the id inline — it can originate from a path
# param, and the log stream must stay one-event-per-line.
logger.debug("register_proc failed for %s: %s",
job_id.replace("\n", " ").replace("\r", " "), e)
try:
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
pass
raise
return proc.returncode, out, err
finally:
if job_id:
try:
unregister_proc(job_id, proc)
except Exception as e:
logger.debug("unregister_proc failed for %s: %s",
job_id.replace("\n", " ").replace("\r", " "), e)
# Guarantee reaping — prevents zombie pileup under timeouts or errors.
if proc.returncode is None:
try:
proc.kill()
except ProcessLookupError:
pass
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
pass
finally:
if script_path:
try:
os.remove(script_path)
except OSError:
pass