remixflow 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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__"]
remixflow/__main__.py ADDED
@@ -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()
remixflow/app.py ADDED
@@ -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)
remixflow/audio/io.py ADDED
@@ -0,0 +1,87 @@
1
+ """Audio load/save with graceful degradation.
2
+
3
+ We prefer ``soundfile`` (libsndfile: WAV/FLAC/OGG) and fall back to
4
+ ``librosa``/``audioread`` (which can reach MP3 via ffmpeg when present). All
5
+ imports are lazy so the API boots and serves the UI even in an environment
6
+ with none of the audio stack installed — feature extraction and DSP simply
7
+ report themselves as unavailable rather than crashing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Optional
14
+
15
+ import numpy as np
16
+
17
+
18
+ @dataclass
19
+ class Clip:
20
+ """Mono/stereo float32 audio in memory. Shape: (channels, samples)."""
21
+
22
+ samples: np.ndarray # float32, shape (channels, n)
23
+ sample_rate: int
24
+
25
+ @property
26
+ def duration(self) -> float:
27
+ return self.samples.shape[-1] / self.sample_rate if self.sample_rate else 0.0
28
+
29
+ def to_mono(self) -> np.ndarray:
30
+ return self.samples.mean(axis=0) if self.samples.ndim == 2 else self.samples
31
+
32
+
33
+ class AudioUnavailable(RuntimeError):
34
+ """Raised when no audio backend can handle a load/save request."""
35
+
36
+
37
+ def load(path: str) -> Clip:
38
+ """Load an audio file into a :class:`Clip`. Raises AudioUnavailable if the
39
+ audio stack cannot read it (missing libs / unsupported codec)."""
40
+ # Try soundfile first — fast and covers WAV/FLAC/OGG.
41
+ try:
42
+ import soundfile as sf # type: ignore
43
+
44
+ data, sr = sf.read(path, dtype="float32", always_2d=True)
45
+ # soundfile gives (n, channels); transpose to (channels, n).
46
+ return Clip(samples=np.ascontiguousarray(data.T), sample_rate=int(sr))
47
+ except Exception:
48
+ pass
49
+
50
+ try:
51
+ import librosa # type: ignore
52
+
53
+ data, sr = librosa.load(path, sr=None, mono=False)
54
+ if data.ndim == 1:
55
+ data = data[np.newaxis, :]
56
+ return Clip(samples=data.astype(np.float32), sample_rate=int(sr))
57
+ except Exception as exc: # pragma: no cover - env dependent
58
+ raise AudioUnavailable(
59
+ f"Could not decode {path!r}. Install 'soundfile' (WAV/FLAC/OGG) or "
60
+ f"'librosa'+ffmpeg (MP3). Underlying error: {exc}"
61
+ ) from exc
62
+
63
+
64
+ def save(clip: Clip, path: str) -> None:
65
+ """Persist a clip to disk (WAV via soundfile)."""
66
+ try:
67
+ import soundfile as sf # type: ignore
68
+ except Exception as exc: # pragma: no cover - env dependent
69
+ raise AudioUnavailable(
70
+ "Saving audio requires 'soundfile' (pip install soundfile)."
71
+ ) from exc
72
+
73
+ data = clip.samples
74
+ # soundfile expects (n, channels).
75
+ out = data.T if data.ndim == 2 else data[:, np.newaxis]
76
+ sf.write(path, np.clip(out, -1.0, 1.0), clip.sample_rate, subtype="PCM_16")
77
+
78
+
79
+ def available() -> bool:
80
+ """True if at least one decode backend is importable."""
81
+ for mod in ("soundfile", "librosa"):
82
+ try:
83
+ __import__(mod)
84
+ return True
85
+ except Exception:
86
+ continue
87
+ return False
remixflow/config.py ADDED
@@ -0,0 +1,28 @@
1
+ """Runtime configuration via environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ PKG_ROOT = Path(__file__).resolve().parent
10
+ # Built frontend assets are copied here at package build time (see pyproject).
11
+ STATIC_DIR = PKG_ROOT / "static"
12
+
13
+
14
+ @dataclass
15
+ class Settings:
16
+ data_dir: Path
17
+ max_upload_mb: int
18
+ cors_origins: list[str]
19
+
20
+ @classmethod
21
+ def from_env(cls) -> "Settings":
22
+ data_dir = Path(os.environ.get("REMIXFLOW_DATA_DIR", "./remixflow_data")).resolve()
23
+ origins = os.environ.get("REMIXFLOW_CORS", "*").split(",")
24
+ return cls(
25
+ data_dir=data_dir,
26
+ max_upload_mb=int(os.environ.get("REMIXFLOW_MAX_UPLOAD_MB", "50")),
27
+ cors_origins=[o.strip() for o in origins if o.strip()],
28
+ )
@@ -0,0 +1,16 @@
1
+ """Music generation backends. The interface is the important part — real
2
+ diffusion/transformer models (ACE-Step, Stable Audio, MusicGen) implement the
3
+ same :class:`~remixflow.generation.base.Generator` contract."""
4
+
5
+ from .base import Generator, GenerationResult
6
+ from .dsp import DSPGenerator
7
+ from .registry import get_generator, register_generator, list_generators
8
+
9
+ __all__ = [
10
+ "Generator",
11
+ "GenerationResult",
12
+ "DSPGenerator",
13
+ "get_generator",
14
+ "register_generator",
15
+ "list_generators",
16
+ ]