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.
Files changed (34) hide show
  1. remixflow-0.1.0/.gitignore +35 -0
  2. remixflow-0.1.0/PKG-INFO +88 -0
  3. remixflow-0.1.0/README.md +65 -0
  4. remixflow-0.1.0/pyproject.toml +45 -0
  5. remixflow-0.1.0/remixflow/__init__.py +12 -0
  6. remixflow-0.1.0/remixflow/__main__.py +36 -0
  7. remixflow-0.1.0/remixflow/app.py +281 -0
  8. remixflow-0.1.0/remixflow/audio/__init__.py +1 -0
  9. remixflow-0.1.0/remixflow/audio/analysis.py +121 -0
  10. remixflow-0.1.0/remixflow/audio/io.py +87 -0
  11. remixflow-0.1.0/remixflow/config.py +28 -0
  12. remixflow-0.1.0/remixflow/generation/__init__.py +16 -0
  13. remixflow-0.1.0/remixflow/generation/acestep.py +378 -0
  14. remixflow-0.1.0/remixflow/generation/base.py +55 -0
  15. remixflow-0.1.0/remixflow/generation/dsp.py +175 -0
  16. remixflow-0.1.0/remixflow/generation/registry.py +51 -0
  17. remixflow-0.1.0/remixflow/jobs.py +92 -0
  18. remixflow-0.1.0/remixflow/living.py +254 -0
  19. remixflow-0.1.0/remixflow/models.py +158 -0
  20. remixflow-0.1.0/remixflow/params.py +134 -0
  21. remixflow-0.1.0/remixflow/presets.py +89 -0
  22. remixflow-0.1.0/remixflow/server.py +5 -0
  23. remixflow-0.1.0/remixflow/service.py +247 -0
  24. remixflow-0.1.0/remixflow/static/assets/index-Bo4yHRF4.js +40 -0
  25. remixflow-0.1.0/remixflow/static/assets/index-CpEM8inD.css +1 -0
  26. remixflow-0.1.0/remixflow/static/index.html +13 -0
  27. remixflow-0.1.0/remixflow/store.py +116 -0
  28. remixflow-0.1.0/scripts/build_ab_page.py +273 -0
  29. remixflow-0.1.0/scripts/make_ab_excerpts.py +50 -0
  30. remixflow-0.1.0/scripts/make_living.py +86 -0
  31. remixflow-0.1.0/scripts/make_var50_full.py +40 -0
  32. remixflow-0.1.0/scripts/make_var50_keepvocals.py +66 -0
  33. remixflow-0.1.0/scripts/try_acestep.py +67 -0
  34. 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
@@ -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)