Files
VoiceStudio/backend/api/routers/stories.py
T
Palash Debnath 5cab8e0149 feat: rename the product to VoiceStudio (previously OmniVoice-Studio)
Renames what users see. The app, the installers, the window title, the
docs and all 21 locales now say VoiceStudio, with "(previously
OmniVoice-Studio)" noted near the title of each doc surface so people
recognise it.

Deliberately NOT renamed, because renaming any of them silently breaks
an existing install — there is no legacy-path fallback anywhere in this
codebase:

  - bundle identifier com.debpalash.omnivoice-studio (MSI UpgradeCode,
    macOS TCC grants, managed venv, WebView localStorage, the
    single-instance lock)
  - data directories OmniVoice / .omnivoice and omnivoice.db
  - the ~150 OMNIVOICE_* environment variables
  - the X-OmniVoice-* HTTP headers (a wire protocol)
  - the published Docker image paths
  - the OmniVoice ENGINE, which is a model name and not this product

tests/test_identity_paths_survive_the_rename.py pins every one of those
so a future well-meaning sweep cannot orphan a user's library.

Linux .deb users install a new package name and should apt remove
omnivoice-studio; that note is in the changelog.
2026-08-07 01:30:58 +05:30

89 lines
3.3 KiB
Python

"""Stories Editor backend — audio encoding.
The Stories Editor stitches its audiobook client-side (Web Audio → WAV). This
endpoint optionally transcodes that WAV to a compressed container (MP3/M4B/OGG)
via ffmpeg, so users can export a small shareable file. Encoding runs through
`spawn_subprocess` (the Windows-`--reload`-safe helper, #122/#175).
"""
import asyncio
import os
import re
import tempfile
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from fastapi.responses import Response
from services.ffmpeg_utils import find_ffmpeg, spawn_subprocess
from core.http_headers import content_disposition
router = APIRouter()
# format -> (ffmpeg audio codec, mime type, file extension). Strict whitelist so
# the operator-uploaded `format` can never inject arbitrary ffmpeg arguments.
_FORMATS = {
"mp3": ("libmp3lame", "audio/mpeg", "mp3"),
"m4b": ("aac", "audio/mp4", "m4b"),
"ogg": ("libvorbis", "audio/ogg", "ogg"),
}
@router.post("/stories/encode")
async def stories_encode(
file: UploadFile = File(...),
format: str = Form("mp3"),
bitrate: str = Form("192k"),
):
"""Transcode an uploaded WAV to MP3/M4B/OGG and return the encoded bytes.
Provenance note (#1169): this endpoint is a pure TRANSCODER, not a
synthesis producer — it never calls a TTS engine, so it must not call
mark_synthetic (the upload may be arbitrary user audio, and marking human
speech as synthetic would be wrong). Audio the Stories Editor stitched
from VoiceStudio generations is already marked at its producing route, and
the AudioSeal mark survives the lossy encode here.
"""
fmt = (format or "mp3").lower()
if fmt not in _FORMATS:
raise HTTPException(status_code=400, detail=f"Unsupported format: {format}")
codec, mime, ext = _FORMATS[fmt]
if not re.match(r"^\d{2,3}k$", bitrate or ""):
bitrate = "192k"
ffmpeg = find_ffmpeg()
if not ffmpeg:
raise HTTPException(
status_code=501,
detail="ffmpeg not available. Install system ffmpeg or re-run the setup.",
)
data = await file.read()
in_path = out_path = None
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as f:
f.write(data)
in_path = f.name
out_path = f"{in_path[:-4]}.{ext}"
proc = await spawn_subprocess(
ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
"-i", in_path, "-c:a", codec, "-b:a", bitrate, out_path,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0 or not os.path.exists(out_path):
detail = (stderr.decode(errors="replace") if stderr else "") or "encode failed"
raise HTTPException(status_code=500, detail=detail[:300])
with open(out_path, "rb") as fh:
encoded = fh.read()
return Response(
content=encoded,
media_type=mime,
headers={"Content-Disposition": content_disposition(f"story.{ext}")},
)
finally:
for p in (in_path, out_path):
if p and os.path.exists(p):
try:
os.remove(p)
except OSError:
pass