Mina Emadi Claude Sonnet 4.6 commited on
Commit ·
08bdfee
1
Parent(s): d1bbea4
add demo preset system with sample track
Browse files- backend/routers/presets.py: new endpoints GET /api/presets and POST /api/preset/{name}
- loads stems + MIDI from backend/presets/{name}/ into a session (no upload needed)
- .gitignore exception + LFS tracking for preset WAV files
- frontend dropdown on upload screen to select and load a demo preset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- .gitattributes +1 -0
- .gitignore +3 -0
- backend/main.py +2 -1
- backend/presets/sample-track/MIDI.mid +0 -0
- backend/presets/sample-track/bass.wav +3 -0
- backend/presets/sample-track/click_record.wav +3 -0
- backend/presets/sample-track/drums.wav +3 -0
- backend/presets/sample-track/guitar.wav +3 -0
- backend/presets/sample-track/synth.wav +3 -0
- backend/routers/__init__.py +2 -0
- backend/routers/presets.py +99 -0
- frontend/src/App.jsx +2 -0
- frontend/src/components/FileUpload.jsx +39 -2
- frontend/src/hooks/useSession.js +32 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
backend/presets/**/*.wav filter=lfs diff=lfs merge=lfs -text
|
.gitignore
CHANGED
|
@@ -37,6 +37,9 @@ frontend/build/
|
|
| 37 |
*.mp3
|
| 38 |
*.flac
|
| 39 |
|
|
|
|
|
|
|
|
|
|
| 40 |
# Testing
|
| 41 |
.pytest_cache/
|
| 42 |
.coverage
|
|
|
|
| 37 |
*.mp3
|
| 38 |
*.flac
|
| 39 |
|
| 40 |
+
# Exception: preset demo files should be committed (via LFS)
|
| 41 |
+
!backend/presets/**/*.wav
|
| 42 |
+
|
| 43 |
# Testing
|
| 44 |
.pytest_cache/
|
| 45 |
.coverage
|
backend/main.py
CHANGED
|
@@ -8,7 +8,7 @@ from fastapi import FastAPI
|
|
| 8 |
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
from fastapi.staticfiles import StaticFiles
|
| 10 |
|
| 11 |
-
from .routers import upload_router, detection_router, processing_router, stems_router, generation_router
|
| 12 |
from .models.session import cleanup_old_sessions
|
| 13 |
|
| 14 |
|
|
@@ -62,6 +62,7 @@ app.include_router(detection_router, prefix="/api", tags=["detection"])
|
|
| 62 |
app.include_router(processing_router, prefix="/api", tags=["processing"])
|
| 63 |
app.include_router(stems_router, prefix="/api", tags=["stems"])
|
| 64 |
app.include_router(generation_router, prefix="/api", tags=["generation"])
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
# Health check endpoint
|
|
|
|
| 8 |
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
from fastapi.staticfiles import StaticFiles
|
| 10 |
|
| 11 |
+
from .routers import upload_router, detection_router, processing_router, stems_router, generation_router, presets_router
|
| 12 |
from .models.session import cleanup_old_sessions
|
| 13 |
|
| 14 |
|
|
|
|
| 62 |
app.include_router(processing_router, prefix="/api", tags=["processing"])
|
| 63 |
app.include_router(stems_router, prefix="/api", tags=["stems"])
|
| 64 |
app.include_router(generation_router, prefix="/api", tags=["generation"])
|
| 65 |
+
app.include_router(presets_router, prefix="/api", tags=["presets"])
|
| 66 |
|
| 67 |
|
| 68 |
# Health check endpoint
|
backend/presets/sample-track/MIDI.mid
ADDED
|
Binary file (24.1 kB). View file
|
|
|
backend/presets/sample-track/bass.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:34d0bf1999390c1846ec635d7d13f719f12235dd0c3b43aff37c978defd20166
|
| 3 |
+
size 70893820
|
backend/presets/sample-track/click_record.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:436b475a472014349fe08f7acee5916bf17d1ff475471b6f04b45e1cf3f67233
|
| 3 |
+
size 70893820
|
backend/presets/sample-track/drums.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:536d1b91df21e93ca9f1f1352da5b70f64e6b0b8ac884e7ef794122f63183cfd
|
| 3 |
+
size 70893820
|
backend/presets/sample-track/guitar.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8ed112603fd68bed8a61c290b9ca36fcecee998b085eb283a21e127df0b2bc9e
|
| 3 |
+
size 70893820
|
backend/presets/sample-track/synth.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ba8a34d85b65a69e09ba0a319136bba8c9d92a6e0fa10e9dc3770d6e26d25a49
|
| 3 |
+
size 70893820
|
backend/routers/__init__.py
CHANGED
|
@@ -5,6 +5,7 @@ from .detection import router as detection_router
|
|
| 5 |
from .processing import router as processing_router
|
| 6 |
from .stems import router as stems_router
|
| 7 |
from .generation import router as generation_router
|
|
|
|
| 8 |
|
| 9 |
__all__ = [
|
| 10 |
"upload_router",
|
|
@@ -12,4 +13,5 @@ __all__ = [
|
|
| 12 |
"processing_router",
|
| 13 |
"stems_router",
|
| 14 |
"generation_router",
|
|
|
|
| 15 |
]
|
|
|
|
| 5 |
from .processing import router as processing_router
|
| 6 |
from .stems import router as stems_router
|
| 7 |
from .generation import router as generation_router
|
| 8 |
+
from .presets import router as presets_router
|
| 9 |
|
| 10 |
__all__ = [
|
| 11 |
"upload_router",
|
|
|
|
| 13 |
"processing_router",
|
| 14 |
"stems_router",
|
| 15 |
"generation_router",
|
| 16 |
+
"presets_router",
|
| 17 |
]
|
backend/routers/presets.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Presets router — load pre-committed demo stems into a session."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import io
|
| 6 |
+
import numpy as np
|
| 7 |
+
import soundfile as sf
|
| 8 |
+
import mido
|
| 9 |
+
from fastapi import APIRouter, HTTPException
|
| 10 |
+
|
| 11 |
+
from ..models.session import create_session, StemData
|
| 12 |
+
from ..models.schemas import UploadResponse
|
| 13 |
+
from ..utils.audio_utils import normalize
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
|
| 17 |
+
PRESETS_DIR = Path(__file__).parent.parent / "presets"
|
| 18 |
+
STEM_NAMES = ['guitar', 'drums', 'bass', 'synth', 'click_record']
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.get("/presets")
|
| 22 |
+
async def list_presets():
|
| 23 |
+
"""List available demo presets."""
|
| 24 |
+
if not PRESETS_DIR.exists():
|
| 25 |
+
return {"presets": []}
|
| 26 |
+
presets = sorted(d.name for d in PRESETS_DIR.iterdir() if d.is_dir())
|
| 27 |
+
return {"presets": presets}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@router.post("/preset/{preset_name}", response_model=UploadResponse)
|
| 31 |
+
async def load_preset(preset_name: str):
|
| 32 |
+
"""Load a pre-committed preset into a session (no upload needed)."""
|
| 33 |
+
preset_dir = PRESETS_DIR / preset_name
|
| 34 |
+
if not preset_dir.exists():
|
| 35 |
+
raise HTTPException(status_code=404, detail=f"Preset '{preset_name}' not found")
|
| 36 |
+
|
| 37 |
+
stems = {}
|
| 38 |
+
sample_rates = []
|
| 39 |
+
|
| 40 |
+
for stem_name in STEM_NAMES:
|
| 41 |
+
wav_path = preset_dir / f"{stem_name}.wav"
|
| 42 |
+
if not wav_path.exists():
|
| 43 |
+
continue
|
| 44 |
+
audio, sr = sf.read(str(wav_path))
|
| 45 |
+
audio = audio.astype(np.float32)
|
| 46 |
+
if audio.ndim == 2:
|
| 47 |
+
audio = np.mean(audio, axis=1).astype(np.float32)
|
| 48 |
+
stems[stem_name] = StemData(name=stem_name, audio=audio, sample_rate=sr)
|
| 49 |
+
sample_rates.append(sr)
|
| 50 |
+
|
| 51 |
+
if not stems:
|
| 52 |
+
raise HTTPException(status_code=404, detail=f"No WAV stems found in preset '{preset_name}'")
|
| 53 |
+
|
| 54 |
+
# Load MIDI files from preset folder
|
| 55 |
+
midi_data = None
|
| 56 |
+
midi_files = sorted(preset_dir.glob("*.mid")) + sorted(preset_dir.glob("*.midi"))
|
| 57 |
+
if midi_files:
|
| 58 |
+
try:
|
| 59 |
+
if len(midi_files) == 1:
|
| 60 |
+
midi_data = mido.MidiFile(filename=str(midi_files[0]))
|
| 61 |
+
else:
|
| 62 |
+
midi_data = mido.MidiFile()
|
| 63 |
+
for midi_path in midi_files:
|
| 64 |
+
temp = mido.MidiFile(filename=str(midi_path))
|
| 65 |
+
for track in temp.tracks:
|
| 66 |
+
midi_data.tracks.append(track)
|
| 67 |
+
except Exception as e:
|
| 68 |
+
print(f"Warning: could not load MIDI from preset '{preset_name}': {e}")
|
| 69 |
+
|
| 70 |
+
# Build session
|
| 71 |
+
session = create_session()
|
| 72 |
+
session.stems = stems
|
| 73 |
+
session.original_sr = sample_rates[0]
|
| 74 |
+
session.midi_data = midi_data
|
| 75 |
+
|
| 76 |
+
# Generate mix
|
| 77 |
+
max_length = max(len(s.audio) for s in stems.values())
|
| 78 |
+
mixed = np.zeros(max_length, dtype=np.float64)
|
| 79 |
+
for stem in stems.values():
|
| 80 |
+
audio = stem.audio
|
| 81 |
+
if len(audio) < max_length:
|
| 82 |
+
padded = np.zeros(max_length, dtype=np.float64)
|
| 83 |
+
padded[:len(audio)] = audio
|
| 84 |
+
mixed += padded
|
| 85 |
+
else:
|
| 86 |
+
mixed += audio
|
| 87 |
+
mixed = normalize(mixed.astype(np.float32), peak=0.95)
|
| 88 |
+
session.full_mix = StemData(name="generated_mix", audio=mixed, sample_rate=sample_rates[0])
|
| 89 |
+
|
| 90 |
+
duration_seconds = max_length / sample_rates[0]
|
| 91 |
+
|
| 92 |
+
return UploadResponse(
|
| 93 |
+
session_id=session.id,
|
| 94 |
+
stems=list(stems.keys()),
|
| 95 |
+
has_full_mix=False,
|
| 96 |
+
duration_seconds=round(duration_seconds, 2),
|
| 97 |
+
sample_rate=sample_rates[0],
|
| 98 |
+
has_midi=midi_data is not None
|
| 99 |
+
)
|
frontend/src/App.jsx
CHANGED
|
@@ -21,6 +21,7 @@ function App() {
|
|
| 21 |
detect,
|
| 22 |
process,
|
| 23 |
generate,
|
|
|
|
| 24 |
clearError
|
| 25 |
} = useSession()
|
| 26 |
|
|
@@ -166,6 +167,7 @@ function App() {
|
|
| 166 |
|
| 167 |
<FileUpload
|
| 168 |
onUpload={handleUpload}
|
|
|
|
| 169 |
loading={loading}
|
| 170 |
error={error}
|
| 171 |
onClearError={clearError}
|
|
|
|
| 21 |
detect,
|
| 22 |
process,
|
| 23 |
generate,
|
| 24 |
+
loadPreset,
|
| 25 |
clearError
|
| 26 |
} = useSession()
|
| 27 |
|
|
|
|
| 167 |
|
| 168 |
<FileUpload
|
| 169 |
onUpload={handleUpload}
|
| 170 |
+
onLoadPreset={loadPreset}
|
| 171 |
loading={loading}
|
| 172 |
error={error}
|
| 173 |
onClearError={clearError}
|
frontend/src/components/FileUpload.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React, { useState, useCallback } from 'react'
|
| 2 |
|
| 3 |
const STEM_TYPES = [
|
| 4 |
{ name: 'guitar', label: 'Guitar', icon: '🎸' },
|
|
@@ -8,7 +8,17 @@ const STEM_TYPES = [
|
|
| 8 |
{ name: 'click_record', label: 'Click Record', icon: '⏱️' }
|
| 9 |
]
|
| 10 |
|
| 11 |
-
function FileUpload({ onUpload, loading, error, onClearError }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
const [stems, setStems] = useState({
|
| 13 |
guitar: null,
|
| 14 |
drums: null,
|
|
@@ -85,6 +95,33 @@ function FileUpload({ onUpload, loading, error, onClearError }) {
|
|
| 85 |
<div className="glass rounded-2xl p-6 animate-fade-in max-w-4xl mx-auto">
|
| 86 |
<h2 className="text-xl font-semibold mb-6 text-center">Upload Your Stems</h2>
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
{/* Stem Upload Grid */}
|
| 89 |
<div className="grid grid-cols-2 gap-4 mb-6">
|
| 90 |
{STEM_TYPES.map(({ name, label, icon }) => (
|
|
|
|
| 1 |
+
import React, { useState, useCallback, useEffect } from 'react'
|
| 2 |
|
| 3 |
const STEM_TYPES = [
|
| 4 |
{ name: 'guitar', label: 'Guitar', icon: '🎸' },
|
|
|
|
| 8 |
{ name: 'click_record', label: 'Click Record', icon: '⏱️' }
|
| 9 |
]
|
| 10 |
|
| 11 |
+
function FileUpload({ onUpload, onLoadPreset, loading, error, onClearError }) {
|
| 12 |
+
const [presets, setPresets] = useState([])
|
| 13 |
+
const [selectedPreset, setSelectedPreset] = useState('')
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
fetch('/api/presets')
|
| 17 |
+
.then(r => r.json())
|
| 18 |
+
.then(data => setPresets(data.presets || []))
|
| 19 |
+
.catch(() => {})
|
| 20 |
+
}, [])
|
| 21 |
+
|
| 22 |
const [stems, setStems] = useState({
|
| 23 |
guitar: null,
|
| 24 |
drums: null,
|
|
|
|
| 95 |
<div className="glass rounded-2xl p-6 animate-fade-in max-w-4xl mx-auto">
|
| 96 |
<h2 className="text-xl font-semibold mb-6 text-center">Upload Your Stems</h2>
|
| 97 |
|
| 98 |
+
{/* Demo presets dropdown */}
|
| 99 |
+
{presets.length > 0 && (
|
| 100 |
+
<div className="mb-6 p-4 bg-gray-800/50 rounded-xl border border-gray-600">
|
| 101 |
+
<p className="text-sm text-gray-400 mb-3">Or try a demo track:</p>
|
| 102 |
+
<div className="flex gap-3">
|
| 103 |
+
<select
|
| 104 |
+
value={selectedPreset}
|
| 105 |
+
onChange={(e) => setSelectedPreset(e.target.value)}
|
| 106 |
+
disabled={loading}
|
| 107 |
+
className="flex-1 bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-primary-500"
|
| 108 |
+
>
|
| 109 |
+
<option value="">Select a demo...</option>
|
| 110 |
+
{presets.map(p => (
|
| 111 |
+
<option key={p} value={p}>{p}</option>
|
| 112 |
+
))}
|
| 113 |
+
</select>
|
| 114 |
+
<button
|
| 115 |
+
onClick={() => selectedPreset && onLoadPreset(selectedPreset)}
|
| 116 |
+
disabled={loading || !selectedPreset}
|
| 117 |
+
className="px-4 py-2 rounded-lg text-sm font-medium transition-all disabled:bg-gray-700 disabled:text-gray-500 disabled:cursor-not-allowed bg-gradient-to-r from-primary-600 to-accent-600 hover:from-primary-500 hover:to-accent-500"
|
| 118 |
+
>
|
| 119 |
+
{loading ? 'Loading...' : 'Load'}
|
| 120 |
+
</button>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
)}
|
| 124 |
+
|
| 125 |
{/* Stem Upload Grid */}
|
| 126 |
<div className="grid grid-cols-2 gap-4 mb-6">
|
| 127 |
{STEM_TYPES.map(({ name, label, icon }) => (
|
frontend/src/hooks/useSession.js
CHANGED
|
@@ -147,6 +147,37 @@ export function useSession() {
|
|
| 147 |
}
|
| 148 |
}, [sessionId])
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
const generate = useCallback(async (regionStart, regionEnd, duration = 15.0, prompt = null) => {
|
| 151 |
if (!sessionId) {
|
| 152 |
setError('No session to generate')
|
|
@@ -196,6 +227,7 @@ export function useSession() {
|
|
| 196 |
process,
|
| 197 |
generate,
|
| 198 |
getStems,
|
|
|
|
| 199 |
clearError
|
| 200 |
}
|
| 201 |
}
|
|
|
|
| 147 |
}
|
| 148 |
}, [sessionId])
|
| 149 |
|
| 150 |
+
const loadPreset = useCallback(async (presetName) => {
|
| 151 |
+
setLoading(true)
|
| 152 |
+
setError(null)
|
| 153 |
+
|
| 154 |
+
try {
|
| 155 |
+
const uploadResponse = await fetch(`/api/preset/${presetName}`, { method: 'POST' })
|
| 156 |
+
if (!uploadResponse.ok) {
|
| 157 |
+
const data = await uploadResponse.json().catch(() => ({}))
|
| 158 |
+
throw new Error(data.detail || `Failed to load preset: ${uploadResponse.status}`)
|
| 159 |
+
}
|
| 160 |
+
const uploadData = await uploadResponse.json()
|
| 161 |
+
setSessionId(uploadData.session_id)
|
| 162 |
+
setStems(uploadData.stems)
|
| 163 |
+
|
| 164 |
+
const detectResponse = await fetch(`/api/detect/${uploadData.session_id}`, { method: 'POST' })
|
| 165 |
+
if (!detectResponse.ok) {
|
| 166 |
+
const data = await detectResponse.json().catch(() => ({}))
|
| 167 |
+
throw new Error(data.detail || `Detection failed: ${detectResponse.status}`)
|
| 168 |
+
}
|
| 169 |
+
const detectData = await detectResponse.json()
|
| 170 |
+
setDetection(detectData)
|
| 171 |
+
|
| 172 |
+
return { upload: uploadData, detection: detectData }
|
| 173 |
+
} catch (err) {
|
| 174 |
+
setError(err.message)
|
| 175 |
+
return null
|
| 176 |
+
} finally {
|
| 177 |
+
setLoading(false)
|
| 178 |
+
}
|
| 179 |
+
}, [])
|
| 180 |
+
|
| 181 |
const generate = useCallback(async (regionStart, regionEnd, duration = 15.0, prompt = null) => {
|
| 182 |
if (!sessionId) {
|
| 183 |
setError('No session to generate')
|
|
|
|
| 227 |
process,
|
| 228 |
generate,
|
| 229 |
getStems,
|
| 230 |
+
loadPreset,
|
| 231 |
clearError
|
| 232 |
}
|
| 233 |
}
|