Files
VoiceStudio/tests/test_stories_encode.py
T
Palash DebnathandClaude Opus 4.8 a07da5f302 feat(stories): MP3 export — backend ffmpeg encode + format selector (#181)
Completes the pro-output story: an Export-format selector (WAV / MP3) on the
Stories toolbar. WAV stays fully client-side; MP3 routes the client-stitched
WAV through a new backend POST /stories/encode (ffmpeg via the Windows-reload-
safe spawn_subprocess, #175), with a strict format whitelist (mp3/m4b/ogg) and
bitrate validation so the uploaded format can't inject ffmpeg args. Both the
audiobook and per-character stems honor the selected format; MP3 falls back to
WAV with a toast if ffmpeg is unavailable.

- backend/api/routers/stories.py + main.py registration; temp-file cleanup.
- frontend/src/api/stories.ts encodeAudio() (apiFetch: same-origin + PIN).
- tests: format-whitelist 400, ffmpeg-missing 501, real mp3 encode (skips if
  ffmpeg absent). Backend 26 incl. router smoke; frontend 152/152, typecheck/
  build/CJK ✓.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:09:43 +05:30

57 lines
1.6 KiB
Python

"""Stories audio-encode endpoint (/stories/encode)."""
from __future__ import annotations
import io
import struct
import wave
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.routers import stories
from services.ffmpeg_utils import find_ffmpeg
def _app():
app = FastAPI()
app.include_router(stories.router)
return app
def _wav_bytes(sec=0.1, sr=8000):
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(sr)
w.writeframes(b"".join(struct.pack("<h", 0) for _ in range(int(sr * sec))))
return buf.getvalue()
def test_rejects_unknown_format():
c = TestClient(_app())
r = c.post("/stories/encode", files={"file": ("s.wav", _wav_bytes(), "audio/wav")}, data={"format": "exe"})
assert r.status_code == 400
def test_501_when_ffmpeg_missing(monkeypatch):
monkeypatch.setattr(stories, "find_ffmpeg", lambda: None)
c = TestClient(_app())
r = c.post("/stories/encode", files={"file": ("s.wav", _wav_bytes(), "audio/wav")}, data={"format": "mp3"})
assert r.status_code == 501
def test_encodes_mp3_when_ffmpeg_present():
if not find_ffmpeg():
pytest.skip("ffmpeg not available in this environment")
c = TestClient(_app())
r = c.post(
"/stories/encode",
files={"file": ("s.wav", _wav_bytes(0.2), "audio/wav")},
data={"format": "mp3", "bitrate": "96k"},
)
assert r.status_code == 200
assert r.headers["content-type"] == "audio/mpeg"
assert len(r.content) > 0