sparrow/app/services/export.py
pyr0ball a6f60c9e07 feat: implement Sparrow backend (v0.1.0)
Full FastAPI backend for the AI music continuation editor:

Services
- chain.py: chain + node CRUD, commit/discard, recursive CTE spine query
- musicgen.py: MusicGenClient with cf-orch allocation + mock mode (CF_MUSICGEN_MOCK=1)
- stems.py: Demucs 4-stem separation subprocess wrapper + mock mode
- export.py: ffmpeg concat demuxer to stitch committed spine into WAV/MP3

API endpoints
- chains: CRUD, multipart audio upload (WAV/MP3/FLAC/OGG/M4A/AIFF)
- nodes: branch creation (202 + BackgroundTasks), commit, discard, audio stream
- gpu: cf-orch capacity status; session allocation stubbed pending cf-orch#43
- stems: Paid-tier stem separation (Demucs, gated via tiers.py)
- export: POST /{chain_id}/export → FileResponse download
- events: SSE stream (node-status events) per chain via asyncio Queue pub/sub

Infrastructure
- lifespan: reads SPARROW_DB_PATH/DATA_DIR at startup (not import time)
- events_store: subscribe/unsubscribe/broadcast pattern for SSE
- CORS: open in dev, SPARROW_CORS_ORIGINS in production
- Background generation opens its own DB connection (WAL-safe)

Tests: 30/30 passing across service units and API integration
2026-04-17 15:22:37 -07:00

80 lines
2.4 KiB
Python

# app/services/export.py — Stitch committed spine audio files via ffmpeg
#
# Uses ffmpeg concat demuxer (list-form args, no shell injection).
# WAV output: copy codec. MP3 output: libmp3lame 320k.
from __future__ import annotations
import asyncio
import logging
import os
import shutil
import tempfile
from pathlib import Path
from typing import Literal
logger = logging.getLogger(__name__)
ExportFormat = Literal["wav", "mp3"]
async def stitch(
audio_paths: list[str],
output_path: str,
fmt: ExportFormat = "wav",
) -> str:
"""
Concatenate audio_paths in order and write to output_path.
Returns output_path on success. Raises RuntimeError on ffmpeg failure.
Requires ffmpeg on PATH.
"""
if not audio_paths:
raise ValueError("No audio paths to stitch.")
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
# Single file: just copy, no concatenation needed
if len(audio_paths) == 1:
shutil.copy2(audio_paths[0], output_path)
return output_path
# Write ffmpeg concat file list to a tempfile
with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False
) as flist:
for p in audio_paths:
# ffmpeg concat list format requires one "file '/abs/path'" per line
flist.write(f"file '{p}'\n")
flist_path = flist.name
try:
codec_args: list[str] = (
["-c:a", "copy"] if fmt == "wav"
else ["-c:a", "libmp3lame", "-b:a", "320k"]
)
# create_subprocess_exec uses a list of args — no shell interpolation
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-y",
"-f", "concat",
"-safe", "0",
"-i", flist_path,
*codec_args,
output_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(
f"ffmpeg concat failed (exit {proc.returncode}): {stderr.decode()}"
)
finally:
os.unlink(flist_path)
logger.info("Stitched %d files -> %s", len(audio_paths), output_path)
return output_path
def make_export_path(data_dir: str, chain_id: str, fmt: ExportFormat) -> str:
"""Standard export output path."""
return str(Path(data_dir) / "chains" / chain_id / f"export.{fmt}")