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
80 lines
2.4 KiB
Python
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}")
|