remixflow 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- remixflow-0.1.0/.gitignore +35 -0
- remixflow-0.1.0/PKG-INFO +88 -0
- remixflow-0.1.0/README.md +65 -0
- remixflow-0.1.0/pyproject.toml +45 -0
- remixflow-0.1.0/remixflow/__init__.py +12 -0
- remixflow-0.1.0/remixflow/__main__.py +36 -0
- remixflow-0.1.0/remixflow/app.py +281 -0
- remixflow-0.1.0/remixflow/audio/__init__.py +1 -0
- remixflow-0.1.0/remixflow/audio/analysis.py +121 -0
- remixflow-0.1.0/remixflow/audio/io.py +87 -0
- remixflow-0.1.0/remixflow/config.py +28 -0
- remixflow-0.1.0/remixflow/generation/__init__.py +16 -0
- remixflow-0.1.0/remixflow/generation/acestep.py +378 -0
- remixflow-0.1.0/remixflow/generation/base.py +55 -0
- remixflow-0.1.0/remixflow/generation/dsp.py +175 -0
- remixflow-0.1.0/remixflow/generation/registry.py +51 -0
- remixflow-0.1.0/remixflow/jobs.py +92 -0
- remixflow-0.1.0/remixflow/living.py +254 -0
- remixflow-0.1.0/remixflow/models.py +158 -0
- remixflow-0.1.0/remixflow/params.py +134 -0
- remixflow-0.1.0/remixflow/presets.py +89 -0
- remixflow-0.1.0/remixflow/server.py +5 -0
- remixflow-0.1.0/remixflow/service.py +247 -0
- remixflow-0.1.0/remixflow/static/assets/index-Bo4yHRF4.js +40 -0
- remixflow-0.1.0/remixflow/static/assets/index-CpEM8inD.css +1 -0
- remixflow-0.1.0/remixflow/static/index.html +13 -0
- remixflow-0.1.0/remixflow/store.py +116 -0
- remixflow-0.1.0/scripts/build_ab_page.py +273 -0
- remixflow-0.1.0/scripts/make_ab_excerpts.py +50 -0
- remixflow-0.1.0/scripts/make_living.py +86 -0
- remixflow-0.1.0/scripts/make_var50_full.py +40 -0
- remixflow-0.1.0/scripts/make_var50_keepvocals.py +66 -0
- remixflow-0.1.0/scripts/try_acestep.py +67 -0
- remixflow-0.1.0/tests/test_pipeline.py +144 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
|
|
11
|
+
# RemixFlow runtime data (imported songs + generated audio)
|
|
12
|
+
remixflow_data/
|
|
13
|
+
*_data/
|
|
14
|
+
|
|
15
|
+
# Node / frontend
|
|
16
|
+
node_modules/
|
|
17
|
+
frontend/dist/
|
|
18
|
+
|
|
19
|
+
# Built UI is generated by `npm run build` (shipped in the wheel, not committed)
|
|
20
|
+
backend/remixflow/static/assets/
|
|
21
|
+
backend/remixflow/static/index.html
|
|
22
|
+
|
|
23
|
+
# OS / editor
|
|
24
|
+
.DS_Store
|
|
25
|
+
*.swp
|
|
26
|
+
|
|
27
|
+
# Test audio
|
|
28
|
+
*.mp3
|
|
29
|
+
|
|
30
|
+
# Audio outputs (derived works / large binaries)
|
|
31
|
+
*.wav
|
|
32
|
+
samples/
|
|
33
|
+
|
|
34
|
+
# TS build info
|
|
35
|
+
frontend/tsconfig.tsbuildinfo
|
remixflow-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: remixflow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An AI-powered music evolution platform — generate endless familiar-but-fresh variations of a song.
|
|
5
|
+
Project-URL: Homepage, https://github.com/fbobe321/remixflow
|
|
6
|
+
Project-URL: Repository, https://github.com/fbobe321/remixflow
|
|
7
|
+
Author: RemixFlow
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: ai,audio,diffusion,generation,music,remix
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: fastapi>=0.110
|
|
12
|
+
Requires-Dist: numpy>=1.24
|
|
13
|
+
Requires-Dist: pydantic>=2.6
|
|
14
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
15
|
+
Requires-Dist: soundfile>=0.12
|
|
16
|
+
Requires-Dist: uvicorn[standard]>=0.29
|
|
17
|
+
Provides-Extra: audio
|
|
18
|
+
Requires-Dist: librosa>=0.10; extra == 'audio'
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# RemixFlow — backend
|
|
25
|
+
|
|
26
|
+
FastAPI backend for the RemixFlow music-evolution platform. See the repo-root
|
|
27
|
+
`README.md` for the full picture; this file covers the Python package.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install -e ".[audio,dev]" # audio extra adds librosa (tempo/pitch/key)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Core deps are pure PyPI wheels. The bundled libsndfile (via `soundfile`) reads
|
|
36
|
+
**and writes** MP3/WAV/FLAC/OGG with no system packages — **no ffmpeg needed**.
|
|
37
|
+
The `audio` extra pulls in `librosa` for tempo/key detection and
|
|
38
|
+
time-stretch/pitch-shift.
|
|
39
|
+
|
|
40
|
+
## Run
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
remixflow serve # http://127.0.0.1:8770 (API + built UI)
|
|
44
|
+
remixflow serve --reload # dev auto-reload
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Interactive API docs at `/docs`.
|
|
48
|
+
|
|
49
|
+
## Layout
|
|
50
|
+
|
|
51
|
+
| Path | Role |
|
|
52
|
+
|------|------|
|
|
53
|
+
| `params.py` | Single source of truth for every steering control & identity lock (PRD §3, §4). Served to the UI via `/api/controls`. |
|
|
54
|
+
| `models.py` | Pydantic schemas: `Song`, `Variant`, `Steering`, evolution `TreeNode`. |
|
|
55
|
+
| `audio/io.py` | Load/save with graceful degradation (soundfile → librosa). |
|
|
56
|
+
| `audio/analysis.py` | Feature extraction + Musical-DNA embedding + similarity. |
|
|
57
|
+
| `generation/base.py` | The `Generator` contract every backend implements. |
|
|
58
|
+
| `generation/dsp.py` | Reference DSP backend — real, audible variations today. |
|
|
59
|
+
| `generation/registry.py` | Plug point for model backends (ACE-Step, Stable Audio, MusicGen). |
|
|
60
|
+
| `service.py` | Orchestration: import → steer → generate → evaluate → branch. |
|
|
61
|
+
| `store.py` | File-backed store (JSON + audio dir); swap for a DB later. |
|
|
62
|
+
| `app.py` | FastAPI routes; serves the built React UI from `static/`. |
|
|
63
|
+
|
|
64
|
+
## Adding a real model backend
|
|
65
|
+
|
|
66
|
+
Implement `Generator` and register it:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from remixflow.generation import Generator, register_generator
|
|
70
|
+
|
|
71
|
+
class AceStepBackend(Generator):
|
|
72
|
+
name = "ace-step"
|
|
73
|
+
description = "ACE-Step 1.5 diffusion"
|
|
74
|
+
def generate(self, parent, steering, *, original=None, seed=None):
|
|
75
|
+
... # map steering -> latent edits, run the model, return GenerationResult
|
|
76
|
+
|
|
77
|
+
register_generator(AceStepBackend())
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
It then appears in `/api/backends` and is selectable via `POST /api/generate?backend=ace-step`.
|
|
81
|
+
|
|
82
|
+
## Config (env vars)
|
|
83
|
+
|
|
84
|
+
| Var | Default | Meaning |
|
|
85
|
+
|-----|---------|---------|
|
|
86
|
+
| `REMIXFLOW_DATA_DIR` | `./remixflow_data` | Where songs/variants/audio persist. |
|
|
87
|
+
| `REMIXFLOW_MAX_UPLOAD_MB` | `50` | Upload size cap. |
|
|
88
|
+
| `REMIXFLOW_CORS` | `*` | Comma-separated allowed origins. |
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# RemixFlow — backend
|
|
2
|
+
|
|
3
|
+
FastAPI backend for the RemixFlow music-evolution platform. See the repo-root
|
|
4
|
+
`README.md` for the full picture; this file covers the Python package.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install -e ".[audio,dev]" # audio extra adds librosa (tempo/pitch/key)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Core deps are pure PyPI wheels. The bundled libsndfile (via `soundfile`) reads
|
|
13
|
+
**and writes** MP3/WAV/FLAC/OGG with no system packages — **no ffmpeg needed**.
|
|
14
|
+
The `audio` extra pulls in `librosa` for tempo/key detection and
|
|
15
|
+
time-stretch/pitch-shift.
|
|
16
|
+
|
|
17
|
+
## Run
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
remixflow serve # http://127.0.0.1:8770 (API + built UI)
|
|
21
|
+
remixflow serve --reload # dev auto-reload
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Interactive API docs at `/docs`.
|
|
25
|
+
|
|
26
|
+
## Layout
|
|
27
|
+
|
|
28
|
+
| Path | Role |
|
|
29
|
+
|------|------|
|
|
30
|
+
| `params.py` | Single source of truth for every steering control & identity lock (PRD §3, §4). Served to the UI via `/api/controls`. |
|
|
31
|
+
| `models.py` | Pydantic schemas: `Song`, `Variant`, `Steering`, evolution `TreeNode`. |
|
|
32
|
+
| `audio/io.py` | Load/save with graceful degradation (soundfile → librosa). |
|
|
33
|
+
| `audio/analysis.py` | Feature extraction + Musical-DNA embedding + similarity. |
|
|
34
|
+
| `generation/base.py` | The `Generator` contract every backend implements. |
|
|
35
|
+
| `generation/dsp.py` | Reference DSP backend — real, audible variations today. |
|
|
36
|
+
| `generation/registry.py` | Plug point for model backends (ACE-Step, Stable Audio, MusicGen). |
|
|
37
|
+
| `service.py` | Orchestration: import → steer → generate → evaluate → branch. |
|
|
38
|
+
| `store.py` | File-backed store (JSON + audio dir); swap for a DB later. |
|
|
39
|
+
| `app.py` | FastAPI routes; serves the built React UI from `static/`. |
|
|
40
|
+
|
|
41
|
+
## Adding a real model backend
|
|
42
|
+
|
|
43
|
+
Implement `Generator` and register it:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from remixflow.generation import Generator, register_generator
|
|
47
|
+
|
|
48
|
+
class AceStepBackend(Generator):
|
|
49
|
+
name = "ace-step"
|
|
50
|
+
description = "ACE-Step 1.5 diffusion"
|
|
51
|
+
def generate(self, parent, steering, *, original=None, seed=None):
|
|
52
|
+
... # map steering -> latent edits, run the model, return GenerationResult
|
|
53
|
+
|
|
54
|
+
register_generator(AceStepBackend())
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
It then appears in `/api/backends` and is selectable via `POST /api/generate?backend=ace-step`.
|
|
58
|
+
|
|
59
|
+
## Config (env vars)
|
|
60
|
+
|
|
61
|
+
| Var | Default | Meaning |
|
|
62
|
+
|-----|---------|---------|
|
|
63
|
+
| `REMIXFLOW_DATA_DIR` | `./remixflow_data` | Where songs/variants/audio persist. |
|
|
64
|
+
| `REMIXFLOW_MAX_UPLOAD_MB` | `50` | Upload size cap. |
|
|
65
|
+
| `REMIXFLOW_CORS` | `*` | Comma-separated allowed origins. |
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "remixflow"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "An AI-powered music evolution platform — generate endless familiar-but-fresh variations of a song."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "RemixFlow" }]
|
|
13
|
+
keywords = ["music", "audio", "generation", "diffusion", "ai", "remix"]
|
|
14
|
+
|
|
15
|
+
# Core deps — all pip-installable from PyPI, no system packages required to boot.
|
|
16
|
+
dependencies = [
|
|
17
|
+
"fastapi>=0.110",
|
|
18
|
+
"uvicorn[standard]>=0.29",
|
|
19
|
+
"pydantic>=2.6",
|
|
20
|
+
"python-multipart>=0.0.9",
|
|
21
|
+
"numpy>=1.24",
|
|
22
|
+
"soundfile>=0.12", # WAV/FLAC/OGG read/write via libsndfile (bundled wheels)
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
# Richer analysis + time-stretch/pitch-shift. librosa reaches MP3 via ffmpeg.
|
|
27
|
+
audio = ["librosa>=0.10"]
|
|
28
|
+
dev = ["pytest>=8.0", "httpx>=0.27"]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
remixflow = "remixflow.__main__:main"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/fbobe321/remixflow"
|
|
35
|
+
Repository = "https://github.com/fbobe321/remixflow"
|
|
36
|
+
|
|
37
|
+
# The built React UI is emitted into remixflow/static (see frontend build) and is
|
|
38
|
+
# .gitignored (build output). `artifacts` forces it into the sdist/wheel anyway,
|
|
39
|
+
# so `pip install remixflow` serves the app with no Node.
|
|
40
|
+
[tool.hatch.build]
|
|
41
|
+
artifacts = ["remixflow/static/**/*"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["remixflow"]
|
|
45
|
+
artifacts = ["remixflow/static/**/*"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""RemixFlow — an AI music evolution platform (see PRD.md).
|
|
2
|
+
|
|
3
|
+
This package hosts the FastAPI backend: song import, feature extraction, a
|
|
4
|
+
pluggable steering/generation engine, an evolution tree, A/B comparison,
|
|
5
|
+
morphing, and preference learning.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
from .app import create_app
|
|
11
|
+
|
|
12
|
+
__all__ = ["create_app", "__version__"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""CLI entrypoint: ``python -m remixflow serve`` / ``remixflow serve``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
parser = argparse.ArgumentParser(prog="remixflow", description="RemixFlow server")
|
|
12
|
+
parser.add_argument("--version", action="version", version=f"remixflow {__version__}")
|
|
13
|
+
sub = parser.add_subparsers(dest="command")
|
|
14
|
+
|
|
15
|
+
serve = sub.add_parser("serve", help="Run the API + UI server")
|
|
16
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
17
|
+
# 8770 default: this host runs other services on 8000 and 8188 (llama.cpp).
|
|
18
|
+
serve.add_argument("--port", type=int, default=8770)
|
|
19
|
+
serve.add_argument("--reload", action="store_true", help="Auto-reload (dev)")
|
|
20
|
+
|
|
21
|
+
args = parser.parse_args()
|
|
22
|
+
if args.command in (None, "serve"):
|
|
23
|
+
import uvicorn
|
|
24
|
+
|
|
25
|
+
host = getattr(args, "host", "127.0.0.1")
|
|
26
|
+
port = getattr(args, "port", 8000)
|
|
27
|
+
reload = getattr(args, "reload", False)
|
|
28
|
+
# Import string form enables --reload.
|
|
29
|
+
uvicorn.run("remixflow.server:app", host=host, port=port, reload=reload,
|
|
30
|
+
factory=False)
|
|
31
|
+
else: # pragma: no cover
|
|
32
|
+
parser.print_help()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
main()
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""FastAPI application: import, steering, generation, evolution tree, A/B,
|
|
2
|
+
morphing, and preference learning. Serves the built React UI when present."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fastapi import Depends, FastAPI, File, HTTPException, Query, UploadFile
|
|
11
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
12
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
13
|
+
from fastapi.staticfiles import StaticFiles
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .audio.io import available as audio_available
|
|
17
|
+
from .config import STATIC_DIR, Settings
|
|
18
|
+
from .generation import list_generators
|
|
19
|
+
from .jobs import JobManager
|
|
20
|
+
from .models import GenerateRequest, LivingRequest, MorphRequest, PresetCreate, RateRequest
|
|
21
|
+
from .params import controls_manifest
|
|
22
|
+
from .presets import PresetStore
|
|
23
|
+
from .service import RemixService, ServiceError
|
|
24
|
+
from .store import Store
|
|
25
|
+
|
|
26
|
+
ALLOWED_EXT = {".mp3", ".wav", ".flac", ".ogg"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
30
|
+
settings = settings or Settings.from_env()
|
|
31
|
+
store = Store(settings.data_dir)
|
|
32
|
+
service = RemixService(store)
|
|
33
|
+
jobs = JobManager(max_workers=1)
|
|
34
|
+
presets = PresetStore(settings.data_dir)
|
|
35
|
+
|
|
36
|
+
app = FastAPI(title="RemixFlow", version=__version__)
|
|
37
|
+
app.add_middleware(
|
|
38
|
+
CORSMiddleware,
|
|
39
|
+
allow_origins=settings.cors_origins,
|
|
40
|
+
allow_methods=["*"],
|
|
41
|
+
allow_headers=["*"],
|
|
42
|
+
)
|
|
43
|
+
app.state.store = store
|
|
44
|
+
app.state.service = service
|
|
45
|
+
app.state.settings = settings
|
|
46
|
+
app.state.jobs = jobs
|
|
47
|
+
|
|
48
|
+
def get_service() -> RemixService:
|
|
49
|
+
return service
|
|
50
|
+
|
|
51
|
+
# --- meta ------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
@app.get("/api/health")
|
|
54
|
+
def health() -> dict:
|
|
55
|
+
return {
|
|
56
|
+
"status": "ok",
|
|
57
|
+
"version": __version__,
|
|
58
|
+
"audio_backend": audio_available(),
|
|
59
|
+
"backends": list_generators(),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@app.get("/api/controls")
|
|
63
|
+
def controls() -> dict:
|
|
64
|
+
"""The full steering control surface for the UI to render."""
|
|
65
|
+
return controls_manifest()
|
|
66
|
+
|
|
67
|
+
@app.get("/api/backends")
|
|
68
|
+
def backends() -> list:
|
|
69
|
+
return list_generators()
|
|
70
|
+
|
|
71
|
+
# --- songs -----------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
@app.post("/api/songs")
|
|
74
|
+
async def import_song(
|
|
75
|
+
file: UploadFile = File(...),
|
|
76
|
+
title: str = Query(default=""),
|
|
77
|
+
svc: RemixService = Depends(get_service),
|
|
78
|
+
) -> dict:
|
|
79
|
+
ext = Path(file.filename or "").suffix.lower()
|
|
80
|
+
if ext not in ALLOWED_EXT:
|
|
81
|
+
raise HTTPException(400, f"Unsupported format {ext!r}. Allowed: {sorted(ALLOWED_EXT)}")
|
|
82
|
+
|
|
83
|
+
max_bytes = settings.max_upload_mb * 1024 * 1024
|
|
84
|
+
data = await file.read(max_bytes + 1)
|
|
85
|
+
if len(data) > max_bytes:
|
|
86
|
+
raise HTTPException(413, f"File exceeds {settings.max_upload_mb} MB limit.")
|
|
87
|
+
|
|
88
|
+
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
|
|
89
|
+
tmp.write(data)
|
|
90
|
+
tmp_path = tmp.name
|
|
91
|
+
try:
|
|
92
|
+
song, root = svc.import_song(tmp_path, title, file.filename or "")
|
|
93
|
+
except ServiceError as exc:
|
|
94
|
+
raise HTTPException(422, str(exc))
|
|
95
|
+
finally:
|
|
96
|
+
os.unlink(tmp_path)
|
|
97
|
+
return {"song": song.model_dump(), "root": root.model_dump()}
|
|
98
|
+
|
|
99
|
+
@app.get("/api/songs")
|
|
100
|
+
def list_songs() -> list:
|
|
101
|
+
return [s.model_dump() for s in store.list_songs()]
|
|
102
|
+
|
|
103
|
+
@app.get("/api/songs/{song_id}")
|
|
104
|
+
def get_song(song_id: str) -> dict:
|
|
105
|
+
song = store.get_song(song_id)
|
|
106
|
+
if not song:
|
|
107
|
+
raise HTTPException(404, "Song not found")
|
|
108
|
+
return song.model_dump()
|
|
109
|
+
|
|
110
|
+
@app.delete("/api/songs/{song_id}")
|
|
111
|
+
def delete_song(song_id: str) -> dict:
|
|
112
|
+
if not store.delete_song(song_id):
|
|
113
|
+
raise HTTPException(404, "Song not found")
|
|
114
|
+
return {"deleted": song_id}
|
|
115
|
+
|
|
116
|
+
@app.get("/api/songs/{song_id}/tree")
|
|
117
|
+
def get_tree(song_id: str) -> dict:
|
|
118
|
+
tree = store.tree(song_id)
|
|
119
|
+
if tree is None:
|
|
120
|
+
raise HTTPException(404, "Song not found")
|
|
121
|
+
return tree.model_dump()
|
|
122
|
+
|
|
123
|
+
@app.get("/api/songs/{song_id}/preferences")
|
|
124
|
+
def preferences(song_id: str, svc: RemixService = Depends(get_service)) -> dict:
|
|
125
|
+
if not store.get_song(song_id):
|
|
126
|
+
raise HTTPException(404, "Song not found")
|
|
127
|
+
return svc.preference_profile(song_id)
|
|
128
|
+
|
|
129
|
+
# --- variants --------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
@app.get("/api/variants/{variant_id}")
|
|
132
|
+
def get_variant(variant_id: str) -> dict:
|
|
133
|
+
v = store.get_variant(variant_id)
|
|
134
|
+
if not v:
|
|
135
|
+
raise HTTPException(404, "Variant not found")
|
|
136
|
+
return v.model_dump()
|
|
137
|
+
|
|
138
|
+
@app.post("/api/generate", status_code=202)
|
|
139
|
+
def generate(
|
|
140
|
+
req: GenerateRequest,
|
|
141
|
+
backend: str | None = Query(default=None),
|
|
142
|
+
seed: int | None = Query(default=None),
|
|
143
|
+
svc: RemixService = Depends(get_service),
|
|
144
|
+
) -> dict:
|
|
145
|
+
"""Enqueue a generation job. Returns a job the client polls; real model
|
|
146
|
+
backends (ACE-Step) run for tens of seconds, so this is never blocking."""
|
|
147
|
+
parent = store.get_variant(req.parent_id)
|
|
148
|
+
if parent is None:
|
|
149
|
+
raise HTTPException(422, f"Unknown parent variant {req.parent_id!r}.")
|
|
150
|
+
|
|
151
|
+
def work(report) -> dict:
|
|
152
|
+
report(0.05, "Preparing…")
|
|
153
|
+
report(0.15, f"Generating with {backend or 'default'} backend…")
|
|
154
|
+
variant = svc.generate(req, backend=backend, seed=seed)
|
|
155
|
+
report(0.98, "Finalizing…")
|
|
156
|
+
return variant.model_dump()
|
|
157
|
+
|
|
158
|
+
job = jobs.submit("generate", work, song_id=parent.song_id)
|
|
159
|
+
return job.to_dict()
|
|
160
|
+
|
|
161
|
+
@app.get("/api/jobs/{job_id}")
|
|
162
|
+
def get_job(job_id: str) -> dict:
|
|
163
|
+
job = jobs.get(job_id)
|
|
164
|
+
if not job:
|
|
165
|
+
raise HTTPException(404, "Job not found")
|
|
166
|
+
return job.to_dict()
|
|
167
|
+
|
|
168
|
+
@app.get("/api/jobs")
|
|
169
|
+
def recent_jobs() -> list:
|
|
170
|
+
return [j.to_dict() for j in jobs.recent()]
|
|
171
|
+
|
|
172
|
+
@app.post("/api/generate/sync")
|
|
173
|
+
def generate_sync(
|
|
174
|
+
req: GenerateRequest,
|
|
175
|
+
backend: str | None = Query(default=None),
|
|
176
|
+
seed: int | None = Query(default=None),
|
|
177
|
+
svc: RemixService = Depends(get_service),
|
|
178
|
+
) -> dict:
|
|
179
|
+
"""Blocking generation — convenient for tests/scripts and fast backends."""
|
|
180
|
+
try:
|
|
181
|
+
variant = svc.generate(req, backend=backend, seed=seed)
|
|
182
|
+
except ServiceError as exc:
|
|
183
|
+
raise HTTPException(422, str(exc))
|
|
184
|
+
return variant.model_dump()
|
|
185
|
+
|
|
186
|
+
@app.post("/api/morph")
|
|
187
|
+
def morph(req: MorphRequest, svc: RemixService = Depends(get_service)) -> dict:
|
|
188
|
+
try:
|
|
189
|
+
variant = svc.morph(req)
|
|
190
|
+
except ServiceError as exc:
|
|
191
|
+
raise HTTPException(422, str(exc))
|
|
192
|
+
return variant.model_dump()
|
|
193
|
+
|
|
194
|
+
@app.post("/api/variants/{variant_id}/rate")
|
|
195
|
+
def rate(variant_id: str, req: RateRequest, svc: RemixService = Depends(get_service)) -> dict:
|
|
196
|
+
try:
|
|
197
|
+
variant = svc.rate(variant_id, req.rating)
|
|
198
|
+
except ServiceError as exc:
|
|
199
|
+
raise HTTPException(404, str(exc))
|
|
200
|
+
return variant.model_dump()
|
|
201
|
+
|
|
202
|
+
# --- Living Mode (PRD Phase 2) ---------------------------------------
|
|
203
|
+
|
|
204
|
+
@app.post("/api/living", status_code=202)
|
|
205
|
+
def living(
|
|
206
|
+
req: LivingRequest,
|
|
207
|
+
backend: str | None = Query(default=None),
|
|
208
|
+
svc: RemixService = Depends(get_service),
|
|
209
|
+
) -> dict:
|
|
210
|
+
"""Enqueue a Living segment render. Poll the returned job; its result is a
|
|
211
|
+
segment {audio_url, next_index, ...}. Call again with start_index=next_index
|
|
212
|
+
for a seamless continuation (Living Repeat)."""
|
|
213
|
+
if not store.get_song(req.song_id):
|
|
214
|
+
raise HTTPException(404, "Song not found")
|
|
215
|
+
|
|
216
|
+
def work(report) -> dict:
|
|
217
|
+
report(0.02, "Starting Living…")
|
|
218
|
+
return svc.living_segment(req, backend=backend, progress=report)
|
|
219
|
+
|
|
220
|
+
job = jobs.submit("living", work, song_id=req.song_id)
|
|
221
|
+
return job.to_dict()
|
|
222
|
+
|
|
223
|
+
@app.get("/api/presets")
|
|
224
|
+
def list_presets() -> list:
|
|
225
|
+
return [p.model_dump() for p in presets.list()]
|
|
226
|
+
|
|
227
|
+
@app.post("/api/presets", status_code=201)
|
|
228
|
+
def create_preset(req: PresetCreate) -> dict:
|
|
229
|
+
return presets.add(req.name, req.params).model_dump()
|
|
230
|
+
|
|
231
|
+
@app.delete("/api/presets/{preset_id}")
|
|
232
|
+
def delete_preset(preset_id: str) -> dict:
|
|
233
|
+
if not presets.delete(preset_id):
|
|
234
|
+
raise HTTPException(404, "Preset not found (built-in presets can't be deleted)")
|
|
235
|
+
return {"deleted": preset_id}
|
|
236
|
+
|
|
237
|
+
@app.get("/api/living/audio/{seg_id}")
|
|
238
|
+
def living_audio(seg_id: str) -> FileResponse:
|
|
239
|
+
# seg_id is server-minted (live_<hex>); constrain to that shape.
|
|
240
|
+
if not seg_id.startswith("live_") or "/" in seg_id or ".." in seg_id:
|
|
241
|
+
raise HTTPException(400, "Bad segment id")
|
|
242
|
+
path = store.audio_dir / f"{seg_id}.wav"
|
|
243
|
+
if not path.exists():
|
|
244
|
+
raise HTTPException(404, "Segment not found")
|
|
245
|
+
return FileResponse(str(path), media_type="audio/wav")
|
|
246
|
+
|
|
247
|
+
@app.get("/api/audio/{variant_id}")
|
|
248
|
+
def get_audio(variant_id: str) -> FileResponse:
|
|
249
|
+
v = store.get_variant(variant_id)
|
|
250
|
+
if not v or not v.audio_path or not Path(v.audio_path).exists():
|
|
251
|
+
raise HTTPException(404, "Audio not found")
|
|
252
|
+
return FileResponse(v.audio_path, media_type="audio/wav")
|
|
253
|
+
|
|
254
|
+
# --- static frontend (built React app) -------------------------------
|
|
255
|
+
|
|
256
|
+
if STATIC_DIR.exists() and (STATIC_DIR / "index.html").exists():
|
|
257
|
+
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
|
|
258
|
+
|
|
259
|
+
@app.get("/")
|
|
260
|
+
def index() -> FileResponse:
|
|
261
|
+
return FileResponse(STATIC_DIR / "index.html")
|
|
262
|
+
|
|
263
|
+
@app.exception_handler(404)
|
|
264
|
+
async def spa_fallback(request, exc): # noqa: ANN001
|
|
265
|
+
# Serve the SPA shell for client-side routes, but keep API 404s real.
|
|
266
|
+
if request.url.path.startswith("/api/"):
|
|
267
|
+
return JSONResponse({"detail": "Not found"}, status_code=404)
|
|
268
|
+
return FileResponse(STATIC_DIR / "index.html")
|
|
269
|
+
else:
|
|
270
|
+
|
|
271
|
+
@app.get("/")
|
|
272
|
+
def index_dev() -> dict:
|
|
273
|
+
return {
|
|
274
|
+
"app": "RemixFlow API",
|
|
275
|
+
"version": __version__,
|
|
276
|
+
"note": "Frontend not built. Run the Vite dev server, or build it "
|
|
277
|
+
"so it is served here. See README.",
|
|
278
|
+
"docs": "/docs",
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return app
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Audio I/O, analysis, and DSP helpers."""
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Feature extraction and the 'Musical DNA' embedding (PRD §1, §2, Advanced).
|
|
2
|
+
|
|
3
|
+
Everything here degrades gracefully: if ``librosa`` is missing we still return
|
|
4
|
+
cheap descriptors computed with NumPy (duration, RMS energy, a coarse spectral
|
|
5
|
+
centroid, and a deterministic embedding), flagging ``analyzed=False`` so the UI
|
|
6
|
+
can show what is estimated vs. measured.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from ..models import AudioFeatures
|
|
14
|
+
from .io import Clip
|
|
15
|
+
|
|
16
|
+
_PITCH_CLASSES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
|
17
|
+
EMBED_DIM = 16
|
|
18
|
+
#: Feature extraction runs at this rate — CQT/beat/MFCC are several× cheaper at
|
|
19
|
+
#: 22 kHz than 44.1 kHz with negligible impact on these descriptors.
|
|
20
|
+
ANALYSIS_SR = 22050
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _mono_at_analysis_sr(mono: np.ndarray, sr: int):
|
|
24
|
+
"""Downsample mono audio to ANALYSIS_SR (via librosa) for cheaper analysis."""
|
|
25
|
+
if sr and sr > ANALYSIS_SR:
|
|
26
|
+
import librosa # type: ignore
|
|
27
|
+
|
|
28
|
+
return librosa.resample(mono, orig_sr=sr, target_sr=ANALYSIS_SR), ANALYSIS_SR
|
|
29
|
+
return mono, sr
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _spectral_centroid_np(mono: np.ndarray, sr: int) -> float:
|
|
33
|
+
"""A dependency-free spectral centroid (Hz) over the whole clip."""
|
|
34
|
+
if mono.size == 0 or sr == 0:
|
|
35
|
+
return 0.0
|
|
36
|
+
spec = np.abs(np.fft.rfft(mono * np.hanning(mono.size)))
|
|
37
|
+
freqs = np.fft.rfftfreq(mono.size, d=1.0 / sr)
|
|
38
|
+
total = spec.sum()
|
|
39
|
+
return float((freqs * spec).sum() / total) if total > 0 else 0.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fallback_embedding(mono: np.ndarray, sr: int) -> list[float]:
|
|
43
|
+
"""A stable, low-cost latent vector derived from band energies.
|
|
44
|
+
|
|
45
|
+
Not a learned embedding — a stand-in with the right *shape* and stability
|
|
46
|
+
so the similarity evaluator and morph feature work end-to-end until a real
|
|
47
|
+
music encoder backend is plugged into :func:`embed`.
|
|
48
|
+
"""
|
|
49
|
+
if mono.size == 0:
|
|
50
|
+
return [0.0] * EMBED_DIM
|
|
51
|
+
spec = np.abs(np.fft.rfft(mono))
|
|
52
|
+
if spec.sum() == 0:
|
|
53
|
+
return [0.0] * EMBED_DIM
|
|
54
|
+
bands = np.array_split(spec, EMBED_DIM)
|
|
55
|
+
vec = np.array([b.mean() for b in bands], dtype=np.float64)
|
|
56
|
+
norm = np.linalg.norm(vec)
|
|
57
|
+
return (vec / norm).tolist() if norm > 0 else vec.tolist()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def analyze(clip: Clip) -> AudioFeatures:
|
|
61
|
+
"""Extract descriptors from a clip, using librosa when available."""
|
|
62
|
+
mono = clip.to_mono().astype(np.float32)
|
|
63
|
+
feats = AudioFeatures(
|
|
64
|
+
duration_sec=round(clip.duration, 3),
|
|
65
|
+
sample_rate=clip.sample_rate,
|
|
66
|
+
rms_energy=float(np.sqrt(np.mean(mono**2))) if mono.size else 0.0,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
import librosa # type: ignore
|
|
71
|
+
|
|
72
|
+
y, sr = _mono_at_analysis_sr(mono, clip.sample_rate)
|
|
73
|
+
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
|
|
74
|
+
feats.tempo_bpm = round(float(np.atleast_1d(tempo)[0]), 2)
|
|
75
|
+
feats.spectral_centroid = round(
|
|
76
|
+
float(np.mean(librosa.feature.spectral_centroid(y=y, sr=sr))), 2
|
|
77
|
+
)
|
|
78
|
+
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
|
|
79
|
+
feats.key = _PITCH_CLASSES[int(np.argmax(chroma.mean(axis=1)))]
|
|
80
|
+
feats.embedding = embed(clip)
|
|
81
|
+
feats.analyzed = True
|
|
82
|
+
feats.note = "librosa"
|
|
83
|
+
except Exception as exc: # librosa missing or failed — cheap fallback.
|
|
84
|
+
feats.spectral_centroid = round(_spectral_centroid_np(mono, clip.sample_rate), 2)
|
|
85
|
+
feats.embedding = _fallback_embedding(mono, clip.sample_rate)
|
|
86
|
+
feats.analyzed = False
|
|
87
|
+
feats.note = f"estimated (librosa unavailable: {type(exc).__name__})"
|
|
88
|
+
|
|
89
|
+
return feats
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def embed(clip: Clip) -> list[float]:
|
|
93
|
+
"""Return the Musical DNA embedding for a clip.
|
|
94
|
+
|
|
95
|
+
Uses MFCC statistics via librosa when available (a reasonable timbral
|
|
96
|
+
fingerprint), else the NumPy band-energy fallback. This is the seam where a
|
|
97
|
+
real learned music encoder (CLAP / MERT / MusicFM) plugs in later.
|
|
98
|
+
"""
|
|
99
|
+
mono = clip.to_mono().astype(np.float32)
|
|
100
|
+
try:
|
|
101
|
+
import librosa # type: ignore
|
|
102
|
+
|
|
103
|
+
y, sr = _mono_at_analysis_sr(mono, clip.sample_rate)
|
|
104
|
+
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=EMBED_DIM)
|
|
105
|
+
vec = mfcc.mean(axis=1).astype(np.float64)
|
|
106
|
+
norm = np.linalg.norm(vec)
|
|
107
|
+
return (vec / norm).tolist() if norm > 0 else vec.tolist()
|
|
108
|
+
except Exception:
|
|
109
|
+
return _fallback_embedding(mono, clip.sample_rate)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def similarity(a: list[float], b: list[float]) -> float:
|
|
113
|
+
"""Cosine similarity mapped to [0, 1] — the identity-preservation score."""
|
|
114
|
+
if not a or not b or len(a) != len(b):
|
|
115
|
+
return 0.0
|
|
116
|
+
va, vb = np.asarray(a), np.asarray(b)
|
|
117
|
+
denom = np.linalg.norm(va) * np.linalg.norm(vb)
|
|
118
|
+
if denom == 0:
|
|
119
|
+
return 0.0
|
|
120
|
+
cos = float(np.dot(va, vb) / denom)
|
|
121
|
+
return round(max(0.0, min(1.0, (cos + 1.0) / 2.0)), 4)
|