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
64 lines
2.3 KiB
Python
64 lines
2.3 KiB
Python
# app/api/endpoints/export.py — stitch committed spine and serve as download
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.responses import FileResponse
|
|
|
|
from app.api.deps import get_conn, get_data_dir
|
|
from app.models.schemas.node import ExportRequest
|
|
from app.services import chain as chain_svc
|
|
from app.services.export import ExportFormat, make_export_path, stitch
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/chains", tags=["export"])
|
|
|
|
|
|
@router.post("/{chain_id}/export")
|
|
async def export_chain(
|
|
chain_id: str,
|
|
body: ExportRequest,
|
|
conn=Depends(get_conn),
|
|
data_dir: str = Depends(get_data_dir),
|
|
) -> FileResponse:
|
|
"""
|
|
Stitch node audio files into a single download.
|
|
|
|
If body.node_ids is provided, those nodes are used in order.
|
|
Otherwise the committed spine (root → leaf) is used.
|
|
Raises 409 if any targeted node has no audio.
|
|
"""
|
|
if body.node_ids is not None:
|
|
nodes = [chain_svc.get_node(conn, nid) for nid in body.node_ids]
|
|
if any(n is None for n in nodes):
|
|
raise HTTPException(status_code=404, detail="One or more nodes not found.")
|
|
else:
|
|
nodes = chain_svc.get_committed_spine(conn, chain_id)
|
|
if not nodes:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Chain not found or has no committed nodes.",
|
|
)
|
|
|
|
audio_paths = [n["audio_path"] for n in nodes if n] # type: ignore[index]
|
|
missing = [n["id"] for n in nodes if n and n["audio_path"] is None] # type: ignore[index]
|
|
if missing:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Nodes {missing} have no audio yet.",
|
|
)
|
|
|
|
fmt: ExportFormat = body.format # type: ignore[assignment]
|
|
output_path = make_export_path(data_dir, chain_id, fmt)
|
|
|
|
try:
|
|
await stitch(audio_paths, output_path, fmt)
|
|
except Exception as exc:
|
|
logger.exception("Export failed for chain %s: %s", chain_id, exc)
|
|
raise HTTPException(status_code=500, detail=f"Export failed: {exc}") from exc
|
|
|
|
media_type = "audio/wav" if fmt == "wav" else "audio/mpeg"
|
|
filename = f"sparrow_export_{chain_id[:8]}.{fmt}"
|
|
return FileResponse(output_path, media_type=media_type, filename=filename)
|