Files
VoiceStudio/backend/api/routers/generation.py
T
Palash Debnath 2b8c8aec7c fix: actionable errors for non-executable engine binary (#437) + unreachable backend (#438/#454/#466) (#471)
Two reliability bugs from open issues, both first-run papercuts where the error
told the user the wrong thing.

#437 — `[Errno 13] Permission denied: bin/omnivoice-tts-linux-x86_64`: a git
clone / zip extract on POSIX can drop the bundled binary's execute bit. It only
surfaced at spawn time, and the generic synth handler then mislabeled it as
"ran out of memory" and told the user to flush the model.
- omnivoice_gguf.is_available() now self-heals: after the SHA check confirms the
  binary is the right file, it adds +x (best-effort) on POSIX; if it can't, it
  returns a clear "isn't executable — run chmod +x <path>" message instead of a
  spawn-time crash. No-op on Windows.
- generation.py classifies PermissionError / EACCES / "Permission denied" as its
  own case ("a bundled binary lost its execute bit — reinstall or chmod +x"),
  so it never again masquerades as OOM.

#438/#454/#466 — bare "Failed to fetch" / "NetworkError": when the local backend
is still starting, crashed, or the dev server dropped, fetch() throws a TypeError
that propagated raw to the user.
- client.ts apiFetch now catches the thrown fetch and raises an ApiError with an
  actionable message ("Can't reach the local OmniVoice backend — it may still be
  starting up… restart the app or check Settings → Logs"), status:0 to mark a
  transport failure vs an HTTP error.

Tests: client.test.ts +1 (thrown fetch → ApiError status 0 + actionable text);
3 pass. CJK guard green.
2026-06-14 21:46:52 +05:30

620 lines
27 KiB
Python

import os
import io
import uuid
import time
import asyncio
import tempfile
import contextlib
import logging
import traceback
from typing import Optional
from fastapi import APIRouter, File, Form, UploadFile, HTTPException
from fastapi.responses import StreamingResponse
from core.db import db_conn
from core.config import OUTPUTS_DIR, VOICES_DIR
from services.model_manager import get_model, _gpu_pool
from services.audio_io import _safe_torchaudio_save
from core import event_bus
router = APIRouter()
logger = logging.getLogger("omnivoice.generate")
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)))
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 _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 Compressor + Reverb chain that's tuned for
OmniVoice'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,
)
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 _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)
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
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}"
)
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,
):
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
# 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 model.generate(
text=span_text, language=language, ref_audio=ref_audio_path,
ref_text=ref_text, instruct=instruct, duration=None,
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
denoise=denoise, postprocess_output=postprocess_output,
**kwargs
)[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(model.generate(
text=chunk_text, language=language, ref_audio=ref_audio_path,
ref_text=ref_text, instruct=instruct, duration=None,
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
denoise=denoise, postprocess_output=postprocess_output,
**kwargs
)[0])
audio_out = concatenate_audio_chunks(parts, sr, _xfade_ms)
else:
audios = model.generate(
text=text, language=language, ref_audio=ref_audio_path,
ref_text=ref_text, instruct=instruct, duration=duration,
num_step=num_step, guidance_scale=guidance_scale, speed=speed,
denoise=denoise, postprocess_output=postprocess_output,
**kwargs
)
audio_out = audios[0]
# Apply DSP effect preset. The OmniVoice 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,
):
"""Engine-aware twin of :func:`_run_inference` (issue #312).
Runs the request through a pluggable ``TTSBackend`` adapter instead of the
OmniVoice model directly. The adapter protocol is narrower than the
OmniVoice-native surface — engine-specific extras (``t_shift``,
``layer_penalty_factor``, …) only exist on the native path, which is why
OmniVoice itself still goes through ``_run_inference``.
"""
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,
)
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:
def _gen_span(span_text):
# Per-span duration is left to the engine; an explicit overall
# `duration` can't be meaningfully split across spans.
return backend.generate(span_text, duration=None, **gen_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)
parts.append(backend.generate(chunk_text, duration=None, **gen_kwargs))
audio_out = concatenate_audio_chunks(parts, sr, _xfade_ms)
else:
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 e
except Exception as e:
_oom_friendly_reraise(e)
@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: int = Form(16),
guidance_scale: float = Form(2.0),
speed: float = Form(1.0),
t_shift: Optional[float] = Form(None),
denoise: bool = Form(True),
postprocess_output: bool = Form(True),
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),
):
# ── 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 (OmniVoice), 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()
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."
),
)
_model = None
_backend = None
if backend_cls is OmniVoiceBackend:
# OmniVoice 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 to the old 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).
from core.device_caps import detect_host_caps
from services.engine_routing import resolve_routing, routing_notice
_routing = resolve_routing(getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps())
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
ref_audio_path = None
cleanup_ref = False
used_seed = seed
resolved_profile_id = None
history_mode = None # profile.kind when a profile drives; else inferred at insert
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
# `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"
history_mode = profile_kind
if row["is_locked"] and row["locked_audio_path"]:
ref_audio_path = os.path.join(VOICES_DIR, row["locked_audio_path"])
if not ref_text:
ref_text = row["ref_text"]
if not instruct:
instruct = row["instruct"]
if used_seed is None and row["seed"] is not None:
used_seed = row["seed"]
elif profile_kind == "design":
# Rendered sample (if present) carries the voice identity;
# instruct alone is the fallback for legacy archetype rows.
ref_audio_path = os.path.join(VOICES_DIR, row["ref_audio_path"]) if row["ref_audio_path"] else None
if ref_audio_path and not ref_text and row["ref_text"]:
ref_text = row["ref_text"]
if not instruct:
instruct = row["instruct"]
if used_seed is None and row["seed"] is not None:
used_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 instruct:
instruct = row["instruct"]
if used_seed is None and row["seed"] is not None:
used_seed = row["seed"]
else:
ref_audio_path = os.path.join(VOICES_DIR, row["ref_audio_path"]) if row["ref_audio_path"] else None
if not ref_text and row["ref_text"]:
ref_text = row["ref_text"]
if not instruct and row["instruct"]:
instruct = row["instruct"]
if used_seed is None and row["seed"] is not None:
used_seed = row["seed"]
if language == "Auto":
language = None
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
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
ref_text = await asyncio.get_running_loop().run_in_executor(
_gpu_pool, transcribe_reference, ref_audio_path
)
start_time = time.time()
try:
loop = asyncio.get_running_loop()
if _backend is not None:
audio_tensor = await loop.run_in_executor(
_gpu_pool, _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,
)
# Read after generation: engines with lazy model loading report
# their real rate only once weights are up.
sample_rate = _backend.sample_rate
else:
audio_tensor = await loop.run_in_executor(
_gpu_pool, _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,
)
sample_rate = _model.sampling_rate
# 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. embed_watermark
# self-gates on the user's watermark setting + AudioSeal availability and
# passes the audio through unchanged on any failure, so it never breaks
# generation.
from services.watermark import embed_watermark
audio_tensor = await loop.run_in_executor(
_gpu_pool, embed_watermark, audio_tensor, sample_rate
)
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)
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())
)
event_bus.emit("generation_history", {"action": "created", "id": audio_id})
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)),
}
# Routing notice (#21): cpu_fallback or accelerated-with-caveat only;
# the WAV body is binary so the header channel is the carrier.
if _routing_notice:
from services.engine_routing import header_safe_reason
_resp_headers["X-OmniVoice-Routing"] = _routing_notice[0]
_hr = header_safe_reason(_routing_notice[1])
if _hr:
_resp_headers["X-OmniVoice-Routing-Reason"] = _hr
return StreamingResponse(
_stream_wav(),
media_type="audio/wav",
headers=_resp_headers,
)
except HTTPException:
raise
except ValueError as e:
logger.error("Validation failed: %s", e)
raise HTTPException(status_code=400, detail=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: {e}"
),
)
finally:
if cleanup_ref and ref_audio_path:
with contextlib.suppress(OSError):
os.remove(ref_audio_path)
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
@router.get("/history")
def list_history():
"""Newest 50 generations whose audio still exists on disk.
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."""
with db_conn() as conn:
rows = conn.execute(
"SELECT * FROM generation_history ORDER BY created_at DESC LIMIT 50"
).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
@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()
if row and row["audio_path"]:
p = _safe_output_path(row["audio_path"])
if p and os.path.exists(p):
with contextlib.suppress(OSError):
os.remove(p)
conn.execute("DELETE FROM generation_history WHERE id=?", (history_id,))
event_bus.emit("generation_history", {"action": "deleted", "id": history_id})
return {"deleted": True}