sparrow/app/api/endpoints/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

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)