Files
VoiceStudio/backend/api/routers/generation.py
T

2626 lines
126 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 os
import io
import re
import uuid
import time
import random
import asyncio
import tempfile
import contextlib
import logging
import threading
import traceback
from typing import Optional
from fastapi import APIRouter, File, Form, UploadFile, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import sqlite3
from core.db import db_conn, ensure_schema
from core.config import OUTPUTS_DIR, VOICES_DIR
import functools
from services.model_manager import (
get_model, _gpu_pool, run_on_gpu_pool_guarded, GpuJobTimeoutError,
GpuPoolBusyError,
)
from services.audio_io import _safe_torchaudio_save
from services.binary_preflight import InvalidBinaryError
from core import event_bus
from core.logging_utils import log_safe
from omnivoice.utils.voice_design import heal_design_instruct
router = APIRouter()
logger = logging.getLogger("omnivoice.generate")
class _TempReferenceLease:
"""Delete a request-owned reference once every abandoned reader drains."""
def __init__(self, path: str):
self.path = path
self._lock = threading.Lock()
self._active = 0
self._request_done = False
self._deleted = False
def acquire(self):
with self._lock:
if self._request_done:
raise RuntimeError("reference lease acquired after request cleanup")
self._active += 1
once_lock = threading.Lock()
released = False
def release() -> None:
nonlocal released
with once_lock:
if released:
return
released = True
self._release()
return release
def _release(self) -> None:
delete = False
with self._lock:
self._active -= 1
if self._active < 0:
raise RuntimeError("reference lease released too many times")
if self._request_done and self._active == 0 and not self._deleted:
self._deleted = True
delete = True
if delete:
with contextlib.suppress(OSError):
os.remove(self.path)
def finish_request(self) -> None:
delete = False
with self._lock:
self._request_done = True
if self._active == 0 and not self._deleted:
self._deleted = True
delete = True
if delete:
with contextlib.suppress(OSError):
os.remove(self.path)
async def _run_with_reference_lease(lease, factory):
"""Hold an ad-hoc reference through one local GPU-pool dispatch."""
if lease is None:
return await factory(None)
release = lease.acquire()
abandoned = False
try:
return await factory(release)
except GpuPoolBusyError:
# Busy means no job started; release now. The callback may already have
# done so, and the lease token is deliberately idempotent.
release()
abandoned = True
raise
except (asyncio.CancelledError, GpuJobTimeoutError):
# The guard owns release now: immediately for a queued cancellation,
# or from the worker finalizer after an in-flight job drains.
abandoned = True
raise
finally:
if not abandoned:
release()
def _profile_instruct(row):
"""Validator-safe instruct for a stored profile row.
Sanitizes the persisted instruct (dropping the ``"[object Object]"``
sentinel / freeform prose that older builds saved) and, for a design row,
rebuilds the tags from ``vd_states`` when the stored value is unusable — so
a poisoned/legacy profile never 400-s generation (#550 #571 #594 #596).
"""
try:
vd = row["vd_states"]
except (KeyError, IndexError):
vd = None
return heal_design_instruct(row["instruct"], vd)
def _resolve_profile_conditioning(row, *, ref_text=None, instruct=None,
seed=None, language=None):
"""Resolve a ``voice_profiles`` row into generation conditioning.
Extracted verbatim from /generate's inline profile-resolution block so
other synthesis routes (POST /convert) share the exact same semantics —
lock wins, ``kind`` is authoritative (0005), legacy pre-0004 rows fall
back to the is_locked/instruct inference, and #533's language fill.
Request-supplied values (``ref_text``/``instruct``/``seed``/``language``)
always win over the stored row; only gaps are filled. Returns a dict with
``ref_audio_path`` / ``ref_text`` / ``instruct`` / ``seed`` / ``language``
/ ``kind`` plus ``persist_ref_text`` — True when the caller should cache
an auto-transcribed reference transcript back onto the row (#1032).
"""
out = {
"ref_audio_path": None, "ref_text": ref_text, "instruct": instruct,
"seed": seed, "language": language, "kind": None,
"persist_ref_text": False,
}
# `kind` is authoritative (0005): 'design' profiles condition on their
# deterministic rendered sample + instruct; 'clone' on the user's
# reference. Lock always wins (it pins a specific take). Rows from
# pre-0004 DBs mid-upgrade may lack the column → fall back to the legacy
# is_locked/instruct inference.
try:
profile_kind = row["kind"] or "clone"
except (KeyError, IndexError):
profile_kind = "design" if (
row["instruct"] and not row["is_locked"] and not row["ref_audio_path"]
) else "clone"
out["kind"] = profile_kind
if row["is_locked"] and row["locked_audio_path"]:
out["ref_audio_path"] = os.path.join(VOICES_DIR, row["locked_audio_path"])
if not out["ref_text"]:
out["ref_text"] = row["ref_text"]
if not out["instruct"]:
out["instruct"] = _profile_instruct(row)
if out["seed"] is None and row["seed"] is not None:
out["seed"] = row["seed"]
elif profile_kind == "design":
# Rendered sample (if present) carries the voice identity; instruct
# alone is the fallback for legacy archetype rows.
out["ref_audio_path"] = (
os.path.join(VOICES_DIR, row["ref_audio_path"]) if row["ref_audio_path"] else None
)
if out["ref_audio_path"] and not out["ref_text"] and row["ref_text"]:
out["ref_text"] = row["ref_text"]
if not out["instruct"]:
out["instruct"] = _profile_instruct(row)
if out["seed"] is None and row["seed"] is not None:
out["seed"] = row["seed"]
elif row["instruct"] and not row["is_locked"] and not row["ref_audio_path"]:
# Legacy design-shaped row (pre-0004 archetype materialization failure
# path): instruct-only conditioning.
if not out["instruct"]:
out["instruct"] = _profile_instruct(row)
if out["seed"] is None and row["seed"] is not None:
out["seed"] = row["seed"]
else:
out["ref_audio_path"] = (
os.path.join(VOICES_DIR, row["ref_audio_path"]) if row["ref_audio_path"] else None
)
if not out["ref_text"] and row["ref_text"]:
out["ref_text"] = row["ref_text"]
elif out["ref_audio_path"] and not out["ref_text"]:
# Empty stored transcript → the caller's auto-transcribe will run;
# cache its result onto the profile so it runs ONCE, not on every
# generate (#1032 perf regression).
out["persist_ref_text"] = True
if not out["instruct"] and row["instruct"]:
out["instruct"] = row["instruct"]
if out["seed"] is None and row["seed"] is not None:
out["seed"] = row["seed"]
if out["language"] == "Auto":
out["language"] = None
# #533: a profile's stored language must drive generation when the request
# didn't pin one. An EXPLICIT non-Auto request language still wins; we
# only fill the gap. `row` is a sqlite3.Row, so guard the column lookup
# for pre-language DBs mid-upgrade.
if out["language"] is None:
try:
prof_lang = row["language"]
except (KeyError, IndexError):
prof_lang = None
if prof_lang and prof_lang != "Auto":
out["language"] = prof_lang
return out
def _note_generate_progress() -> None:
"""Tell the pool guard this render just finished a unit of work (#1391).
Every multi-part render calls this after each part. A job that keeps
completing chunks is working, however slowly, and must not be abandoned as
"too heavy for the available compute" the way #1338/#1348/#1391 were —
while a job that produces nothing for a whole base budget still dies on
time. Never raises: a liveness signal that can break a render is worse
than no signal.
"""
try:
from services.model_manager import report_generate_progress
report_generate_progress()
except Exception: # noqa: BLE001 — diagnostics must not break synthesis
pass
def _render_with_pauses(gen_span, segments, sample_rate):
"""Synthesize ``[(text, pause_ms), ...]`` spans and stitch silence between
them (issue #276).
``gen_span(text) -> torch.Tensor`` synthesizes one text span (raw model
output). A silence buffer of ``pause_ms`` is inserted after a span when
requested, matching the audio tensor's channel dims / dtype / device.
Returns the concatenated waveform. Kept model-free (``gen_span`` is injected)
so the stitching is unit-testable without loading the TTS model.
"""
import torch
items = [] # ('a', tensor) for audio, ('s', n_samples) for silence
for span_text, pause_ms in segments:
if span_text and span_text.strip():
items.append(("a", gen_span(span_text)))
_note_generate_progress()
if pause_ms > 0:
n = int(round(sample_rate * pause_ms / 1000.0))
if n > 0:
items.append(("s", n))
ref = next((t for kind, t in items if kind == "a"), None)
if ref is None:
# No speakable text (e.g. the input was only pause markers) — emit the
# requested silence so the caller still gets a valid clip.
total = sum(n for kind, n in items if kind == "s") or 1
return torch.zeros(total, dtype=torch.float32)
parts = []
for kind, val in items:
if kind == "a":
parts.append(val)
else:
shape = list(ref.shape)
shape[-1] = val
parts.append(torch.zeros(*shape, dtype=ref.dtype, device=ref.device))
return torch.cat(parts, dim=-1)
def _sanitize_audio(audio_out):
"""Replace non-finite samples (NaN / ±inf) with silence so a model glitch
can't produce an unreadable WAV (#629). Returns the input unchanged when it's
already finite or isn't a tensor. Never raises."""
try:
import torch
if torch.is_tensor(audio_out) and not bool(torch.isfinite(audio_out).all()):
logger.warning(
"Generated audio contained non-finite samples (NaN/inf) — "
"sanitizing to silence to keep the WAV decodable (#629)."
)
return torch.nan_to_num(audio_out, nan=0.0, posinf=0.0, neginf=0.0)
except Exception as exc:
logger.warning("Generated audio validation failed")
raise RuntimeError(
"Generated audio could not be validated. Retry the generation."
) from exc
return audio_out
def _apply_effect_chain(audio_out, sample_rate, effect_preset, *, skip_mastering=False):
"""Shared post-DSP for /generate: preset validation → mastering →
effect chain → loudness normalization.
``skip_mastering`` honors a backend's ``applies_own_mastering`` flag
(issue #312): studio engines (e.g. VoxCPM2's native 48 kHz output)
opt out of the broadcast highpass + Compressor pre-stage that's tuned
for VoiceStudio's 24 kHz clone output. Loudness normalization still runs —
it's a benign peak scale. Mirrors ``_run_tts`` in openai_compat.py.
"""
from services.audio_dsp import (
EFFECT_PRESETS, apply_mastering, normalize_audio,
apply_effects_chain, get_effect_chain,
)
# #629: a numerical glitch in the model (observed on MPS) can leave NaN/±inf
# samples, which write an unreadable WAV that then fails decoding with an
# opaque "ffmpeg returned error code: 183 / Invalid data" — surfaced to the
# user as a misleading "ran out of memory". Replace non-finite samples with
# silence here, before any DSP/encode touches the audio, so the output is
# always a valid WAV. Covers the raw path too (it returns just below).
audio_out = _sanitize_audio(audio_out)
preset = effect_preset or "broadcast"
if preset not in EFFECT_PRESETS:
raise ValueError(
f"Unknown effect preset: {preset!r}. "
f"Valid: {list(EFFECT_PRESETS.keys())}"
)
if preset == "raw":
# Raw: skip all DSP — return raw model output
return audio_out
if not skip_mastering:
audio_out = apply_mastering(audio_out, sample_rate=sample_rate)
chain = get_effect_chain(preset)
if chain:
audio_out = apply_effects_chain(
audio_out, sample_rate=sample_rate, chain=chain,
)
return normalize_audio(audio_out, target_dBFS=-2.0)
def _safe_exc_text(e: BaseException) -> str:
"""``f"{type(e).__name__}: {e}"`` — the house style used for
unrecognized-error formatting throughout the backend (grep
``type(e).__name__`` in settings.py / asr_backend.py / model_manager.py
/ engines.py) — with a guard against leaking a raw container repr.
#977: an AssertionError raised deep inside a vendored dependency
(mlx-audio's Kokoro pipeline) had ``.args`` shaped like
``('du', {'a': 'American English', ...})`` — a tuple containing a dict.
``str(e)`` on that renders the WHOLE table straight into the user-facing
message. Any engine's ``generate()`` can raise something shaped like
this (not just Kokoro), so guard generically: if any element of
``e.args`` is a container rather than a plain string, don't interpolate
``str(e)`` at all — name the exception type and point at the log
instead.
"""
args = getattr(e, "args", ())
if any(isinstance(a, (dict, list, tuple, set, frozenset)) for a in args):
return f"{type(e).__name__} — see Settings → Logs → Backend for details"
return f"{type(e).__name__}: {e}"
def _exception_chain(e):
"""Yield ``e`` plus every ``__cause__``/``__context__`` beneath it
(cycle-safe). Engines and hub libraries routinely wrap the original
transport/allocator error, so classification must look at the whole
chain, not just the outermost message."""
seen = set()
stack = [e]
while stack:
exc = stack.pop()
if exc is None or id(exc) in seen:
continue
seen.add(id(exc))
yield exc
stack.append(exc.__cause__)
stack.append(exc.__context__)
# #880: transport-level exception type names from httpx (huggingface_hub ≥1.x
# downloads over it) and requests/urllib3 (older engine deps). Any of these
# anywhere in the exception chain means the network — not memory — killed the
# generation.
_NETWORK_EXC_NAMES = frozenset({
# httpx
"ConnectError", "ConnectTimeout", "ReadTimeout", "ReadError",
"WriteError", "WriteTimeout", "PoolTimeout", "NetworkError",
"TransportError", "RemoteProtocolError", "ProxyError", "CloseError",
# requests / urllib3
"ConnectionError", "ChunkedEncodingError", "MaxRetryError",
"NewConnectionError", "ProtocolError",
# stdlib socket-level drops mid-download
"ConnectionResetError", "ConnectionAbortedError", "ConnectionRefusedError",
# huggingface_hub: failed first-use download with nothing in the disk cache
"LocalEntryNotFoundError",
})
# Same class, but the transport error was stringified into a wrapper message
# (so the type name is gone). All lowercase; matched against .lower().
_NETWORK_MSG_SIGNATURES = (
"client has been closed", # httpx closed-client lifecycle error (#880)
"cannot send a request", # httpx: same error, message head
"connection error", # requests / huggingface_hub wording
"connection reset", # ECONNRESET mid-download
"read timed out", # requests/urllib3 timeout wording
"max retries exceeded", # urllib3 retry exhaustion
"temporary failure in name resolution", # DNS down (glibc)
"name or service not known", # DNS down (glibc)
"getaddrinfo failed", # DNS down (Windows)
)
# #1335: a TLS connection cut mid-download. core/failure.py already classifies
# this for the dub/transcribe surfaces (TLS_CONNECTION_DROPPED, #1301), but
# /generate has its own taxonomy and never learned it — so the reporter got a
# bare 500 carrying `_ssl.c:1016`, which means nothing to anyone. It is a
# dropped download, so the network branch is the right owner: retry, not Flush.
#
# Gated on an "ssl" marker rather than matched bare (CodeRabbit): "eof occurred
# in violation of protocol" is OpenSSL's wording, but nothing stops an
# unrelated component from saying something similar, and mislabelling a local
# fault as a network problem sends the user to check their connection for a
# failure that has nothing to do with it. The real message always carries the
# marker: "[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of
# protocol (_ssl.c:1016)".
_TLS_DROP_SIGNATURES = (
"unexpected_eof_while_reading",
"eof occurred in violation of protocol",
)
def _is_network_failure(e) -> bool:
"""True iff the failure (anywhere in its chain) is an HTTP-client
lifecycle / network-transport error — e.g. a first-use model download
from the HF Hub dying mid-generation (#880)."""
for exc in _exception_chain(e):
if type(exc).__name__ in _NETWORK_EXC_NAMES:
return True
low = str(exc).lower()
if any(sig in low for sig in _NETWORK_MSG_SIGNATURES):
return True
if "ssl" in low and any(sig in low for sig in _TLS_DROP_SIGNATURES):
return True
return False
# Signatures of an *actual* out-of-memory condition. All lowercase.
_OOM_MSG_SIGNATURES = (
"out of memory", # CUDA / MPS / generic torch wording
"not enough memory", # torch CPU DefaultCPUAllocator
"cannot allocate memory", # OS-level ENOMEM
"std::bad_alloc", # C++ allocator failure
"cublas_status_alloc_failed", # cuBLAS workspace allocation
"cuda_error_out_of_memory", # raw CUDA driver error name
"paging file is too small", # Windows [WinError 1455] mapping DLLs
)
def _is_oom_failure(e) -> bool:
"""True iff the failure (anywhere in its chain) actually looks like an
out-of-memory condition — the only case where the Flush hint is honest."""
for exc in _exception_chain(e):
if isinstance(exc, MemoryError):
return True
# torch.cuda.OutOfMemoryError subclasses RuntimeError; match by name
# so this needs no torch import (and covers other frameworks' twins).
if type(exc).__name__ == "OutOfMemoryError":
return True
low = str(exc).lower()
if any(sig in low for sig in _OOM_MSG_SIGNATURES):
return True
return False
# #919: an engine that requires a model path / env var which isn't set (or is
# set to a directory missing its model files) fails with a *configuration*
# error, not a runtime one. The reporting user selected sherpa-onnx and hit
# "OMNIVOICE_SHERPA_MODEL not set. Point it to a sherpa-onnx TTS model
# directory …" — a pure setup problem — yet the OOM catch-all told them (on a
# 63 GB-RAM box) to press Flush for memory they never ran out of. Classify the
# whole CLASS of "engine not configured / required env var not set" errors so
# any current or future opt-in engine (sherpa/Confucius4/dots/MOSS …) surfaces
# actionable setup guidance instead of the memory hint. All lowercase; matched
# over the whole exception chain (engines wrap the original error).
_CONFIG_MSG_SIGNATURES = (
"not set. point it to", # sherpa: OMNIVOICE_SHERPA_MODEL not set
"no model.onnx found in", # sherpa: dir set but the model file is missing
"not configured", # generic "engine not configured" wording
"venv not found. set", # confucius4/dots/MOSS dedicated-venv opt-ins
"unavailable: omnivoice_", # is_available() reason wrapped by _ensure_loaded
)
# An OMNIVOICE_* engine env var named alongside "not set" / "point it to" /
# "set omnivoice_…" is the strongest config-missing signal and generalizes to
# any engine gated on such a var (issue #919 class).
_CONFIG_ENV_RE = re.compile(r"omnivoice_[a-z0-9_]+")
def _is_config_failure(e) -> bool:
"""True iff the failure is a *configuration* problem — a required engine
model path / env var that isn't set (or points nowhere) — rather than a
runtime fault. The remedy is to set the value, never to Flush VRAM."""
for exc in _exception_chain(e):
low = str(exc).lower()
if any(sig in low for sig in _CONFIG_MSG_SIGNATURES):
return True
if _CONFIG_ENV_RE.search(low) and (
"not set" in low or "point it to" in low or "set omnivoice_" in low
):
return True
return False
# Exception types that mean "the budget ran out", not "something broke".
# asyncio.TimeoutError is an alias of the builtin on 3.11+, but the engines'
# own wrappers are separate classes, so match by name across the chain.
_TIMEOUT_EXC_NAMES = frozenset({
"TimeoutError", "GpuJobTimeoutError", "FuturesTimeoutError",
})
# Same class, stringified into a wrapper (all lowercase).
_TIMEOUT_MSG_SIGNATURES = (
"timed out",
"timeout expired",
"exceeded its time budget",
)
def _is_timeout_failure(e) -> bool:
"""True iff the generation ran out of *time* rather than failing (#1368).
A bare ``TimeoutError`` used to fall through to the unrecognized-error
catch-all, so the user was told to "retry once and report it with the full
trace" for the one failure mode whose cause is fully known and whose
message is usually EMPTY — ``TimeoutError:`` with nothing after the colon
tells them nothing at all.
Deliberately checked before the OOM branch: a job killed at its deadline is
not an allocation failure, and Flush is the wrong remedy for it.
"""
for exc in _exception_chain(e):
if isinstance(exc, TimeoutError) or type(exc).__name__ in _TIMEOUT_EXC_NAMES:
return True
low = str(exc).lower()
if any(sig in low for sig in _TIMEOUT_MSG_SIGNATURES):
# "read timed out" is a download dying, which _is_network_failure
# owns and explains better; don't steal it.
if "read timed out" in low:
continue
return True
return False
def _is_media_process_launch_failure(exc: BaseException) -> bool:
"""Identify an ffmpeg/ffprobe launch ENOENT without guessing from a file name."""
if not isinstance(exc, FileNotFoundError):
return False
# A regular missing reference/model file may itself be named "ffmpeg".
# Require the innermost raise site to be Python's process launcher so that
# basename collisions keep the normal missing-file diagnosis (#1677).
traceback_cursor = exc.__traceback__
if traceback_cursor is None:
return False
while traceback_cursor.tb_next is not None:
traceback_cursor = traceback_cursor.tb_next
origin_module = traceback_cursor.tb_frame.f_globals.get("__name__", "")
if origin_module != "subprocess" and not origin_module.startswith("asyncio."):
return False
filename = getattr(exc, "filename", None)
if not filename:
return "[winerror 2]" in str(exc).lower()
return os.path.basename(str(filename)).lower() in {
"ffmpeg", "ffmpeg.exe", "ffprobe", "ffprobe.exe",
}
def _oom_friendly_reraise(e):
"""Best-effort cache flush + the user-facing OOM hint shared by both
inference paths."""
import gc
import torch
gc.collect()
if torch.backends.mps.is_available():
torch.mps.empty_cache()
elif torch.cuda.is_available():
torch.cuda.empty_cache()
# #278: don't mislabel a torch.compile/Triton/Inductor crash as an
# out-of-memory condition. (model_manager's generate wrapper already
# retries these eagerly; this only triggers if that retry also died.)
from services.model_manager import _is_compile_runtime_failure
if _is_compile_runtime_failure(e):
raise RuntimeError(
f"TTS engine hit a torch.compile/Triton error (not out of memory). "
f"Disable torch.compile in Settings → Performance, use the Flush "
f"button to reload the model, then regenerate. Underlying error: {e}"
) from e
# #437: a Permission-denied / exec failure (e.g. a bundled engine binary
# that lost its +x bit) is NOT an OOM — don't send the user to the Flush
# button; tell them what's actually wrong.
es = str(e)
# #1677: Windows CreateProcess reports a missing executable as a bare
# ``FileNotFoundError: [WinError 2] ...`` with no filename, while POSIX
# includes the missing ffmpeg/ffprobe name. The bundled-media downloader
# now republishes PATH as soon as it finishes, but a failed/blocked
# download still needs an actionable recovery rather than the unknown-
# error dead end. Keep missing reference/model files on their own path.
for _exc in _exception_chain(e):
if _is_media_process_launch_failure(_exc):
raise RuntimeError(
"A required media program couldn't be launched. Open "
"Settings → Audio tools and use "
"Download/Repair for the media engine, then retry. If Audio "
"tools is already ready, repair the selected TTS engine and "
f"restart VoiceStudio. Underlying error: {_safe_exc_text(_exc)}"
) from e
if isinstance(e, PermissionError) or "Permission denied" in es or "Errno 13" in es:
raise RuntimeError(
f"A required engine binary couldn't be executed (permission denied). "
f"This usually means a bundled binary lost its execute bit — reinstall, "
f"or run `chmod +x` on the engine binary named in the error. "
f"Underlying error: {e}"
) from e
# #629: a decode/ffmpeg failure on the rendered audio is NOT out of memory —
# it's unreadable audio (usually a transient numerical glitch). Say so rather
# than sending the user down the OOM path.
if "ffmpeg returned error" in es or "Decoding failed" in es or "Invalid data found" in es:
raise RuntimeError(
f"The engine produced unreadable audio (a decode step failed) — this is "
f"usually a transient glitch. Use the Flush button to reload the model, "
f"then regenerate. Underlying error: {e}"
) from e
# #664: a bad voice-design instruct (free-form prose, mixed EN/ZH, or
# conflicting tags) raises "Unsupported instruct items …" / "Cannot mix …
# in a single instruct" / "Conflicting instruct items …" from omnivoice's
# _resolve_instruct. That's a USER-INPUT validation error, not an OOM. Match
# on the message signature (NOT the type — a lower layer can wrap the original
# ValueError, which is why the route's `except ValueError` guard misses it)
# and re-raise as a clean ValueError so the route returns a 400 with the
# instruct guidance, instead of a 500 telling the user to Flush for memory
# they never ran out of. (Complements the client-side guard in #658/#612.)
_low = es.lower()
if ("unsupported instruct items" in _low
or "conflicting instruct items" in _low
or "in a single instruct" in _low):
raise ValueError(es) from e
# #705: a corrupt or wrong-architecture native component (a .dll / .pyd / .exe
# — torch, ffmpeg, or a bundled engine binary) fails to load/spawn on Windows
# with "[WinError 193] %1 is not a valid Win32 application". That is NOT OOM,
# and Flush won't help — reinstalling/repairing the component is the real fix.
if "[winerror 193]" in _low or "is not a valid win32 application" in _low:
raise RuntimeError(
f"A native component (a DLL / .pyd / .exe — e.g. torch, ffmpeg, or an "
f"engine binary) is corrupt or built for the wrong architecture "
f"([WinError 193]). Reinstall or repair that component — the Flush "
f"button won't help here. Underlying error: {e}"
) from e
# #1227: Windows Smart App Control / an App Control (WDAC) policy blocked
# a file the engine needs — "[WinError 4551] An Application Control policy
# has blocked this file". WinError 1260 is the same class from the older
# Software Restriction / AppLocker policies. Not OOM, and Flush can't help:
# the OS is refusing to load the binary at all.
if ("[winerror 4551]" in _low or "[winerror 1260]" in _low
or "application control policy" in _low):
raise RuntimeError(
f"Windows blocked a file VoiceStudio needs from running — an "
f"Application Control policy (Smart App Control, WDAC, or "
f"AppLocker) refused to load it. On a personal PC: Windows "
f"Security → App & browser control → Smart App Control → Off "
f"(note Windows only lets you turn it off once — re-enabling "
f"needs a Windows reset), then restart VoiceStudio. On a managed/"
f"work PC ask IT to allow the VoiceStudio install folder. The Flush "
f"button won't help. Underlying error: {e}"
) from e
# #1221: libsndfile/soundfile could not read or write an audio file. Its
# errors are bare ("LibsndfileError: System error.") so they used to fall
# through to the unrecognized catch-all. audio_io._describe_write_failure
# already names the target for the WRITE path; this covers every other
# libsndfile surface (reading a reference clip, a decode) with the causes
# that actually produce an OS-level audio I/O failure.
if "libsndfile" in _low or "writing the audio file failed" in _low:
raise RuntimeError(
f"An audio file couldn't be read or written (libsndfile failed at "
f"the OS level). This is a file/disk problem, not a memory one: "
f"check the drive isn't full, the output and temp folders exist "
f"and are writable, and that antivirus or OneDrive isn't locking "
f"them (add a VoiceStudio exclusion if you use one). If it happens "
f"only with one reference clip, re-import that clip. Underlying "
f"error: {e}"
) from e
# #715: a "[Errno 32] Broken pipe" (BrokenPipeError) surfacing from
# generation is NOT out of memory — it means the backend's stdout/stderr
# pipe to the desktop shell that launched it closed mid-render (an orphaned
# backend whose parent shell exited or relaunched). main.py wraps
# sys.stdout/stderr to swallow EPIPE, but a C-level write inside the native
# engine/torch can still raise one past that guard. Flush won't help —
# relaunching the app re-parents the backend to a live shell.
# #756: the GPU's compute capability isn't in this PyTorch build's arch list,
# so CUDA can't launch kernels ("no kernel image is available for execution").
# NOT OOM. get_best_device() now falls back to CPU up front, but classify the
# raw error too in case CUDA was forced (OMNIVOICE_FORCE_CUDA) or a sub-path
# still ran on the GPU — point at the real fix, not the Flush button.
if "no kernel image is available" in _low:
raise RuntimeError(
f"Your GPU isn't supported by the installed PyTorch build (CUDA can't "
f"launch kernels for its compute capability). Switch the compute device "
f"to CPU in Settings, or install a matching PyTorch (e.g. a cu128 build "
f"for newer GPUs). The Flush button won't help. Underlying error: {e}"
) from e
if isinstance(e, BrokenPipeError) or "broken pipe" in _low or "errno 32" in _low:
raise RuntimeError(
f"The backend lost its output pipe mid-generation — the desktop app "
f"that launched it closed or relaunched ([Errno 32] Broken pipe). "
f"Restart the app and try again; the Flush button won't help here. "
f"Underlying error: {e}"
) from e
# #880: an httpx/requests transport failure surfacing from generation —
# most commonly a first-use model download from the HF Hub dying with
# httpx's "Cannot send a request, as the client has been closed" (the
# shared client got closed mid-lifecycle), a connect/read timeout, or a
# dropped connection — is NOT out of memory. The model never finished
# loading, so Flush is the wrong remedy; retrying is. Matched over the
# whole exception chain (type names + stringified signatures) because
# engines wrap the original transport error.
if _is_network_failure(e):
raise RuntimeError(
f"A model download or network call failed mid-generation (usually "
f"the engine fetching its model files on first use). This is a "
f"network problem, not a memory problem — flushing VRAM won't "
f"help. Retry the generation; if it keeps failing, check your "
f"internet connection and any HF_ENDPOINT/mirror setting. "
f"Underlying error: {e}"
) from e
# #919: a required engine model path / env var that isn't set is a pure
# CONFIGURATION problem, not a runtime one. sherpa-onnx's
# "OMNIVOICE_SHERPA_MODEL not set. Point it to …" used to fall through to
# the OOM catch-all, telling a user with 63 GB of RAM to press Flush. Point
# at the real fix — set the variable — and never mention memory or Flush.
# The underlying error already names the exact variable + what to point it
# at (and Model Catalogue shows a copy-paste setup line), so keep it
# front-and-center. Checked before the OOM branch so a config error can
# never be mislabeled as memory.
if _is_config_failure(e):
raise RuntimeError(
f"This TTS engine isn't set up yet — it needs a model path or "
f"environment variable that isn't configured, so nothing was "
f"generated. Set it as the underlying error describes (it names the "
f"exact variable and what to point it at), then restart VoiceStudio — "
f"or pick a ready engine in Model Catalogue. This is a setup "
f"problem, not a memory one. Underlying error: {e}"
) from e
# #880 (the class bug): the OOM hint used to be the catch-all fallback,
# so ANY unrecognized error told the user to press Flush for memory they
# never ran out of. Only claim OOM when something in the chain actually
# looks like one; everything else surfaces as what it is — unrecognized —
# with the real error front and center.
# #1368: a generate killed at its deadline is not an unrecognized fault.
# It arrived as a bare `TimeoutError:` with an EMPTY message, so the
# catch-all below asked the user to report a trace that says nothing.
# Checked before the OOM branch — a job that ran out of time did not run
# out of memory, and Flush is the wrong remedy.
if _is_timeout_failure(e):
# `TimeoutError` is routinely raised with no message, so the usual
# "Underlying error: …" tail rendered as a bare `TimeoutError:` —
# a sentence stopping mid-thought. Append it only when it says
# something (#1368).
# Test the MESSAGE, not _safe_exc_text() — that always prefixes the
# type name, so it is never empty and the check would never fire.
_tail = f" Underlying error: {_safe_exc_text(e)}" if str(e).strip() else ""
raise RuntimeError(
"The engine hit its time limit before finishing, so generation was "
"stopped. Nothing is broken and flushing memory won't help. The "
"usual causes are a first-use model download still in progress "
"(retry once it finishes — it resumes), a very long input, or an "
"engine running on CPU. Shorter text, or raising "
"OMNIVOICE_GENERATE_TIMEOUT_S, will get it through."
+ _tail
) from e
# #1334: Windows refusing to back a large model mapping (WinError 1455) is
# a paging-file limit, not a working-set shortage. It was matching the OOM
# branch below and telling the user to press Flush — advice that cannot
# work, as the shared hint for this class says outright ("closing other
# apps usually won't fix it"). Checked first so the specific case wins.
_low_1455 = str(e).lower()
if "paging file is too small" in _low_1455 or (
"1455" in _low_1455 and ("winerror" in _low_1455 or "os error" in _low_1455)
):
from core.failure import _HINTS
raise RuntimeError(
"Windows ran out of virtual memory while mapping the model — its "
"paging file is smaller than the model needs. This is not your RAM "
"being full, it is not a network problem, and Flush cannot help. "
+ _HINTS["WINDOWS_PAGING_FILE_TOO_SMALL"]
+ f" Underlying error: {e}"
) from e
if _is_oom_failure(e):
raise RuntimeError(
f"TTS engine stopped mid-generation. This usually means it ran out of memory. "
f"Try the Flush button to reload the model, then regenerate. Underlying error: {e}"
) from e
raise RuntimeError(
f"TTS engine stopped mid-generation with an error VoiceStudio doesn't "
f"recognize. Retry once; if it keeps failing, please report it with "
f"the full trace. Underlying error: {_safe_exc_text(e)}"
) from e
def _generate_timeout_s(
text: str,
*,
execution_device=None,
min_vram_gb=0.0,
hardware_family=None,
vram_gb=None,
) -> float:
"""Wall-clock budget for one generate, scaled to the request.
Thin alias for the canonical helper, which moved to
``services.model_manager.generate_timeout_s`` (#1190) so /v1/audio/speech,
batch, dub and archetype previews share it instead of each re-deriving (or,
as they did, silently keeping the flat 300s).
``min_vram_gb`` is the engine's declared VRAM floor. A GPU below it pages to
system RAM and renders slower than this machine's CPU, so it must not be
budgeted as fast hardware (#1804) — the same figure the dispatch already
hands the guard so a timeout message can name the card (#1226/#1222).
"""
from services.model_manager import generate_timeout_s
return generate_timeout_s(
text,
execution_device=execution_device,
min_vram_gb=min_vram_gb,
hardware_family=hardware_family,
vram_gb=vram_gb,
)
def _run_inference(
model, text, language, ref_audio_path, ref_text, instruct, duration,
num_step, guidance_scale, speed, t_shift, denoise,
postprocess_output, layer_penalty_factor, position_temperature,
class_temperature, used_seed, effect_preset="broadcast",
max_chunk_chars=None, crossfade_ms=None, *, dropped_sink=None,
):
import torch
try:
if used_seed is not None:
torch.manual_seed(used_seed)
kwargs = {}
if t_shift is not None: kwargs["t_shift"] = t_shift
if layer_penalty_factor is not None: kwargs["layer_penalty_factor"] = layer_penalty_factor
if position_temperature is not None: kwargs["position_temperature"] = position_temperature
if class_temperature is not None: kwargs["class_temperature"] = class_temperature
sr = model.sampling_rate if hasattr(model, 'sampling_rate') else 24000
from services.tts_backend import generate_with_cached_ref
def _gen(gen_text, gen_duration):
"""One generate call for this request's voice, reference encoded once."""
return generate_with_cached_ref(
model, ref_audio=ref_audio_path, ref_text=ref_text,
text=gen_text, language=language, instruct=instruct,
duration=gen_duration, num_step=num_step,
guidance_scale=guidance_scale, speed=speed, denoise=denoise,
postprocess_output=postprocess_output, **kwargs
)
# Inline [pause Nms] markers (issue #276): split the text and stitch
# silence between independently-synthesized spans. Fully opt-in — text
# without a marker takes the unchanged single-shot path below.
from omnivoice.utils.text import parse_pause_markers
segments = parse_pause_markers(text)
has_pause = len(segments) > 1 or (segments and segments[0][1] > 0)
if has_pause:
def _gen_span(span_text):
# Per-span duration is left to the model; an explicit overall
# `duration` can't be meaningfully split across spans.
return _gen(span_text, None)[0]
audio_out = _render_with_pauses(_gen_span, segments, sr)
else:
# Wave 1.2: long text is split at sentence boundaries and the
# per-chunk audio crossfaded — removes the length ceiling. Short
# text takes the single-shot path below unchanged. [pause] inputs
# keep the dedicated stitcher above (spans are already short).
from services.chunked_tts import (
DEFAULT_CROSSFADE_MS, DEFAULT_MAX_CHUNK_CHARS,
concatenate_audio_chunks, split_text_into_chunks,
)
_max_chars = DEFAULT_MAX_CHUNK_CHARS if max_chunk_chars is None else max_chunk_chars
_xfade_ms = DEFAULT_CROSSFADE_MS if crossfade_ms is None else crossfade_ms
text_chunks = split_text_into_chunks(text, _max_chars)
if len(text_chunks) > 1:
parts = []
for i, chunk_text in enumerate(text_chunks):
# Vary the seed per chunk (deterministically) to avoid
# correlated RNG artifacts across chunk boundaries.
if used_seed is not None:
torch.manual_seed(used_seed + i)
parts.append(_gen(chunk_text, None)[0])
_note_generate_progress()
audio_out = concatenate_audio_chunks(parts, sr, _xfade_ms,
texts=text_chunks,
sink=dropped_sink)
else:
audio_out = _gen(text, duration)[0]
# Apply DSP effect preset. The VoiceStudio model never masters its own
# output, so mastering always runs here (unchanged behavior).
return _apply_effect_chain(audio_out, sr, effect_preset)
except ValueError as e:
# Don't wrap validation errors in OOM message
raise e
except Exception as e:
_oom_friendly_reraise(e)
def _run_backend_inference(
backend, text, language, ref_audio_path, ref_text, instruct, duration,
num_step, guidance_scale, speed, denoise, postprocess_output,
used_seed, effect_preset="broadcast",
max_chunk_chars=None, crossfade_ms=None, *, t_shift=None,
layer_penalty_factor=None, position_temperature=None,
class_temperature=None, dropped_sink=None,
):
"""Engine-aware twin of :func:`_run_inference` (issue #312).
Runs the request through a pluggable ``TTSBackend`` adapter instead of the
VoiceStudio model directly. A crash-isolated OmniVoice proxy advertises
``supports_native_omnivoice_controls`` and receives the same advanced
controls and per-call seed as the native path; other adapters keep the
narrower protocol unchanged.
"""
import torch
try:
if used_seed is not None:
torch.manual_seed(used_seed)
if language and language.lower() == "auto":
language = None
gen_kwargs = dict(
language=language, ref_audio=ref_audio_path, ref_text=ref_text,
instruct=instruct, num_step=num_step, guidance_scale=guidance_scale,
speed=speed, denoise=denoise, postprocess_output=postprocess_output,
)
native_proxy = bool(
getattr(backend, "supports_native_omnivoice_controls", False)
)
if native_proxy:
gen_kwargs.update({
key: value for key, value in {
"t_shift": t_shift,
"layer_penalty_factor": layer_penalty_factor,
"position_temperature": position_temperature,
"class_temperature": class_temperature,
}.items() if value is not None
})
sr = backend.sample_rate
# Inline [pause Nms] markers (issue #276) work for every engine — the
# silence stitching is model-free.
from omnivoice.utils.text import parse_pause_markers
segments = parse_pause_markers(text)
has_pause = len(segments) > 1 or (segments and segments[0][1] > 0)
if has_pause:
first_span = True
def _gen_span(span_text):
nonlocal first_span
# Per-span duration is left to the engine; an explicit overall
# `duration` can't be meaningfully split across spans.
span_kwargs = dict(gen_kwargs)
if native_proxy and first_span and used_seed is not None:
span_kwargs["seed"] = used_seed
first_span = False
return backend.generate(span_text, duration=None, **span_kwargs)
audio_out = _render_with_pauses(_gen_span, segments, sr)
else:
# Wave 1.2: sentence-boundary chunking for long text (see
# _run_inference for the rationale; behavior is identical here).
from services.chunked_tts import (
DEFAULT_CROSSFADE_MS, DEFAULT_MAX_CHUNK_CHARS,
concatenate_audio_chunks, split_text_into_chunks,
)
_max_chars = DEFAULT_MAX_CHUNK_CHARS if max_chunk_chars is None else max_chunk_chars
_xfade_ms = DEFAULT_CROSSFADE_MS if crossfade_ms is None else crossfade_ms
text_chunks = split_text_into_chunks(text, _max_chars)
if len(text_chunks) > 1:
parts = []
for i, chunk_text in enumerate(text_chunks):
if used_seed is not None:
torch.manual_seed(used_seed + i)
chunk_kwargs = dict(gen_kwargs)
if native_proxy and used_seed is not None:
chunk_kwargs["seed"] = used_seed + i
parts.append(backend.generate(
chunk_text, duration=None, **chunk_kwargs
))
_note_generate_progress()
audio_out = concatenate_audio_chunks(parts, sr, _xfade_ms,
texts=text_chunks,
sink=dropped_sink)
else:
if native_proxy and used_seed is not None:
gen_kwargs["seed"] = used_seed
audio_out = backend.generate(text, duration=duration, **gen_kwargs)
return _apply_effect_chain(
audio_out, sr, effect_preset,
skip_mastering=getattr(backend, "applies_own_mastering", False),
)
except ValueError as e:
# Don't wrap validation errors in OOM message
raise _language_rejection_or(e, backend, language)
except Exception as e:
rewritten = _language_rejection_or(e, backend, language)
if rewritten is not e:
raise rewritten from e
_oom_friendly_reraise(e)
# #1257: the language picker offers all 646 languages regardless of engine,
# because MLXAudioBackend.supported_languages() returns ["multi"] on the stated
# assumption that "each engine silently ignores languages it doesn't know".
# That assumption is false — the underlying library raises, and the reporter got
# a bare 400 that recited 23 language codes without saying which engine was
# refusing, or that switching engines was the fix.
# Each signature must be about the LANGUAGE itself. "Unsupported language" as a
# bare prefix also matches "Unsupported language model configuration" — a model
# problem handed engine-switch advice it has no use for (#1257 review) — so the
# looser wordings require the rejected thing to end there or be a code/name.
_LANGUAGE_REJECTION_SIGNATURES = (
"invalid language code",
"language not supported",
"language is not supported",
"unsupported language code",
)
#: `unsupported language: xx` / `unsupported language 'xx'` — but not
#: `unsupported language model ...`.
_LANGUAGE_REJECTION_RE = re.compile(
r"unsupported language\s*[:=]|unsupported language\s*['\"]|"
r"unsupported language\s*$",
re.IGNORECASE | re.MULTILINE,
)
def _language_rejection_or(e: BaseException, backend, language):
"""``e`` rewritten with engine context when it's a language rejection.
Returns ``e`` unchanged otherwise, so this is safe to wrap any failure in.
Matched on the message, not the type: the engines multiplex third-party
libraries that each raise their own class.
"""
text = str(e)
low = text.lower()
if not any(sig in low for sig in _LANGUAGE_REJECTION_SIGNATURES) and not (
_LANGUAGE_REJECTION_RE.search(text)
):
return e
engine = getattr(backend, "display_name", None) or getattr(
type(backend), "id", type(backend).__name__
)
requested = f" '{language}'" if language else ""
return ValueError(
f"The {engine} engine can't speak{requested}. VoiceStudio offers every "
f"language its default engine supports, but each engine covers a "
f"different set — pick one this engine supports, or switch engine in "
f"Model Catalogue (the VoiceStudio engine has the widest coverage) "
f"and generate again. Engine's own message: {e}"
)
def _persist_profile_ref_text(profile_id: str, ref_text: str) -> None:
"""Cache an auto-transcribed reference transcript onto its profile row.
#1032 perf regression: profiles saved without a transcript re-ran a FULL
ASR model load + transcribe on every /generate (the #308 auto-transcribe
path). Persisting the first transcript makes subsequent generates read it
from the row like a user-entered one. The guarded UPDATE only ever fills
an empty column — it can never overwrite a transcript the user typed or a
lock wrote — and a failure is logged, never raised (best-effort, same
contract as the transcribe itself)."""
try:
with db_conn() as conn:
updated = conn.execute(
"UPDATE voice_profiles SET ref_text=? "
"WHERE id=? AND (ref_text IS NULL OR ref_text='')",
(ref_text, profile_id),
).rowcount
if updated:
event_bus.emit("profiles", {"action": "updated", "id": profile_id})
except Exception as e: # noqa: BLE001 — cache write must not break generate
logger.warning(
"could not persist auto-transcribed ref_text onto profile %s: %s",
log_safe(profile_id), log_safe(e),
)
async def _finalize_generation(
audio_tensor, sample_rate, *, text, history_mode, ref_audio_path,
language, instruct, resolved_profile_id, used_seed, start_time,
already_marked=False,
):
"""Shared tail of a successful generation: watermark → save WAV →
history row (self-healing) → retention prune → event emit.
Used verbatim by both the classic whole-file response path and the
streaming-preview path (``stream=true``), so the on-disk artifact —
watermark, filename, history row, retention behavior — is identical
regardless of how the audio was delivered to the client.
``already_marked`` is for audio that arrives provenance-marked: a remote
worker marks at the tensor stage before it encodes (with ``force=True``,
so the *requesting* user's preference governs, not the GPU owner's), and
embedding a second AudioSeal payload over the first degrades detection of
both. The take users keep carries exactly one whole-take mark either way.
Returns ``(watermarked_tensor, meta)`` where ``meta`` carries
``id`` / ``filename`` / ``duration`` / ``gen_time``.
"""
# Invisible AudioSeal provenance watermark on the final audio. Embedding
# was previously only wired into the dub pipeline (dub_generate.py), so
# plain TTS came out unmarked despite the setting being on — and the same
# class of gap later bit /v1/audio/speech (#1169), which is why ALL
# producers now share the mark_synthetic chokepoint. It self-gates on the
# user's watermark setting + AudioSeal availability and passes the audio
# through unchanged on any failure, so it never breaks generation.
# Dispatched to the dedicated watermark pool, not the GPU pool (#1190):
# AudioSeal embedding is CPU work that holds no VRAM, so occupying a GPU
# worker with it only delays the next generate on 1-worker hosts.
if not already_marked:
from services.watermark import mark_synthetic_async
audio_tensor = await mark_synthetic_async(
audio_tensor, sample_rate, context="generate.finalize",
)
gen_time = round(time.time() - start_time, 2)
audio_id = str(uuid.uuid4())[:8]
audio_filename = f"{audio_id}.wav"
audio_path = os.path.join(OUTPUTS_DIR, audio_filename)
_safe_torchaudio_save(audio_path, audio_tensor, sample_rate)
audio_dur = round(audio_tensor.shape[-1] / sample_rate, 2)
# #710: the clip is already generated and saved above. A history-write
# failure — e.g. "no such table: generation_history" on a DB that missed
# schema init — must NOT 500 the user's generation. Self-heal the schema
# once and retry; if it still fails, log and return the audio anyway.
def _write_history():
with db_conn() as conn:
conn.execute(
"INSERT INTO generation_history (id, text, mode, language, instruct, profile_id, audio_path, duration_seconds, generation_time, seed, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(audio_id, text[:200], history_mode or ("clone" if ref_audio_path else "design"),
language or "Auto", instruct or "", resolved_profile_id,
audio_filename, audio_dur, gen_time, used_seed, time.time())
)
try:
_write_history()
except sqlite3.OperationalError as e:
logger.warning("generation history write failed (%s); healing schema + retrying", e)
try:
ensure_schema()
_write_history()
except Exception as e2:
logger.warning("history write still failed after schema heal; returning audio anyway: %s", e2)
except Exception as e:
logger.warning("generation history write failed; returning audio anyway: %s", e)
# Retention cap: without it, takes (rows + WAVs in OUTPUTS_DIR) grow
# unbounded forever. Best-effort — a prune failure must never affect
# the generation that just succeeded.
try:
_prune_history_over_cap()
except Exception as e: # noqa: BLE001
logger.warning("history retention prune failed (non-fatal): %s", e)
event_bus.emit("generation_history", {"action": "created", "id": audio_id})
# Opt-in analytics (core/analytics.py): no-op unless the user turned it on.
# Metadata only — text_length is the LENGTH of the text, never the text; the
# allowlist in analytics.sanitize_properties() enforces that regardless.
try:
from core.analytics import capture as _ph
_ph("speech_generated", {
"mode": history_mode,
"language": language or "auto",
"duration_seconds": audio_dur,
"gen_time_seconds": gen_time,
"text_length": len(text or ""), # the LENGTH. never the text.
"has_profile": bool(resolved_profile_id),
})
except Exception: # noqa: BLE001 — analytics may never break a generation…
# …but it must not fail SILENTLY either: a typo'd variable here would
# otherwise mean the event simply never fires and nobody ever knows.
logger.warning("analytics: speech_generated capture failed", exc_info=True)
return audio_tensor, {
"id": audio_id,
"filename": audio_filename,
"duration": audio_dur,
"gen_time": gen_time,
}
def _pcm16_b64(wav_tensor) -> str:
"""Mono 16-bit little-endian PCM, base64-encoded — the streaming-preview
wire format (same conversion as /ws/tts). N-D tensors take channel 0."""
import base64
import torch
pcm = (wav_tensor * 32767).clamp(-32768, 32767).to(torch.int16)
while pcm.ndim > 1:
pcm = pcm[0]
return base64.b64encode(pcm.cpu().numpy().tobytes()).decode("ascii")
# ── Remote GPU: this route is the producer the scheduler never had ─────────
#
# Picking a remote worker used to change a badge and nothing else — every
# render still ran on this machine, which is the whole reported bug. The
# decision is taken ONCE per request, through `services/gpu_gateway.py`, and
# BEFORE anything local is loaded — for two reasons that are not
# interchangeable: a render bound for the user's 4090 must not first pull a
# multi-GB model into this machine's RAM, and it must not be refused by a gate
# that asked whether THIS host has the accelerator the engine needs (a
# CUDA-only engine on a Mac control plane is exactly the case remote workers
# exist for).
_REMOTE_OP = "tts"
# The gateway's coarse phase → the sentence a user reads while someone else's
# GPU works. A five-minute remote render otherwise shows the same bare spinner
# as a local one, with no way to tell "queued behind another task" from
# "downloading 5 GB of weights" from "actually generating".
_REMOTE_PHASE_LABELS = {
"queued": "queued on {target}",
"loading": "loading model on {target}",
"running": "generating on {target}",
"uploading": "receiving audio from {target}",
}
class _LocalDecision:
"""Stand-in for ``worker.routing.Decision`` meaning "run here".
Used only when the gateway cannot be imported at all, so a build without
it still renders instead of 500-ing.
"""
remote = False
worker_id = None
label = "Local"
reason = ""
_LOCAL_DECISION = _LocalDecision()
def _routing_decision():
"""Local or remote for this request — resolved once, never re-asked.
Asked once because the target is user-settable at any moment: a decision
that flipped between prewarm and dispatch would either warm an engine
nothing will use or dispatch remotely after paying a local cold load.
"""
try:
from services import gpu_gateway
return gpu_gateway.decide(_REMOTE_OP)
except Exception: # noqa: BLE001 — routing is advisory; local always works
logger.debug("remote routing unavailable; running locally", exc_info=True)
return _LOCAL_DECISION
def _remote_only_local_call(target_label, reason=""):
"""The local branch of a render whose local half was deliberately skipped.
``gpu_gateway.run`` always takes a local callable — it is where rule 1
(pre-dispatch unavailability) lands. But this route skips every local
preparation step once the decision is remote, precisely so a job bound for
the 4090 does not first load gigabytes here, so there is no local render
left to fall back to.
The causes rule 1 actually covers — worker offline, disabled, not
approved, breaker open, remote workers switched off — are already answered
by ``decide()`` BEFORE that skip, and come back as a local decision with a
named reason. What is left is the narrow window where dispatch itself is
refused (a full queue, a task dropped between submit and wait). Saying so
and offering the local re-run is honest; silently returning nothing is not.
"""
from services.gpu_gateway import RemoteJobFailed
def _refuse():
raise RemoteJobFailed(
reason or f"{target_label} could not take this render",
worker_label=target_label,
code="REMOTE_NOT_DISPATCHED",
hint="Run it on this machine instead, or pick another GPU.",
)
return _refuse
def _remote_progress_frame(state, target):
"""One gateway ``on_state`` payload → the NDJSON event the UI renders."""
phase = str((state or {}).get("phase") or "running")
try:
pct = max(0, min(100, round(float((state or {}).get("progress") or 0.0) * 100)))
except (TypeError, ValueError):
pct = 0
detail = _REMOTE_PHASE_LABELS.get(phase, _REMOTE_PHASE_LABELS["running"])
detail = detail.format(target=target)
if phase == "running" and pct:
detail = f"{detail} ({pct}%)"
return {
"type": "progress", "stage": phase, "percent": pct,
"target": target, "detail": detail,
}
def _apply_routing_headers(headers, engine_notice, decision):
"""Say where this render ran, on the notice channel that already exists.
``X-OmniVoice-Routing`` / ``-Routing-Reason`` are already set for the #21
engine routing gate and already consumed as a de-duped one-time toast, so
"this ran on gpu2" and "your 4090 was asleep, this ran here" travel the
same wire rather than inventing a second one.
The engine notice wins on a local render: "the engine fell back to CPU"
explains the slowness the user is looking at, while the worker notice for
a local render is the quieter of the two. A remote render has no engine
notice at all — that gate answers for THIS host, and this host did nothing.
"""
from services.engine_routing import header_safe_reason
notice = engine_notice
if decision is not None:
try:
from services.gpu_gateway import notice_for
worker_notice = notice_for(decision)
except Exception: # noqa: BLE001 — a notice must never fail a render
worker_notice = None
if worker_notice and (getattr(decision, "remote", False) or not notice):
notice = worker_notice
if not notice:
return headers
headers["X-OmniVoice-Routing"] = notice[0]
safe = header_safe_reason(notice[1]) if notice[1] else ""
if safe:
headers["X-OmniVoice-Routing-Reason"] = safe
return headers
@router.post("/generate")
async def generate_speech(
text: str = Form(...),
language: Optional[str] = Form(None),
ref_audio: Optional[UploadFile] = File(None),
ref_text: Optional[str] = Form(None),
instruct: Optional[str] = Form(None),
duration: Optional[float] = Form(None),
num_step: Optional[int] = Form(None),
guidance_scale: float = Form(2.0),
speed: float = Form(1.0),
t_shift: Optional[float] = Form(None),
denoise: bool = Form(True),
postprocess_output: Optional[bool] = Form(None),
layer_penalty_factor: Optional[float] = Form(None),
position_temperature: Optional[float] = Form(None),
class_temperature: Optional[float] = Form(None),
profile_id: Optional[str] = Form(None),
seed: Optional[int] = Form(None),
effect_preset: str = Form("broadcast"),
engine: Optional[str] = Form(None),
# Wave 1.2 — unlimited-length generation: long text is split at sentence
# boundaries and crossfaded. 0 disables chunking (whole text to engine).
max_chunk_chars: int = Form(800, ge=0),
crossfade_ms: int = Form(50, ge=0, le=1000),
# Expressive-TTS Spec 01: apply the user pronunciation dictionary + inline
# [[…]] overrides to the text before synthesis. Default ON; the global
# OMNIVOICE_PRONUNCIATION pref can disable it for power users. Omitting it
# with an empty dictionary is byte-identical to legacy behavior.
pronounce: bool = Form(True),
# Streaming preview: when true, the response is application/x-ndjson —
# one JSON event per line ("start" → N × "chunk" (base64 PCM16 preview of
# each text chunk, playable the moment it arrives) → "done" with the saved
# take's metadata, or "error"). The final WAV on disk (watermark, history
# row, retention) is produced by the exact same finalize path as the
# classic flow, so streaming is purely a delivery channel — engine-agnostic
# (text-level chunking, no per-engine token streaming).
stream: bool = Form(False),
):
# #502: NFC-normalize the input text so decomposed (NFD) diacritics — common
# in pasted Vietnamese and other Latin-with-marks text — are composed to the
# single codepoints the tokenizer/model expect, instead of base-letter +
# combining-mark sequences that render as distorted/garbled speech. NFC is a
# no-op for already-composed text; mirrors the duration estimator
# (utils/duration.py) so the estimate and the synthesis see the same text.
import unicodedata
text = unicodedata.normalize("NFC", text)
# ── Engine resolution (issue #312) ──────────────────────────────────────
# The request runs on the engine selected in Settings (POST /engines/select,
# env var OMNIVOICE_TTS_BACKEND wins), or an explicit per-request `engine`
# override — same pattern as /ws/tts's `engine` field and /v1/audio/speech's
# `model`. Omitting both keeps the historical default (VoiceStudio), so
# existing API consumers see no change.
from services.tts_backend import (
OmniVoiceBackend, _mask_hf_tokens, active_backend_id, get_backend_class,
)
engine_id = engine or active_backend_id()
from services.performance_profiles import tts_defaults
sampling_defaults = tts_defaults(engine_id)
if num_step is None:
num_step = sampling_defaults.get("num_step", 16)
if postprocess_output is None:
postprocess_output = sampling_defaults.get("postprocess_output", True)
try:
backend_cls = get_backend_class(engine_id)
except ValueError:
raise HTTPException(
status_code=400,
detail=(
f"Unknown TTS engine: {engine_id!r}. "
"See GET /engines/tts for the list of valid engine ids."
),
)
# Crash forensics (#1164): a generate is exactly the kind of work an OOM
# kill lands on — record it (engine id only, never the text) so an
# unclean death is attributable by the next run. Throttled + never raises.
from core.run_sentinel import touch_activity
touch_activity("generate", engine_id)
# ── Where does this render run? Asked once, here, because every line
# between this point and the dispatch below is preparation of THIS
# machine's GPU — model eviction, a multi-GB load, a host-capability gate.
# None of it applies to a render that belongs on the user's other box, and
# running it anyway is how "I selected gpu2" ended up meaning "the Mac did
# the work after loading the model twice".
_decision = _routing_decision()
_remote = bool(getattr(_decision, "remote", False))
_target_label = getattr(_decision, "label", "") or "the chosen worker"
_model = None
_backend = None
_engine_min_vram_gb = getattr(backend_cls, "min_vram_gb", 0.0)
_routing_notice = None
# Remote renders deliberately skip this host's capability gate. Keep the
# local fallback call's timeout device-neutral so the closure is valid
# without pretending the control plane describes the remote worker.
_routing = {"effective_device": None}
_routing_hardware_family = None
_routing_vram_gb = None
if not _remote:
# Single-active-engine memory discipline: hand back any OTHER resident
# TTS engine's model before loading this one, so switching engines (or
# a per-request engine= override, which bypasses /engines/select
# entirely) doesn't stack two multi-GB models in memory — the
# accumulation behind the 16 GB-Mac OOM deaths. No-op when nothing else
# is resident, so steady-state single-engine use pays nothing. Opt out:
# OMNIVOICE_SINGLE_ENGINE_RESIDENT=0.
from services.engine_memory import evict_other_tts_engines
await evict_other_tts_engines(engine_id)
# Non-blocking breadcrumb: if free memory is already low before this
# load, log it. A later OOM kill (the 16 GB-Mac class) then has a trail
# pointing at the load that tipped it, instead of a silent process
# death. Never blocks — the OS can reclaim cache, and a hard refuse
# would brick legitimate loads.
try:
from services.memory_budget import log_if_low
log_if_low(f"TTS load ({engine_id})")
except Exception:
pass
# VRAM eviction runs in get_model()'s warm-return path now, so every
# native TTS generate (this route, WS TTS, dub, batch, audiobook) is
# covered.
if backend_cls is OmniVoiceBackend:
# VoiceStudio keeps its native path: it carries the full advanced
# parameter surface (t_shift, layer/position/class controls) that
# the generic adapter protocol doesn't. Byte-identical behavior.
_model = await get_model()
else:
try:
ok, msg = backend_cls.is_available()
except Exception as exc:
ok, msg = False, f"{type(exc).__name__}: {exc}"
if not ok:
raise HTTPException(
status_code=400,
detail=f"TTS engine '{engine_id}' is not available: {_mask_hf_tokens(msg)}",
)
# Reuse the per-process instance cache shared with the engine
# health-check route so weights load once, not per request.
from api.routers.engines import _get_engine_instance
_backend = _get_engine_instance(backend_cls)
# ── Routing gate (#21 — no silent CPU fallback). Computed ONCE per
# request (host caps are constant; the per-request engine= override
# bypasses the /engines/select gate, so this is the only place it's
# enforced for synth). Local only, and deliberately: it asks what THIS
# host can accelerate, and a remote render is precisely the case where
# that answer is none of the question — a CUDA-only engine sent to a
# 4090 from a Mac control plane would be refused by a gate describing
# a machine that is about to do nothing.
from core.device_caps import detect_host_caps
from services.engine_routing import (
routing_notice,
runtime_compute_profile_async,
)
_routing = await runtime_compute_profile_async(
backend_cls, detect_host_caps()
)
_engine_min_vram_gb = _routing["min_vram_gb"]
_routing_hardware_family = _routing.get("runtime_hardware_family")
_routing_vram_gb = _routing.get("runtime_vram_gb")
if _routing["routing_status"] == "unavailable":
# The engine needs an accelerator this host lacks and has no CPU path.
raise HTTPException(status_code=400, detail=_routing["routing_reason"])
_routing_notice = routing_notice(_routing) # (status, reason) or None
# ── #1033/#1037: warm the engine under the LOAD budget, not the generate
# budget. A cold adapter lazily loads (and possibly downloads multi-GB
# weights) inside generate(), so a fresh install's first request burned
# its whole OMNIVOICE_GENERATE_TIMEOUT_S window on the download and died
# with a misleading "too heavy for the available compute" 503 (#1014
# measured it: 0% GPU util for the full 300s). Model loading gets its own,
# larger budget (OMNIVOICE_MODEL_LOAD_TIMEOUT, default 1200s) — the same
# split get_model() already has for the native engine. Once warm, this is
# a no-op per request. A remote render gets the same two-phase split from
# the worker, under the assignment's own model-load deadline.
if _backend is not None:
from services import gpu_gateway
try:
await gpu_gateway.prewarm(
_REMOTE_OP, backend=_backend, engine=engine_id, decision=_decision,
)
# Builtin TimeoutError base, not GpuJobTimeoutError — reload-proof
# class identity (see the twin catch in openai_compat.py).
except (TimeoutError, gpu_gateway.ModelLoadTimeout) as exc:
logger.warning("engine load exceeded the model-load budget: %s", exc)
raise HTTPException(
status_code=503,
detail=(
f"TTS engine '{engine_id}' did not finish loading within its "
f"model-load budget — on a first run this usually means the "
f"weight download is slow or stalled (check the engine's Weights list in Model Catalogue "
f"for progress), not that generation failed. Retry once the "
f"model shows as installed."
),
) from exc
ref_audio_path = None
cleanup_ref = False
ref_lease = None
used_seed = seed
resolved_profile_id = None
history_mode = None # profile.kind when a profile drives; else inferred at insert
# #1032: profile id to persist an auto-transcribed reference transcript to.
# Set only for a plain (unlocked) clone profile whose stored ref_text is
# empty — the case where every /generate re-ran a full ASR model load +
# transcribe of the same clip. Locked profiles are excluded (their ref
# audio is the locked take, and unlocking would leave a mismatched
# transcript paired with the original reference); design profiles are
# excluded (a re-render replaces the sample, stranding a stale transcript).
persist_ref_text_profile_id = None
if profile_id:
with db_conn() as conn:
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
if row:
resolved_profile_id = profile_id
# Shared with POST /convert — see _resolve_profile_conditioning
# for the resolution rules (kind-authoritative, lock wins, #533
# language fill, #1032 transcript-cache signal).
_cond = _resolve_profile_conditioning(
row, ref_text=ref_text, instruct=instruct, seed=used_seed,
language=language,
)
history_mode = _cond["kind"]
ref_audio_path = _cond["ref_audio_path"]
ref_text = _cond["ref_text"]
instruct = _cond["instruct"]
used_seed = _cond["seed"]
language = _cond["language"]
if _cond["persist_ref_text"]:
persist_ref_text_profile_id = profile_id
elif ref_audio is not None:
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as f:
f.write(await ref_audio.read())
ref_audio_path = f.name
cleanup_ref = True
ref_lease = _TempReferenceLease(ref_audio_path)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# #308: a transcript-less reference is transcribed with the active ASR
# backend (whisperx / faster-whisper / mlx-whisper) instead of the model's
# built-in transformers pipeline, which cannot load whisper-large-v3-turbo
# on transformers 5.3. On failure ref_text stays None and the model's
# fallback behaves exactly as before.
if ref_audio_path and not ref_text:
from services.asr_backend import transcribe_reference
# Same #730 hang risk as any whisperx transcribe — bound + reset the pool
# so a wedged reference transcribe can't brick the backend. This path is
# best-effort (transcribe_reference returns None on failure → the model's
# built-in ASR fallback), so a timeout degrades to None rather than
# failing the whole generate.
try:
ref_text = await _run_with_reference_lease(
ref_lease,
lambda release: run_on_gpu_pool_guarded(
functools.partial(transcribe_reference, ref_audio_path),
what="Reference transcribe",
# Floor budget (#1190): a reference clip is seconds of audio,
# so the length-scaled bonus never applies — but the timeout is
# explicit here too, so no dispatch relies on a hidden default.
timeout=_generate_timeout_s(
"", execution_device=_routing["effective_device"]
),
on_abandon=release,
)
)
# TimeoutError covers both the execution bound and pool saturation:
# this path is best-effort either way.
except TimeoutError as e:
logger.warning("reference transcribe hung (%s); using model ASR fallback", e)
ref_text = None
# #1032: cache the transcript onto its clone profile so the ASR model
# load + transcribe above happens once per profile, not per generate.
# Only fills an empty column — a user-entered transcript always wins.
if ref_text and persist_ref_text_profile_id:
_persist_profile_ref_text(persist_ref_text_profile_id, ref_text)
# #526: materialize a concrete seed when none was supplied (and no profile
# pinned one) so the take is reproducible and we can hand it back via the
# X-Seed header for the "keep this seed" control. An explicit request seed
# or a profile's stored seed still wins — used_seed is only filled when it
# is still None here, never overwritten.
if used_seed is None:
used_seed = random.randint(0, 2**31 - 1)
# Engine-agnostic text normalization (junk strip, numbers→words,
# abbreviations) — AFTER `language` is fully resolved, and BEFORE the
# pronunciation dictionary so user dictionary entries operate on
# normalized text and respellings are never re-mangled (ordering rationale
# in services/text_normalization.py). Pref-gated (default ON), idempotent,
# never raises; applied exactly once per request, at this choke point.
from services.text_normalization import normalize_for_tts
text = normalize_for_tts(text, language)
# Expressive-TTS Spec 01: apply the user pronunciation dictionary + inline
# [[…]] one-off overrides to the text, here — AFTER `language` is fully
# resolved (a profile may fill it above) so per-language entries match the
# real render language, and BEFORE the text reaches either inference path
# (native VoiceStudio or a pluggable backend) and the chunk splitter. This is
# the single point user text → normalized text → model, so the transform
# covers generate for every engine. Pure text substitution → identical on
# mac/Win/Linux. A disabled pref or empty dictionary is a pass-through, so
# plain text stays byte-identical (#G5 backward-compat).
from core import prefs as _prefs
_pron_env = os.environ.get("OMNIVOICE_PRONUNCIATION")
if _pron_env is not None:
# Env wins (power-user override); "0"/"false"/"no"/"off" disable it.
_pron_enabled = _pron_env.strip().lower() not in ("0", "false", "no", "off", "")
else:
_pron_enabled = bool(_prefs.get("pronunciation_enabled", True))
if pronounce and _pron_enabled:
from services.pronunciation import apply_pronunciation, load_entries_from_db
try:
_pron_rows = load_entries_from_db()
except Exception: # noqa: BLE001 — table missing / DB locked → no-op
_pron_rows = []
text = apply_pronunciation(text, _pron_rows, language)
else:
# Even with the dictionary off, inline [[…]] overrides are an explicit,
# in-text authoring choice → always honored (and never left as literal
# double-bracket text the model would mispronounce).
from services.pronunciation import apply_inline_overrides
text = apply_inline_overrides(text)
start_time = time.time()
# ── The remote assignment ───────────────────────────────────────────────
# Built even for a local render (it costs a dict) so the gateway owns the
# branch rather than this route owning two of them.
#
# The worker runs the ENTIRE render as one op — sentence split, per-chunk
# generate at ``seed + i``, crossfaded concat, effect chain, provenance
# mark — because dispatching a chunk at a time would pay a round trip, a
# progress lease and a slot per sentence against a worker whose
# concurrency defaults to 1. So every knob that shapes the local render has
# to be on the wire: a missing one is not an error, it is remote audio that
# quietly differs from local audio (no sentence splitting, no per-chunk
# seed variation, no crossfade).
from services import gpu_gateway
from services.watermark import is_enabled as _watermark_enabled
_remote_params = {
"text": text,
"language": None if (language and language.lower() == "auto") else language,
"ref_audio": ref_audio_path,
"ref_text": ref_text,
"instruct": instruct,
"duration": duration,
"speed": speed,
"num_step": num_step,
"guidance_scale": guidance_scale,
"denoise": denoise,
"postprocess_output": postprocess_output,
"t_shift": t_shift,
"layer_penalty_factor": layer_penalty_factor,
"position_temperature": position_temperature,
"class_temperature": class_temperature,
"seed": used_seed,
"max_chunk_chars": max_chunk_chars,
"crossfade_ms": crossfade_ms,
"effect_preset": effect_preset,
# The requesting user's provenance preference, not the GPU owner's.
"watermark": bool(_watermark_enabled()),
}
_remote_call = gpu_gateway.RemoteCall(
engine=engine_id, operation=_REMOTE_OP, params=_remote_params,
)
async def _render_on_worker(on_state=None):
"""One whole render on the chosen worker → ``(tensor, sample_rate)``.
The audio comes back already effect-chained and provenance-marked: the
worker mirrors the local order (split → generate → concat → effects →
mark) so a remote take and a local take of the same request differ
only in which GPU produced them.
"""
waveform, sample_rate = await gpu_gateway.run(
_REMOTE_OP,
local=gpu_gateway.LocalCall(
_remote_only_local_call(_target_label),
what="TTS generate",
timeout=_generate_timeout_s(
text,
execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb,
hardware_family=_routing_hardware_family,
vram_gb=_routing_vram_gb,
),
min_vram_gb=_engine_min_vram_gb,
),
remote=_remote_call,
decision=_decision,
on_state=on_state,
)
if getattr(waveform, "ndim", 2) == 1:
# `_safe_torchaudio_save` and the local paths deal in
# (channels, samples); a mono artifact reads back flat.
waveform = waveform.unsqueeze(0)
return waveform, sample_rate
# ── Streaming preview (feat: streaming-tts-preview) ─────────────────────
# Long scripts used to mean staring at a spinner until the ENTIRE render
# finished. With stream=true the existing text chunks (the Wave 1.2
# sentence-boundary splitter — unchanged) are synthesized sequentially and
# each chunk's audio is yielded the moment it's rendered, so playback can
# begin after the first chunk. The final file is then assembled through
# the SAME concat → effect-chain → watermark → save → history pipeline as
# the classic path, so the on-disk take is identical to a non-streamed
# one. [pause]-marker inputs and single-chunk (short) texts keep their
# unchanged single-shot pipeline and stream as one chunk. All prep above
# (engine warm under the #1039 model-load budget, routing gate, profile /
# seed / normalization) already ran, so per-chunk jobs spend the generate
# budget on generation only — and each chunk gets its own budget, so a
# long script can't time out merely for being long.
if stream and _remote:
# ── Remote: the streaming PREVIEW is off, the render still streams ──
# Progressive playback needs per-chunk dispatch, and per-chunk dispatch
# to a worker means a round trip, a progress lease and a slot for every
# sentence, serialised by a default concurrency of 1. So the render
# goes as ONE op and there is no first chunk to play early.
#
# The NDJSON channel stays open anyway, because the desktop UI asks for
# it whenever auto-play is on — which is the default. Answering with
# the classic WAV shape here would make the client fall back to a
# LOCAL re-render, i.e. exactly the bug this phase exists to fix: the
# user picks gpu2, clicks Synthesize, and their laptop does the work.
# What flows down it instead is coarse progress from the worker, then
# the finished take as a single chunk.
_remote_headers = _apply_routing_headers(
{"X-Seed": str(used_seed) if used_seed is not None else "",
"Cache-Control": "no-cache"},
None, _decision,
)
_progress_q: asyncio.Queue = asyncio.Queue()
def _push_progress(event):
# Called from the control plane's own loop; never let a progress
# frame break a render that is otherwise going fine.
try:
_progress_q.put_nowait(dict(event or {}))
except Exception: # noqa: BLE001
logger.debug("dropped a remote progress frame", exc_info=True)
async def _remote_stream_events():
import json
def _line(obj) -> bytes:
return (json.dumps(obj, separators=(",", ":")) + "\n").encode("utf-8")
render = asyncio.ensure_future(_render_on_worker(_push_progress))
try:
# Relay progress until the render settles, then flush whatever
# arrived in the gap so the last "generating (98%)" is not lost.
while not render.done():
getter = asyncio.ensure_future(_progress_q.get())
done, _pending = await asyncio.wait(
{render, getter}, return_when=asyncio.FIRST_COMPLETED
)
if getter in done:
yield _line(_remote_progress_frame(getter.result(), _target_label))
continue
getter.cancel()
while not _progress_q.empty():
yield _line(_remote_progress_frame(_progress_q.get_nowait(),
_target_label))
audio_tensor, sample_rate = await render
yield _line({
"type": "start", "sample_rate": sample_rate, "channels": 1,
"format": "pcm16", "total_chunks": 1, "crossfade_ms": 0,
"seed": used_seed,
})
# No second provenance mark: the worker marked at the tensor
# stage before encoding, with the requesting user's preference
# forced, and stacking a second AudioSeal payload over the
# first degrades detection of both.
yield _line({"type": "chunk", "seq": 0, "pcm": _pcm16_b64(audio_tensor)})
_, meta = await _finalize_generation(
audio_tensor, sample_rate, text=text, history_mode=history_mode,
ref_audio_path=ref_audio_path, language=language,
instruct=instruct, resolved_profile_id=resolved_profile_id,
used_seed=used_seed, start_time=start_time, already_marked=True,
)
# #1330's dropped-chunk warning has no remote carrier yet: the
# gateway hands back audio, not the worker's render metadata.
# Reported as a cross-stream gap rather than faked as zero.
yield _line({
"type": "done", "id": meta["id"], "audio_path": meta["filename"],
"duration": meta["duration"], "gen_time": meta["gen_time"],
"seed": used_seed, "sample_rate": sample_rate,
})
except (asyncio.CancelledError, GeneratorExit):
# The user hit stop, or the request was abandoned. Cancelling
# the render is what tells the worker to release its slot —
# otherwise the 4090 keeps rendering audio nobody will hear,
# holding what is often its only slot until the lease lapses.
render.cancel()
raise
except ValueError:
logger.error("Remote generation request rejected")
from core.public_errors import stream_failure
yield _line({"type": "error", **stream_failure("invalid_request")})
except gpu_gateway.ModelNotDownloaded as e:
logger.warning("Remote model missing on %s", _target_label)
from core.public_errors import stream_failure
yield _line({
"type": "error",
**stream_failure("model_not_downloaded"),
"engine": e.engine,
"repo_ids": e.repo_ids,
"target": e.target,
"target_label": e.target_label,
"downloadable": e.downloadable,
})
except gpu_gateway.RemoteJobFailed as e:
logger.error("Remote generate failed on %s", _target_label)
from core.public_errors import stream_failure
yield _line({
"type": "error",
**stream_failure("generation_failed"),
"retryable": True,
"target_label": e.worker_label or _target_label,
"hint": e.hint,
})
except Exception as exc:
# Mid-job remote failure is NOT quietly redone here: the client
# treats a retryable error as "surface it", so the user decides
# whether to spend the same minutes again on this machine. Like
# the local streaming path, this in-band frame stands in for the
# global 500 handler, so it journals the scrubbed failure and
# names a recognized cause instead of the bare generic string
# (#1607).
logger.error(
"Remote generation failed (class=%s)",
type(exc).__name__,
)
from core.public_errors import stream_generation_failure
from core import error_journal
error_journal.record(
exc, route="/generate", trace=traceback.format_exc()
)
yield _line({"type": "error", **stream_generation_failure(exc)})
finally:
if not render.done():
render.cancel()
if cleanup_ref and ref_lease is not None:
ref_lease.finish_request()
return StreamingResponse(
_remote_stream_events(),
media_type="application/x-ndjson",
headers=_remote_headers,
)
if stream:
from omnivoice.utils.text import parse_pause_markers
from services.chunked_tts import split_text_into_chunks
_segments = parse_pause_markers(text)
_has_pause = len(_segments) > 1 or (_segments and _segments[0][1] > 0)
_text_chunks = [] if _has_pause else split_text_into_chunks(text, max_chunk_chars)
# #1330 — see the non-streaming path: chunks the engine rendered to
# nothing land here so the stream can say the take is missing text
# instead of quietly handing back a short one.
_dropped_sink: list = []
def _render_stream_chunk(i: int, chunk_text: str):
"""One text chunk → (raw engine tensor, preview-DSP tensor, sr).
Runs on the GPU pool. Mirrors ONE iteration of the multi-chunk
loop in _run_inference/_run_backend_inference exactly (per-chunk
deterministic seed, same generate kwargs), so concatenating the
raw parts afterwards reproduces the non-streaming output. The
preview copy gets the same effect chain the final file will get,
so what the user hears mid-stream matches the saved take.
"""
import torch
try:
if used_seed is not None:
torch.manual_seed(used_seed + i)
if _backend is not None:
_lang = None if (language and language.lower() == "auto") else language
raw = _backend.generate(
chunk_text, duration=None, language=_lang,
ref_audio=ref_audio_path, ref_text=ref_text,
instruct=instruct, num_step=num_step,
guidance_scale=guidance_scale, speed=speed,
denoise=denoise, postprocess_output=postprocess_output,
**({
key: value for key, value in {
"t_shift": t_shift,
"layer_penalty_factor": layer_penalty_factor,
"position_temperature": position_temperature,
"class_temperature": class_temperature,
"seed": used_seed + i if used_seed is not None else None,
}.items() if value is not None
} if getattr(
_backend, "supports_native_omnivoice_controls", False
) else {}),
)
sr = _backend.sample_rate
skip = getattr(_backend, "applies_own_mastering", False)
else:
kwargs = {}
if t_shift is not None: kwargs["t_shift"] = t_shift
if layer_penalty_factor is not None: kwargs["layer_penalty_factor"] = layer_penalty_factor
if position_temperature is not None: kwargs["position_temperature"] = position_temperature
if class_temperature is not None: kwargs["class_temperature"] = class_temperature
# Same cached-reference path as _run_inference: chunk 0 encodes
# the reference, chunks 1..N hit the cache instead of re-encoding.
from services.tts_backend import generate_with_cached_ref
raw = generate_with_cached_ref(
_model, ref_audio=ref_audio_path, ref_text=ref_text,
text=chunk_text, language=language, instruct=instruct,
duration=None, num_step=num_step,
guidance_scale=guidance_scale, speed=speed, denoise=denoise,
postprocess_output=postprocess_output, **kwargs
)[0]
sr = _model.sampling_rate if hasattr(_model, "sampling_rate") else 24000
skip = False
preview = _apply_effect_chain(raw, sr, effect_preset, skip_mastering=skip)
# The STREAMED copy is provenance-marked by the caller (#1169
# mark, moved off this GPU job in #1190): the preview PCM
# leaves the app the moment it's yielded, before
# _finalize_generation marks the assembled take, so it needs
# its own mark — but AudioSeal embedding is CPU work, and
# doing it here held the GPU worker for the whole embed on
# every one of N chunks. `raw` stays unmarked — the saved
# artifact gets exactly one whole-take mark in the finalize
# path (no double-embed on the file users keep).
return raw, preview, sr
except ValueError:
raise
except Exception as e:
_oom_friendly_reraise(e)
def _assemble_stream_chunks(parts, sr):
"""Concat + whole-take effect chain — the same tail the
non-streaming multi-chunk loop runs, as one pool job."""
from services.chunked_tts import concatenate_audio_chunks
try:
audio_out = concatenate_audio_chunks(parts, sr, crossfade_ms,
texts=_text_chunks,
sink=_dropped_sink)
skip = (getattr(_backend, "applies_own_mastering", False)
if _backend is not None else False)
return _apply_effect_chain(audio_out, sr, effect_preset, skip_mastering=skip)
except ValueError:
raise
except Exception as e:
_oom_friendly_reraise(e)
async def _stream_events():
import json
def _line(obj) -> bytes:
return (json.dumps(obj, separators=(",", ":")) + "\n").encode("utf-8")
try:
if _has_pause or len(_text_chunks) <= 1:
# Single-shot pipeline, unchanged — streamed as one chunk.
if _backend is not None:
audio_tensor = await _run_with_reference_lease(
ref_lease,
lambda release: run_on_gpu_pool_guarded(
functools.partial(
_run_backend_inference,
_backend, text, language, ref_audio_path, ref_text,
instruct, duration, num_step, guidance_scale, speed,
denoise, postprocess_output, used_seed, effect_preset,
max_chunk_chars, crossfade_ms, t_shift=t_shift,
layer_penalty_factor=layer_penalty_factor,
position_temperature=position_temperature,
class_temperature=class_temperature,
dropped_sink=_dropped_sink,
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
timeout=_generate_timeout_s(
text,
execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb,
hardware_family=_routing_hardware_family,
vram_gb=_routing_vram_gb,
),
on_abandon=release,
)
)
sample_rate = _backend.sample_rate
else:
audio_tensor = await _run_with_reference_lease(
ref_lease,
lambda release: run_on_gpu_pool_guarded(
functools.partial(
_run_inference,
_model, text, language, ref_audio_path, ref_text,
instruct, duration, num_step, guidance_scale, speed,
t_shift, denoise, postprocess_output,
layer_penalty_factor, position_temperature,
class_temperature, used_seed, effect_preset,
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_sink,
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
timeout=_generate_timeout_s(
text,
execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb,
hardware_family=_routing_hardware_family,
vram_gb=_routing_vram_gb,
),
on_abandon=release,
)
)
sample_rate = _model.sampling_rate
yield _line({
"type": "start", "sample_rate": sample_rate, "channels": 1,
"format": "pcm16", "total_chunks": 1, "crossfade_ms": 0,
"seed": used_seed,
})
# Provenance-mark the streamed copy (#1169): these PCM
# bytes leave the app before _finalize_generation marks
# the saved take. Marking a copy keeps the artifact's
# single whole-take mark (embed_watermark returns a new
# tensor; audio_tensor itself is untouched).
# Runs on the dedicated watermark pool, not the GPU pool
# (#1190): AudioSeal embedding is CPU work that owns no
# VRAM, and on a 1-worker host it used to serialize
# directly ahead of the next generate.
from services.watermark import mark_synthetic_async
_preview = await mark_synthetic_async(
audio_tensor, sample_rate,
context="generate.stream_preview",
)
yield _line({"type": "chunk", "seq": 0, "pcm": _pcm16_b64(_preview)})
else:
parts = []
sample_rate = None
for i, chunk_text in enumerate(_text_chunks):
# Bounded per chunk + pool-reset on hang (#730 class);
# a timeout surfaces as an "error" event below.
raw, preview, sample_rate = await _run_with_reference_lease(
ref_lease,
lambda release: run_on_gpu_pool_guarded(
functools.partial(_render_stream_chunk, i, chunk_text),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
# Budget scaled to THIS chunk (#1190) — the flat
# 300s here is what made long streamed renders fail
# even after the v0.3.22 scaled budget shipped.
timeout=_generate_timeout_s(
chunk_text,
execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb,
hardware_family=_routing_hardware_family,
vram_gb=_routing_vram_gb,
),
on_abandon=release,
)
)
parts.append(raw)
# Provenance-mark the streamed copy off the GPU pool
# (#1169 mark, #1190 placement): CPU-only AudioSeal
# work must not occupy a GPU worker between chunks.
from services.watermark import mark_synthetic_async
preview = await mark_synthetic_async(
preview, sample_rate,
context="generate.stream_preview",
)
if i == 0:
# After the first render so lazy-loading engines
# report their REAL sample rate (see /ws/tts).
yield _line({
"type": "start", "sample_rate": sample_rate,
"channels": 1, "format": "pcm16",
"total_chunks": len(_text_chunks),
"crossfade_ms": crossfade_ms, "seed": used_seed,
})
yield _line({"type": "chunk", "seq": i, "pcm": _pcm16_b64(preview)})
audio_tensor = await run_on_gpu_pool_guarded(
functools.partial(_assemble_stream_chunks, parts, sample_rate),
what="TTS assemble",
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
)
_, meta = await _finalize_generation(
audio_tensor, sample_rate, text=text, history_mode=history_mode,
ref_audio_path=ref_audio_path, language=language,
instruct=instruct, resolved_profile_id=resolved_profile_id,
used_seed=used_seed, start_time=start_time,
)
# #1330: before `done`, say what the take is missing. Its own
# frame rather than a `done` field so a consumer that only
# handles known types still surfaces it, and so the shape
# matches `error` (which clients already special-case).
if _dropped_sink:
yield _line({
"type": "warning", "code": "dropped_chunks",
"count": len(_dropped_sink),
"text": [t for t in _dropped_sink if t],
})
yield _line({
"type": "done", "id": meta["id"], "audio_path": meta["filename"],
"duration": meta["duration"], "gen_time": meta["gen_time"],
"seed": used_seed, "sample_rate": sample_rate,
"dropped_chunks": len(_dropped_sink),
})
except (asyncio.CancelledError, GeneratorExit):
# Client went away mid-stream — same semantics as aborting a
# classic /generate mid-render: nothing is saved.
raise
except GpuPoolBusyError as e:
# In-band error frame carries the machine-readable retryable
# marker (#1190) — an NDJSON consumer can back off instead of
# guessing from the prose.
logger.error("Streaming generation capacity unavailable")
from core.public_errors import stream_failure
failure = stream_failure("generation_busy")
failure["retry_after"] = getattr(e, "retry_after", 30)
yield _line({"type": "error", **failure})
except GpuJobTimeoutError:
# The worker started and spent its full execution budget. That
# is compute time, not queue pressure (#1588).
logger.error("Streaming generation exceeded its compute budget")
from core.public_errors import stream_failure
failure = stream_failure("generation_timeout")
failure["retry_after"] = 30
yield _line({"type": "error", **failure})
except ValueError:
logger.error("Streaming generation request rejected")
from core.public_errors import stream_failure
yield _line({"type": "error", **stream_failure("invalid_request")})
except Exception as exc:
# A streaming request answers 200 and carries its failure as an
# in-band error frame, so it never reaches the global 500
# handler — which is where a classic /generate failure gets its
# scrubbed journal entry (Diagnostics / recent errors) AND its
# classified, actionable message. Both have to be reproduced
# here or a streaming generation failure is invisible in the
# diagnostic bundle and opaque to the user (#1607). The raw
# exception is NOT logged: it can carry a reference-clip path or
# a provider secret, and only the journal scrubs before storing.
logger.error(
"Streaming generation failed unexpectedly (class=%s)",
type(exc).__name__,
)
from core.public_errors import stream_generation_failure
from core import error_journal
error_journal.record(
exc, route="/generate", trace=traceback.format_exc()
)
yield _line({"type": "error", **stream_generation_failure(exc)})
finally:
# Ownership of the temp reference clip moves to this generator
# in stream mode (the route returns before rendering starts).
if cleanup_ref and ref_lease is not None:
ref_lease.finish_request()
# Routing notice (#21): known before the stream starts, so it rides the
# same headers the classic path uses — and now also carries "your
# chosen worker was unavailable, this ran here".
_stream_headers = _apply_routing_headers({
"X-Seed": str(used_seed) if used_seed is not None else "",
"Cache-Control": "no-cache",
}, _routing_notice, _decision)
return StreamingResponse(
_stream_events(),
media_type="application/x-ndjson",
headers=_stream_headers,
)
# #1330: text whose chunk rendered to nothing. The engine dropping a chunk
# is silent in the waveform — the take sounds clean and is simply missing a
# sentence — so the render collects what it lost here and the response says
# so. A warning in a log the user never opens is a record of the bug, not a
# fix for it.
_dropped_text: list = []
_already_marked = False
try:
if _remote:
# One op, one worker, the whole render — including the chunk loop.
audio_tensor, sample_rate = await _render_on_worker()
_already_marked = True
else:
# The gateway owns the dispatch on both branches. Locally it still
# lands in run_on_gpu_pool_guarded, so the #730 bound + pool reset
# that keeps a wedged generate from bricking the backend is
# unchanged.
if _backend is not None:
_local_render = functools.partial(
_run_backend_inference,
_backend, text, language, ref_audio_path, ref_text, instruct,
duration, num_step, guidance_scale, speed, denoise,
postprocess_output, used_seed, effect_preset,
max_chunk_chars, crossfade_ms, t_shift=t_shift,
layer_penalty_factor=layer_penalty_factor,
position_temperature=position_temperature,
class_temperature=class_temperature,
dropped_sink=_dropped_text,
)
else:
_local_render = functools.partial(
_run_inference,
_model, text, language, ref_audio_path, ref_text, instruct, duration,
num_step, guidance_scale, speed, t_shift, denoise,
postprocess_output, layer_penalty_factor, position_temperature,
class_temperature, used_seed, effect_preset,
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_text,
)
audio_tensor = await _run_with_reference_lease(
ref_lease,
lambda release: gpu_gateway.run(
_REMOTE_OP,
local=gpu_gateway.LocalCall(
_local_render, what="TTS generate",
timeout=_generate_timeout_s(
text,
execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb,
hardware_family=_routing_hardware_family,
vram_gb=_routing_vram_gb,
),
min_vram_gb=_engine_min_vram_gb,
on_abandon=release,
),
decision=_decision,
)
)
# Read after generation: engines with lazy model loading report
# their real rate only once weights are up.
sample_rate = (_backend.sample_rate if _backend is not None
else _model.sampling_rate)
# Watermark → save → history → prune → emit, shared with the streaming
# path (see _finalize_generation) so both flows produce identical takes.
audio_tensor, _meta = await _finalize_generation(
audio_tensor, sample_rate, text=text, history_mode=history_mode,
ref_audio_path=ref_audio_path, language=language, instruct=instruct,
resolved_profile_id=resolved_profile_id, used_seed=used_seed,
start_time=start_time, already_marked=_already_marked,
)
audio_id = _meta["id"]
audio_filename = _meta["filename"]
audio_dur = _meta["duration"]
gen_time = _meta["gen_time"]
buffer = io.BytesIO()
_safe_torchaudio_save(buffer, audio_tensor, sample_rate, format="wav")
buffer.seek(0)
wav_bytes = buffer.read()
async def _stream_wav():
chunk_size = 16384
for i in range(0, len(wav_bytes), chunk_size):
yield wav_bytes[i:i + chunk_size]
_resp_headers = {
"X-Audio-Id": audio_id,
"X-Gen-Time": str(gen_time),
"X-Audio-Path": audio_filename,
"X-Seed": str(used_seed) if used_seed is not None else "",
"X-Audio-Duration": str(audio_dur),
"Content-Length": str(len(wav_bytes)),
}
# #1330: the body is a WAV, so the header channel carries the notice
# that some of the text produced no audio. Count first (always exact),
# then as much of the lost text as a header can safely hold.
if _dropped_text:
from services.engine_routing import header_safe_reason
_resp_headers["X-OmniVoice-Dropped-Chunks"] = str(len(_dropped_text))
_lost = header_safe_reason(" | ".join(t for t in _dropped_text if t))
if _lost:
_resp_headers["X-OmniVoice-Dropped-Text"] = _lost
# Routing notice (#21): cpu_fallback, accelerated-with-caveat, or the
# machine this render ran on. The WAV body is binary so the header
# channel is the carrier.
_apply_routing_headers(_resp_headers, _routing_notice, _decision)
return StreamingResponse(
_stream_wav(),
media_type="audio/wav",
headers=_resp_headers,
)
except HTTPException:
raise
except gpu_gateway.ModelNotDownloaded as e:
size_bytes = None
try:
from api.routers.setup.models import KNOWN_MODELS
sizes = [m.get("size_gb") for m in KNOWN_MODELS if m.get("repo_id") in e.repo_ids]
if sizes and all(size is not None for size in sizes):
size_bytes = int(sum(float(size) for size in sizes) * 1024**3)
except Exception:
pass
raise HTTPException(status_code=409, detail={
"error": "model_not_downloaded",
"message": str(e),
"engine": e.engine,
"repo_ids": e.repo_ids,
"size_bytes": size_bytes,
"target": e.target,
"target_label": e.target_label,
"downloadable": e.downloadable,
}) from e
except gpu_gateway.RemoteJobFailed as e:
# Rule 2 of the fallback policy: a single-shot interactive render that
# failed ON the worker is reported, not silently redone here. Minutes
# already went somewhere else, the user is watching, and quietly
# re-rendering on the slower machine turns a 20-second wait into a
# four-minute one with no explanation. The header names the target so
# the client can offer "run it on this machine instead" — a resubmit
# the user chose, with a wait they were told about.
logger.error("Remote generate failed on %s: %s", _target_label, e)
raise HTTPException(
status_code=503,
detail=f"{e} {e.hint or 'Run it on this machine instead, or pick another GPU.'}",
headers={"X-OmniVoice-Retryable": "true",
"X-OmniVoice-Routing": "remote_failed",
"Retry-After": "10"},
) from e
except GpuPoolBusyError as e:
# Saturation, not failure (#1190): the job never started, so the caller
# can retry the identical request. Retry-After + the retryable marker
# make that machine-readable for scripted clients.
logger.warning("Generate refused — GPU pool saturated: %s", e)
raise HTTPException(
status_code=503, detail=str(e),
headers={"Retry-After": str(e.retry_after),
"X-OmniVoice-Retryable": "true"},
) from e
except GpuJobTimeoutError as e:
# A generate that really ran and overran its budget (#730 class). The
# abandoned worker still holds the device until it drains — the message
# says so, and Retry-After spaces the retry out accordingly.
logger.error("Generate timed out: %s", e)
raise HTTPException(
status_code=503, detail=str(e),
headers={"Retry-After": "30", "X-OmniVoice-Retryable": "true"},
) from e
except InvalidBinaryError as e:
# #1172 class: a managed engine binary is a placeholder / corrupt /
# refused by the OS. The message carries the repair hint — surface it
# as 503 (engine unavailable), not a generic 500.
logger.error("Engine binary preflight failed: %s", e)
raise HTTPException(status_code=503, detail=str(e)) from e
except ValueError as e:
logger.error("Validation failed: %s", e)
# Most ValueErrors here are VoiceStudio's own validation messages and
# are exactly what the user should read. A few are raw library text
# naming parameters and files the user cannot act on — those get the
# owned remedy for their class instead (#1879). Unclassified ones keep
# passing through, so this cannot swallow a good message.
from core.failure import classify, public_hint_for_topic
_topic = classify(str(e))
_owned = public_hint_for_topic(_topic) if _topic else ""
raise HTTPException(status_code=400, detail=_owned or str(e)) from e
except Exception as e:
tb = traceback.format_exc()
logger.error("Inference failed: %s\n%s", e, tb)
raise HTTPException(
status_code=500,
detail=(
f"Couldn't synthesize audio. See Settings → Logs → Backend for the full trace. "
f"Underlying error: {_safe_exc_text(e)}"
),
)
finally:
if cleanup_ref and ref_lease is not None:
ref_lease.finish_request()
def _safe_output_path(name):
if not name:
return None
base = os.path.basename(name)
if base != name:
return None
outputs_real = os.path.realpath(OUTPUTS_DIR)
candidate = os.path.realpath(os.path.join(OUTPUTS_DIR, base))
if not candidate.startswith(outputs_real + os.sep):
return None
return candidate
def _remove_wav_if_unreferenced(conn, audio_path, exclude_ids=()):
"""Delete a history WAV from OUTPUTS_DIR — but only when no *other*
generation_history row still references the same file.
History WAVs are uniquely owned by their row (lock/save-as-profile COPY
into VOICES_DIR, exports copy to the user's destination), so this guard is
normally a no-op — it exists so any future path that duplicates a row can
never make a delete/prune yank audio out from under a surviving take."""
if not audio_path:
return
p = _safe_output_path(audio_path)
if not p or not os.path.exists(p):
return
placeholders = ",".join("?" for _ in exclude_ids)
others = conn.execute(
"SELECT COUNT(*) FROM generation_history WHERE audio_path=?"
+ (f" AND id NOT IN ({placeholders})" if exclude_ids else ""),
(audio_path, *exclude_ids),
).fetchone()[0]
if others:
return
with contextlib.suppress(OSError):
os.remove(p)
# How many takes to keep before pruning the oldest UNstarred ones (rows + their
# WAVs). User-tunable via Settings → Storage; 0 = unlimited. The pref key is
# shared with api/routers/settings.py (the GET/PUT endpoint) — same pattern as
# perf.torch_compile_disabled, which settings.py and engine_env.py both name.
HISTORY_CAP_PREF_KEY = "generation_history_cap"
DEFAULT_HISTORY_CAP = 200
def _history_cap() -> int:
from core import prefs
try:
cap = int(prefs.get(HISTORY_CAP_PREF_KEY, DEFAULT_HISTORY_CAP))
except (TypeError, ValueError):
return DEFAULT_HISTORY_CAP
return max(0, cap)
def _prune_history_over_cap() -> int:
"""Retention: keep the newest ``_history_cap()`` takes; delete the oldest
UNstarred rows over the cap plus their WAVs (via the unreferenced guard).
Starred takes are never pruned — even when they alone exceed the cap.
Returns the number of rows pruned."""
cap = _history_cap()
if cap <= 0:
return 0 # 0 = unlimited
with db_conn() as conn:
total = conn.execute("SELECT COUNT(*) FROM generation_history").fetchone()[0]
excess = total - cap
if excess <= 0:
return 0
victims = conn.execute(
"SELECT id, audio_path FROM generation_history "
"WHERE COALESCE(starred, 0)=0 ORDER BY created_at ASC LIMIT ?",
(excess,),
).fetchall()
if not victims:
return 0
victim_ids = [r["id"] for r in victims]
conn.executemany(
"DELETE FROM generation_history WHERE id=?", [(i,) for i in victim_ids]
)
for r in victims:
_remove_wav_if_unreferenced(conn, r["audio_path"], exclude_ids=victim_ids)
logger.info("history retention: pruned %d takes over the %d cap", len(victims), cap)
return len(victims)
@router.get("/history")
def list_history():
"""The newest 50 generations plus every starred take, newest first, kept to
rows whose audio still exists on disk.
Starred takes ride along past the 50-row window so a keeper can never age
off the rail. Rows whose WAV was deleted out-of-band (cleared outputs dir,
manual cleanup) used to come back anyway and render dead players that 404
on every fetch; prune them here so the UI never sees them again."""
query = (
"SELECT * FROM generation_history WHERE COALESCE(starred, 0)=1 "
"OR id IN (SELECT id FROM generation_history ORDER BY created_at DESC LIMIT 50) "
"ORDER BY created_at DESC"
)
with db_conn() as conn:
try:
rows = conn.execute(query).fetchall()
except sqlite3.OperationalError:
# Same class as #710/#552: a DB that missed init or the additive
# `starred` column. Heal once and retry inside this connection.
ensure_schema()
rows = conn.execute(query).fetchall()
alive, stale_ids = [], []
for r in rows:
p = _safe_output_path(r["audio_path"]) if r["audio_path"] else None
if r["audio_path"] and (not p or not os.path.exists(p)):
stale_ids.append(r["id"])
else:
alive.append(dict(r))
if stale_ids:
conn.executemany(
"DELETE FROM generation_history WHERE id=?",
[(i,) for i in stale_ids],
)
logger.info("pruned %d stale history rows (audio file gone)", len(stale_ids))
return alive
class _StarBody(BaseModel):
starred: bool
@router.put("/history/{history_id}/starred")
def set_history_starred(history_id: str, body: _StarBody):
"""Star/unstar a take. Starred takes survive the retention cap and always
appear in GET /history regardless of the recency window."""
def _update():
with db_conn() as conn:
cur = conn.execute(
"UPDATE generation_history SET starred=? WHERE id=?",
(1 if body.starred else 0, history_id),
)
return cur.rowcount
try:
changed = _update()
except sqlite3.OperationalError as e:
# `no such column: starred` on a pre-migration DB (or the #710
# missing-table class) — heal the schema and retry once.
logger.warning("star update failed (%s); healing schema + retrying", e)
ensure_schema()
changed = _update()
if not changed:
raise HTTPException(
status_code=404,
detail="That take no longer exists — it may have been pruned or deleted.",
)
event_bus.emit("generation_history", {"action": "starred", "id": history_id})
return {"id": history_id, "starred": body.starred}
@router.delete("/history")
def clear_history():
with db_conn() as conn:
rows = conn.execute("SELECT audio_path FROM generation_history").fetchall()
for r in rows:
p = _safe_output_path(r["audio_path"])
if p and os.path.exists(p):
with contextlib.suppress(OSError):
os.remove(p)
conn.execute("DELETE FROM generation_history")
event_bus.emit("generation_history")
return {"cleared": True}
@router.delete("/history/{history_id}")
def delete_single_history(history_id: str):
with db_conn() as conn:
row = conn.execute("SELECT audio_path FROM generation_history WHERE id=?", (history_id,)).fetchone()
conn.execute("DELETE FROM generation_history WHERE id=?", (history_id,))
if row:
# Row first, file second — the WAV goes only if no surviving take
# still references it (see _remove_wav_if_unreferenced).
_remove_wav_if_unreferenced(conn, row["audio_path"], exclude_ids=(history_id,))
event_bus.emit("generation_history", {"action": "deleted", "id": history_id})
return {"deleted": True}