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
67 lines
2.1 KiB
Python
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)
|