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

67 lines
2.1 KiB
Python

# app/api/endpoints/stems.py — 4-stem separation (Paid tier)
from __future__ import annotations
import logging
from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from app.api.deps import get_conn, get_data_dir
from app.models.schemas.node import StemResult
from app.services import chain as chain_svc
from app.services import stems as stems_svc
from app.tiers import require_tier
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/stems", tags=["stems"])
@router.post("/{node_id}", response_model=StemResult, status_code=202)
async def separate_stems(
node_id: str,
background_tasks: BackgroundTasks,
tier: str | None = None,
conn=Depends(get_conn),
data_dir: str = Depends(get_data_dir),
) -> dict:
"""
Run 4-stem separation (Demucs htdemucs) on a ready node.
Requires Paid tier. Returns stem file paths immediately — paths will
be valid once the background task completes. Poll GET /api/nodes/{node_id}
or listen on the chain SSE stream for completion signals.
tier: passed as query param, e.g. ?tier=paid
"""
require_tier("stems", tier)
node = chain_svc.get_node(conn, node_id)
if node is None:
raise HTTPException(status_code=404, detail="Node not found.")
if node["audio_path"] is None:
raise HTTPException(status_code=409, detail="Node has no audio.")
stems_dir = stems_svc.make_stems_dir(data_dir, node["chain_id"], node_id)
stem_paths = stems_svc._stem_paths(stems_dir, Path(node["audio_path"]).stem)
background_tasks.add_task(
_run_separation,
source_audio_path=node["audio_path"],
stems_dir=stems_dir,
)
return StemResult(
node_id=node_id,
vocals=stem_paths["vocals"],
drums=stem_paths["drums"],
bass=stem_paths["bass"],
other=stem_paths["other"],
)
async def _run_separation(source_audio_path: str, stems_dir: str) -> None:
try:
await stems_svc.separate(source_audio_path, stems_dir)
logger.info("Stems complete: %s", stems_dir)
except Exception as exc:
logger.exception("Stem separation failed: %s", exc)