* 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.
755 lines
31 KiB
Python
755 lines
31 KiB
Python
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
|