Spaces:
Sleeping
Sleeping
Mina Emadi commited on
Commit ·
25d51c9
1
Parent(s): 3bbd60a
applied the comments regarding chord progression, scale suggestions and UI/UX changes
Browse files- .DS_Store +0 -0
- backend/.DS_Store +0 -0
- backend/__pycache__/main.cpython-310.pyc +0 -0
- backend/main.py +2 -1
- backend/models/__pycache__/schemas.cpython-310.pyc +0 -0
- backend/models/__pycache__/session.cpython-310.pyc +0 -0
- backend/models/schemas.py +25 -0
- backend/models/session.py +4 -0
- backend/presets/282-Static-Summer/metadata.json +0 -1
- backend/routers/__init__.py +2 -0
- backend/routers/__pycache__/__init__.cpython-310.pyc +0 -0
- backend/routers/__pycache__/detection.cpython-310.pyc +0 -0
- backend/routers/chords.py +51 -0
- backend/routers/detection.py +27 -28
- backend/services/__pycache__/midi_analyzer.cpython-310.pyc +0 -0
- backend/services/audio_key_detector.py +83 -0
- backend/services/chord_detector.py +238 -0
- backend/services/midi_analyzer.py +147 -94
- backend/utils/__pycache__/music_theory.cpython-310.pyc +0 -0
- backend/utils/music_theory.py +186 -0
- frontend/src/App.jsx +154 -130
- frontend/src/components/AnalysisDisplay.jsx +144 -47
- frontend/src/components/ChordDisplay.jsx +61 -0
- frontend/src/components/FileUpload.jsx +4 -4
- frontend/src/components/LiveChordView.jsx +127 -0
- frontend/src/components/ProcessingOverlay.jsx +2 -2
- frontend/src/components/ScalesDisplay.jsx +72 -0
- frontend/src/components/StemMixer.jsx +106 -167
- frontend/src/components/TransportBar.jsx +130 -33
- frontend/src/hooks/useAudioEngine.js +3 -3
- frontend/src/hooks/useSession.js +31 -0
- frontend/src/index.css +24 -27
- frontend/tailwind.config.js +10 -14
.DS_Store
CHANGED
|
Binary files a/.DS_Store and b/.DS_Store differ
|
|
|
backend/.DS_Store
CHANGED
|
Binary files a/backend/.DS_Store and b/backend/.DS_Store differ
|
|
|
backend/__pycache__/main.cpython-310.pyc
CHANGED
|
Binary files a/backend/__pycache__/main.cpython-310.pyc and b/backend/__pycache__/main.cpython-310.pyc differ
|
|
|
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, presets_router
|
| 12 |
from .models.session import cleanup_old_sessions
|
| 13 |
|
| 14 |
|
|
@@ -63,6 +63,7 @@ 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
|
|
|
|
| 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, chords_router
|
| 12 |
from .models.session import cleanup_old_sessions
|
| 13 |
|
| 14 |
|
|
|
|
| 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 |
+
app.include_router(chords_router, prefix="/api", tags=["chords"])
|
| 67 |
|
| 68 |
|
| 69 |
# Health check endpoint
|
backend/models/__pycache__/schemas.cpython-310.pyc
CHANGED
|
Binary files a/backend/models/__pycache__/schemas.cpython-310.pyc and b/backend/models/__pycache__/schemas.cpython-310.pyc differ
|
|
|
backend/models/__pycache__/session.cpython-310.pyc
CHANGED
|
Binary files a/backend/models/__pycache__/session.cpython-310.pyc and b/backend/models/__pycache__/session.cpython-310.pyc differ
|
|
|
backend/models/schemas.py
CHANGED
|
@@ -77,6 +77,31 @@ class GenerateResponse(BaseModel):
|
|
| 77 |
generation_time_seconds: float
|
| 78 |
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
class ErrorResponse(BaseModel):
|
| 81 |
"""Standard error response."""
|
| 82 |
|
|
|
|
| 77 |
generation_time_seconds: float
|
| 78 |
|
| 79 |
|
| 80 |
+
class ChordEntry(BaseModel):
|
| 81 |
+
"""A single chord in the progression."""
|
| 82 |
+
|
| 83 |
+
chord: str # e.g., "Am7", "C", "N.C."
|
| 84 |
+
start_time: float # seconds
|
| 85 |
+
end_time: float # seconds
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class ScaleSuggestion(BaseModel):
|
| 89 |
+
"""A suggested scale for improvising."""
|
| 90 |
+
|
| 91 |
+
name: str # e.g., "A Minor Pentatonic"
|
| 92 |
+
notes: list[str] # e.g., ["A", "C", "D", "E", "G"]
|
| 93 |
+
fit_score: float # 0-1
|
| 94 |
+
guitar_friendly: bool
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class ChordsResponse(BaseModel):
|
| 98 |
+
"""Response from chord detection endpoint."""
|
| 99 |
+
|
| 100 |
+
chords: list[ChordEntry]
|
| 101 |
+
scales: list[ScaleSuggestion]
|
| 102 |
+
chord_source: str # "midi" or "audio"
|
| 103 |
+
|
| 104 |
+
|
| 105 |
class ErrorResponse(BaseModel):
|
| 106 |
"""Standard error response."""
|
| 107 |
|
backend/models/session.py
CHANGED
|
@@ -38,6 +38,10 @@ class Session:
|
|
| 38 |
wav_cache: dict[str, bytes] = field(default_factory=dict)
|
| 39 |
# Optional metadata overrides loaded from preset's metadata.json
|
| 40 |
preset_metadata: Optional[dict] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
# In-memory session store
|
|
|
|
| 38 |
wav_cache: dict[str, bytes] = field(default_factory=dict)
|
| 39 |
# Optional metadata overrides loaded from preset's metadata.json
|
| 40 |
preset_metadata: Optional[dict] = None
|
| 41 |
+
# Chord progression and scale suggestions (cached after first detection)
|
| 42 |
+
chord_progression: Optional[list] = None
|
| 43 |
+
scale_suggestions: Optional[list] = None
|
| 44 |
+
chord_source: Optional[str] = None
|
| 45 |
|
| 46 |
|
| 47 |
# In-memory session store
|
backend/presets/282-Static-Summer/metadata.json
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
{ "key": "A", "mode": "major" }
|
|
|
|
|
|
backend/routers/__init__.py
CHANGED
|
@@ -6,6 +6,7 @@ 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",
|
|
@@ -14,4 +15,5 @@ __all__ = [
|
|
| 14 |
"stems_router",
|
| 15 |
"generation_router",
|
| 16 |
"presets_router",
|
|
|
|
| 17 |
]
|
|
|
|
| 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 |
+
from .chords import router as chords_router
|
| 10 |
|
| 11 |
__all__ = [
|
| 12 |
"upload_router",
|
|
|
|
| 15 |
"stems_router",
|
| 16 |
"generation_router",
|
| 17 |
"presets_router",
|
| 18 |
+
"chords_router",
|
| 19 |
]
|
backend/routers/__pycache__/__init__.cpython-310.pyc
CHANGED
|
Binary files a/backend/routers/__pycache__/__init__.cpython-310.pyc and b/backend/routers/__pycache__/__init__.cpython-310.pyc differ
|
|
|
backend/routers/__pycache__/detection.cpython-310.pyc
CHANGED
|
Binary files a/backend/routers/__pycache__/detection.cpython-310.pyc and b/backend/routers/__pycache__/detection.cpython-310.pyc differ
|
|
|
backend/routers/chords.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chord detection and scale suggestion endpoint."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, HTTPException
|
| 4 |
+
|
| 5 |
+
from ..models.session import get_session
|
| 6 |
+
from ..models.schemas import ChordsResponse
|
| 7 |
+
from ..services.chord_detector import detect_chords
|
| 8 |
+
from ..utils.music_theory import suggest_scales
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/chords/{session_id}", response_model=ChordsResponse)
|
| 14 |
+
async def get_chords_and_scales(session_id: str):
|
| 15 |
+
"""Extract chord progression and suggest scales for a session."""
|
| 16 |
+
session = get_session(session_id)
|
| 17 |
+
if not session:
|
| 18 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 19 |
+
|
| 20 |
+
if not session.detected_key:
|
| 21 |
+
raise HTTPException(status_code=400, detail="Run detection first")
|
| 22 |
+
|
| 23 |
+
# Return cached results if available
|
| 24 |
+
if session.chord_progression is not None:
|
| 25 |
+
return ChordsResponse(
|
| 26 |
+
chords=session.chord_progression,
|
| 27 |
+
scales=session.scale_suggestions or [],
|
| 28 |
+
chord_source=session.chord_source or "unknown",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Detect chords
|
| 32 |
+
chords = detect_chords(session)
|
| 33 |
+
chord_source = "midi" if session.midi_data is not None and chords else "audio"
|
| 34 |
+
|
| 35 |
+
# Suggest scales
|
| 36 |
+
scales = suggest_scales(
|
| 37 |
+
session.detected_key,
|
| 38 |
+
session.detected_mode or "major",
|
| 39 |
+
chords,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# Cache on session
|
| 43 |
+
session.chord_progression = chords
|
| 44 |
+
session.scale_suggestions = scales
|
| 45 |
+
session.chord_source = chord_source
|
| 46 |
+
|
| 47 |
+
return ChordsResponse(
|
| 48 |
+
chords=chords,
|
| 49 |
+
scales=scales,
|
| 50 |
+
chord_source=chord_source,
|
| 51 |
+
)
|
backend/routers/detection.py
CHANGED
|
@@ -5,13 +5,13 @@ from fastapi import APIRouter, HTTPException
|
|
| 5 |
from ..models.session import get_session
|
| 6 |
from ..models.schemas import DetectionResponse
|
| 7 |
from ..services.bpm_detector import detect_bpm
|
| 8 |
-
from ..services.
|
| 9 |
-
from ..services.
|
| 10 |
-
extract_bpm_from_midi,
|
| 11 |
-
extract_key_from_midi,
|
| 12 |
-
)
|
| 13 |
from ..utils.audio_utils import to_mono
|
| 14 |
|
|
|
|
|
|
|
|
|
|
| 15 |
router = APIRouter()
|
| 16 |
|
| 17 |
|
|
@@ -70,7 +70,7 @@ async def detect_bpm_and_key(session_id: str):
|
|
| 70 |
bpm_result = detect_bpm(mix_audio, mix_sr, drums_audio)
|
| 71 |
bpm_source = "audio"
|
| 72 |
|
| 73 |
-
# Key Detection
|
| 74 |
key_source = "audio"
|
| 75 |
key_result = None
|
| 76 |
|
|
@@ -80,32 +80,31 @@ async def detect_bpm_and_key(session_id: str):
|
|
| 80 |
print(f"Key from preset metadata: {key_result['key']} {key_result['mode']}")
|
| 81 |
|
| 82 |
elif session.midi_data is not None:
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
| 86 |
key_source = "midi"
|
| 87 |
-
print(f"Key from MIDI: {key_result['key']} {key_result['mode']}")
|
| 88 |
else:
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
if key_result is None:
|
| 92 |
-
#
|
| 93 |
-
print("
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
mix_audio = to_mono(mix_audio)
|
| 98 |
-
|
| 99 |
-
# Get bass stem if available
|
| 100 |
-
bass_audio = None
|
| 101 |
-
for name, stem in session.stems.items():
|
| 102 |
-
if 'bass' in name.lower():
|
| 103 |
-
bass_audio = stem.audio
|
| 104 |
-
if bass_audio.ndim == 2:
|
| 105 |
-
bass_audio = to_mono(bass_audio)
|
| 106 |
-
break
|
| 107 |
-
|
| 108 |
-
key_result = detect_key(mix_audio, mix_sr, bass_audio)
|
| 109 |
key_source = "audio"
|
| 110 |
|
| 111 |
# Store results in session
|
|
|
|
| 5 |
from ..models.session import get_session
|
| 6 |
from ..models.schemas import DetectionResponse
|
| 7 |
from ..services.bpm_detector import detect_bpm
|
| 8 |
+
from ..services.midi_analyzer import extract_bpm_from_midi, extract_key_from_midi
|
| 9 |
+
from ..services.audio_key_detector import detect_key_from_stems
|
|
|
|
|
|
|
|
|
|
| 10 |
from ..utils.audio_utils import to_mono
|
| 11 |
|
| 12 |
+
# Below this threshold, audio chromagram analysis supplements MIDI key detection
|
| 13 |
+
MIDI_KEY_CONFIDENCE_THRESHOLD = 0.15
|
| 14 |
+
|
| 15 |
router = APIRouter()
|
| 16 |
|
| 17 |
|
|
|
|
| 70 |
bpm_result = detect_bpm(mix_audio, mix_sr, drums_audio)
|
| 71 |
bpm_source = "audio"
|
| 72 |
|
| 73 |
+
# Key Detection
|
| 74 |
key_source = "audio"
|
| 75 |
key_result = None
|
| 76 |
|
|
|
|
| 80 |
print(f"Key from preset metadata: {key_result['key']} {key_result['mode']}")
|
| 81 |
|
| 82 |
elif session.midi_data is not None:
|
| 83 |
+
midi_key = extract_key_from_midi(session.midi_data)
|
| 84 |
+
print(f"Key from MIDI: {midi_key['key']} {midi_key['mode']} confidence={midi_key['confidence']}")
|
| 85 |
+
|
| 86 |
+
if midi_key["confidence"] >= MIDI_KEY_CONFIDENCE_THRESHOLD:
|
| 87 |
+
key_result = midi_key
|
| 88 |
key_source = "midi"
|
|
|
|
| 89 |
else:
|
| 90 |
+
# MIDI is uncertain — run audio chromagram on all stems
|
| 91 |
+
print(f"MIDI confidence {midi_key['confidence']} < {MIDI_KEY_CONFIDENCE_THRESHOLD}, checking audio stems...")
|
| 92 |
+
audio_key = detect_key_from_stems(session.stems)
|
| 93 |
+
print(f"Key from audio: {audio_key['key']} {audio_key['mode']} confidence={audio_key['confidence']}")
|
| 94 |
+
|
| 95 |
+
if audio_key["confidence"] > midi_key["confidence"]:
|
| 96 |
+
key_result = audio_key
|
| 97 |
+
key_source = "audio"
|
| 98 |
+
else:
|
| 99 |
+
key_result = midi_key
|
| 100 |
+
key_source = "midi"
|
| 101 |
|
| 102 |
if key_result is None:
|
| 103 |
+
# No MIDI — use audio only
|
| 104 |
+
print("No MIDI data, using audio key detection...")
|
| 105 |
+
audio_key = detect_key_from_stems(session.stems)
|
| 106 |
+
print(f"Key from audio: {audio_key['key']} {audio_key['mode']} confidence={audio_key['confidence']}")
|
| 107 |
+
key_result = audio_key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
key_source = "audio"
|
| 109 |
|
| 110 |
# Store results in session
|
backend/services/__pycache__/midi_analyzer.cpython-310.pyc
CHANGED
|
Binary files a/backend/services/__pycache__/midi_analyzer.cpython-310.pyc and b/backend/services/__pycache__/midi_analyzer.cpython-310.pyc differ
|
|
|
backend/services/audio_key_detector.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audio stem key detection using weighted chromagram analysis.
|
| 2 |
+
|
| 3 |
+
Works on in-memory numpy arrays (no file I/O needed).
|
| 4 |
+
Instrument priority mirrors the MIDI analyzer:
|
| 5 |
+
bass > lead/synth > keys > pad > strings > guitar
|
| 6 |
+
Drums are excluded automatically by name matching.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import librosa
|
| 11 |
+
|
| 12 |
+
from .midi_analyzer import INSTRUMENT_ROLES, _get_role_and_weight, _run_key_profiles
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def detect_key_from_stems(stems: dict, chroma_type: str = "cqt") -> dict:
|
| 16 |
+
"""
|
| 17 |
+
Detect key from in-memory audio stems using weighted chromagram fusion.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
stems : dict of {stem_name: StemData} from session.stems
|
| 21 |
+
chroma_type: "cqt" (default), "stft", or "cens"
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
dict with "key", "mode", "confidence"
|
| 25 |
+
"""
|
| 26 |
+
fused = np.zeros(12, dtype=np.float64)
|
| 27 |
+
stems_used = []
|
| 28 |
+
|
| 29 |
+
for name, stem_data in stems.items():
|
| 30 |
+
role, weight = _get_role_and_weight(name)
|
| 31 |
+
if weight == 0.0:
|
| 32 |
+
continue # skip drums / unpitched stems
|
| 33 |
+
|
| 34 |
+
y = stem_data.audio
|
| 35 |
+
sr = stem_data.sample_rate
|
| 36 |
+
|
| 37 |
+
# Ensure mono float32
|
| 38 |
+
if y.ndim == 2:
|
| 39 |
+
y = y.mean(axis=1)
|
| 40 |
+
y = y.astype(np.float32)
|
| 41 |
+
|
| 42 |
+
# Use first 60 s for speed on long stems
|
| 43 |
+
y = y[:sr * 60]
|
| 44 |
+
|
| 45 |
+
try:
|
| 46 |
+
if chroma_type == "cqt":
|
| 47 |
+
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
|
| 48 |
+
elif chroma_type == "stft":
|
| 49 |
+
chroma = librosa.feature.chroma_stft(y=y, sr=sr)
|
| 50 |
+
else:
|
| 51 |
+
chroma = librosa.feature.chroma_cens(y=y, sr=sr)
|
| 52 |
+
except Exception as e:
|
| 53 |
+
print(f" [audio key] Skipping '{name}': {e}")
|
| 54 |
+
continue
|
| 55 |
+
|
| 56 |
+
mean_chroma = chroma.mean(axis=1)
|
| 57 |
+
total = mean_chroma.sum()
|
| 58 |
+
if total <= 0:
|
| 59 |
+
continue
|
| 60 |
+
|
| 61 |
+
fused += (mean_chroma / total) * weight
|
| 62 |
+
stems_used.append(f"{name}(w={weight})")
|
| 63 |
+
|
| 64 |
+
if stems_used:
|
| 65 |
+
print(f"Audio key detection using: {', '.join(stems_used)}")
|
| 66 |
+
else:
|
| 67 |
+
print("Audio key detection: no usable pitched stems found")
|
| 68 |
+
|
| 69 |
+
if fused.sum() == 0:
|
| 70 |
+
return {"key": "C", "mode": "major", "confidence": 0.0}
|
| 71 |
+
|
| 72 |
+
fused /= fused.sum()
|
| 73 |
+
|
| 74 |
+
ranked = _run_key_profiles(fused)
|
| 75 |
+
(winner_key, winner_mode), winner_score = ranked[0]
|
| 76 |
+
second_score = ranked[1][1] if len(ranked) > 1 else 0.0
|
| 77 |
+
last_score = ranked[-1][1]
|
| 78 |
+
|
| 79 |
+
score_range = winner_score - last_score
|
| 80 |
+
gap = winner_score - second_score
|
| 81 |
+
confidence = round(gap / score_range, 3) if score_range > 0 else 0.0
|
| 82 |
+
|
| 83 |
+
return {"key": winner_key, "mode": winner_mode, "confidence": confidence}
|
backend/services/chord_detector.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chord detection from MIDI data and audio stems."""
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import mido
|
| 5 |
+
|
| 6 |
+
from ..utils.music_theory import KEY_NAMES, match_chord
|
| 7 |
+
from .midi_analyzer import _get_role_and_weight, extract_bpm_from_midi
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def detect_chords(session) -> list[dict]:
|
| 11 |
+
"""
|
| 12 |
+
Extract chord progression from a session, preferring MIDI over audio.
|
| 13 |
+
|
| 14 |
+
Returns:
|
| 15 |
+
List of {"chord": str, "start_time": float, "end_time": float}
|
| 16 |
+
"""
|
| 17 |
+
if session.midi_data is not None:
|
| 18 |
+
bpm_info = extract_bpm_from_midi(session.midi_data)
|
| 19 |
+
bpm = session.detected_bpm or bpm_info["bpm"]
|
| 20 |
+
chords = extract_chords_from_midi(session.midi_data, bpm)
|
| 21 |
+
if chords:
|
| 22 |
+
return chords
|
| 23 |
+
|
| 24 |
+
# Fallback: audio-based chord detection
|
| 25 |
+
if session.stems:
|
| 26 |
+
bpm = session.detected_bpm or 120.0
|
| 27 |
+
sr = session.original_sr
|
| 28 |
+
return extract_chords_from_audio(session.stems, bpm, sr)
|
| 29 |
+
|
| 30 |
+
return []
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def extract_chords_from_midi(midi_file: mido.MidiFile, bpm: float) -> list[dict]:
|
| 34 |
+
"""
|
| 35 |
+
Extract chord progression from MIDI by analyzing notes in beat-aligned windows.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
midi_file: Parsed MIDI file
|
| 39 |
+
bpm: Detected BPM for beat alignment
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
List of chord entries with start_time and end_time in seconds
|
| 43 |
+
"""
|
| 44 |
+
ticks_per_beat = midi_file.ticks_per_beat or 480
|
| 45 |
+
us_per_beat = 60_000_000 / bpm # microseconds per beat
|
| 46 |
+
|
| 47 |
+
# Collect all note events with absolute times in seconds, weighted by instrument role
|
| 48 |
+
note_events = [] # (start_sec, end_sec, pitch, velocity, weight)
|
| 49 |
+
|
| 50 |
+
for track in midi_file.tracks:
|
| 51 |
+
# Determine track role and weight
|
| 52 |
+
track_name = track.name.strip() if track.name else ""
|
| 53 |
+
instrument_name = ""
|
| 54 |
+
for msg in track:
|
| 55 |
+
if msg.type == 'instrument_name':
|
| 56 |
+
instrument_name = msg.name.strip()
|
| 57 |
+
break
|
| 58 |
+
|
| 59 |
+
display_name = track_name or instrument_name or "unknown"
|
| 60 |
+
role, weight = _get_role_and_weight(display_name)
|
| 61 |
+
|
| 62 |
+
# Skip drums entirely
|
| 63 |
+
if weight == 0.0:
|
| 64 |
+
continue
|
| 65 |
+
|
| 66 |
+
# Parse note on/off pairs
|
| 67 |
+
active_notes = {} # note -> (start_tick, velocity)
|
| 68 |
+
current_tick = 0
|
| 69 |
+
current_tempo = us_per_beat # default
|
| 70 |
+
|
| 71 |
+
# Build tempo map for this track
|
| 72 |
+
tempo_map = []
|
| 73 |
+
tick_acc = 0
|
| 74 |
+
for msg in track:
|
| 75 |
+
tick_acc += msg.time
|
| 76 |
+
if msg.type == 'set_tempo':
|
| 77 |
+
tempo_map.append((tick_acc, msg.tempo))
|
| 78 |
+
|
| 79 |
+
# Reset for note parsing
|
| 80 |
+
current_tick = 0
|
| 81 |
+
tempo_idx = 0
|
| 82 |
+
current_us_per_beat = us_per_beat
|
| 83 |
+
|
| 84 |
+
for msg in track:
|
| 85 |
+
current_tick += msg.time
|
| 86 |
+
|
| 87 |
+
# Update tempo if needed
|
| 88 |
+
while tempo_idx < len(tempo_map) and current_tick >= tempo_map[tempo_idx][0]:
|
| 89 |
+
current_us_per_beat = tempo_map[tempo_idx][1]
|
| 90 |
+
tempo_idx += 1
|
| 91 |
+
|
| 92 |
+
if msg.type == 'note_on' and msg.velocity > 0:
|
| 93 |
+
active_notes[msg.note] = (current_tick, msg.velocity)
|
| 94 |
+
elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0):
|
| 95 |
+
if msg.note in active_notes:
|
| 96 |
+
start_tick, velocity = active_notes.pop(msg.note)
|
| 97 |
+
start_sec = mido.tick2second(start_tick, ticks_per_beat, int(current_us_per_beat))
|
| 98 |
+
end_sec = mido.tick2second(current_tick, ticks_per_beat, int(current_us_per_beat))
|
| 99 |
+
note_events.append((start_sec, end_sec, msg.note, velocity, weight))
|
| 100 |
+
|
| 101 |
+
if not note_events:
|
| 102 |
+
return []
|
| 103 |
+
|
| 104 |
+
# Determine total duration
|
| 105 |
+
max_time = max(e[1] for e in note_events)
|
| 106 |
+
|
| 107 |
+
# Quantize to half-bar windows (2 beats)
|
| 108 |
+
beat_duration = 60.0 / bpm
|
| 109 |
+
window_size = beat_duration * 2 # half-bar
|
| 110 |
+
num_windows = int(np.ceil(max_time / window_size))
|
| 111 |
+
|
| 112 |
+
chords = []
|
| 113 |
+
for i in range(num_windows):
|
| 114 |
+
win_start = i * window_size
|
| 115 |
+
win_end = (i + 1) * window_size
|
| 116 |
+
|
| 117 |
+
# Gather pitch classes weighted by duration * velocity * instrument weight
|
| 118 |
+
pitch_weights = {}
|
| 119 |
+
for start_sec, end_sec, pitch, velocity, weight in note_events:
|
| 120 |
+
# Overlap with this window
|
| 121 |
+
overlap_start = max(start_sec, win_start)
|
| 122 |
+
overlap_end = min(end_sec, win_end)
|
| 123 |
+
if overlap_end <= overlap_start:
|
| 124 |
+
continue
|
| 125 |
+
overlap_duration = overlap_end - overlap_start
|
| 126 |
+
pc = pitch % 12
|
| 127 |
+
w = overlap_duration * (velocity / 127.0) * weight
|
| 128 |
+
pitch_weights[pc] = pitch_weights.get(pc, 0.0) + w
|
| 129 |
+
|
| 130 |
+
chord_name, score = match_chord(pitch_weights)
|
| 131 |
+
if score < 0.1:
|
| 132 |
+
chord_name = "N.C."
|
| 133 |
+
|
| 134 |
+
chords.append({
|
| 135 |
+
"chord": chord_name,
|
| 136 |
+
"start_time": round(win_start, 3),
|
| 137 |
+
"end_time": round(min(win_end, max_time), 3),
|
| 138 |
+
})
|
| 139 |
+
|
| 140 |
+
# Merge consecutive identical chords
|
| 141 |
+
return _merge_consecutive(chords)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def extract_chords_from_audio(
|
| 145 |
+
stems: dict, bpm: float, sr: int = 44100
|
| 146 |
+
) -> list[dict]:
|
| 147 |
+
"""
|
| 148 |
+
Fallback chord detection from audio stems using chroma analysis.
|
| 149 |
+
|
| 150 |
+
Uses librosa's chroma_cqt on harmonic stems (bass, guitar, synth, keys).
|
| 151 |
+
"""
|
| 152 |
+
try:
|
| 153 |
+
import librosa
|
| 154 |
+
except ImportError:
|
| 155 |
+
return []
|
| 156 |
+
|
| 157 |
+
# Mix harmonic stems with instrument weighting
|
| 158 |
+
harmonic_mix = None
|
| 159 |
+
total_weight = 0.0
|
| 160 |
+
|
| 161 |
+
for name, stem_data in stems.items():
|
| 162 |
+
role, weight = _get_role_and_weight(name)
|
| 163 |
+
if weight == 0.0:
|
| 164 |
+
continue
|
| 165 |
+
|
| 166 |
+
audio = stem_data.audio
|
| 167 |
+
if audio.ndim == 2:
|
| 168 |
+
audio = audio.mean(axis=1)
|
| 169 |
+
|
| 170 |
+
# Resample if needed
|
| 171 |
+
if stem_data.sample_rate != sr:
|
| 172 |
+
audio = librosa.resample(audio, orig_sr=stem_data.sample_rate, target_sr=sr)
|
| 173 |
+
|
| 174 |
+
weighted = audio.astype(np.float64) * weight
|
| 175 |
+
if harmonic_mix is None:
|
| 176 |
+
harmonic_mix = np.zeros(len(weighted), dtype=np.float64)
|
| 177 |
+
|
| 178 |
+
min_len = min(len(harmonic_mix), len(weighted))
|
| 179 |
+
harmonic_mix[:min_len] += weighted[:min_len]
|
| 180 |
+
total_weight += weight
|
| 181 |
+
|
| 182 |
+
if harmonic_mix is None or total_weight == 0:
|
| 183 |
+
return []
|
| 184 |
+
|
| 185 |
+
harmonic_mix = harmonic_mix.astype(np.float32)
|
| 186 |
+
|
| 187 |
+
# Compute chroma features
|
| 188 |
+
chroma = librosa.feature.chroma_cqt(y=harmonic_mix, sr=sr, hop_length=512)
|
| 189 |
+
|
| 190 |
+
# Beat-aligned segmentation
|
| 191 |
+
beat_duration = 60.0 / bpm
|
| 192 |
+
window_size = beat_duration * 2 # half-bar
|
| 193 |
+
hop_time = 512 / sr
|
| 194 |
+
window_frames = max(1, int(window_size / hop_time))
|
| 195 |
+
|
| 196 |
+
total_frames = chroma.shape[1]
|
| 197 |
+
total_duration = len(harmonic_mix) / sr
|
| 198 |
+
|
| 199 |
+
chords = []
|
| 200 |
+
for start_frame in range(0, total_frames, window_frames):
|
| 201 |
+
end_frame = min(start_frame + window_frames, total_frames)
|
| 202 |
+
segment = chroma[:, start_frame:end_frame]
|
| 203 |
+
|
| 204 |
+
# Average chroma across the segment
|
| 205 |
+
avg_chroma = segment.mean(axis=1)
|
| 206 |
+
|
| 207 |
+
# Convert to pitch class weights
|
| 208 |
+
pitch_weights = {i: float(avg_chroma[i]) for i in range(12) if avg_chroma[i] > 0.01}
|
| 209 |
+
|
| 210 |
+
chord_name, score = match_chord(pitch_weights)
|
| 211 |
+
if score < 0.1:
|
| 212 |
+
chord_name = "N.C."
|
| 213 |
+
|
| 214 |
+
start_time = start_frame * hop_time
|
| 215 |
+
end_time = min(end_frame * hop_time, total_duration)
|
| 216 |
+
|
| 217 |
+
chords.append({
|
| 218 |
+
"chord": chord_name,
|
| 219 |
+
"start_time": round(start_time, 3),
|
| 220 |
+
"end_time": round(end_time, 3),
|
| 221 |
+
})
|
| 222 |
+
|
| 223 |
+
return _merge_consecutive(chords)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _merge_consecutive(chords: list[dict]) -> list[dict]:
|
| 227 |
+
"""Merge consecutive entries with the same chord name."""
|
| 228 |
+
if not chords:
|
| 229 |
+
return []
|
| 230 |
+
|
| 231 |
+
merged = [chords[0].copy()]
|
| 232 |
+
for entry in chords[1:]:
|
| 233 |
+
if entry["chord"] == merged[-1]["chord"]:
|
| 234 |
+
merged[-1]["end_time"] = entry["end_time"]
|
| 235 |
+
else:
|
| 236 |
+
merged.append(entry.copy())
|
| 237 |
+
|
| 238 |
+
return merged
|
backend/services/midi_analyzer.py
CHANGED
|
@@ -5,7 +5,10 @@ import mido
|
|
| 5 |
|
| 6 |
from ..utils.music_theory import KEY_NAMES
|
| 7 |
|
| 8 |
-
#
|
|
|
|
|
|
|
|
|
|
| 9 |
# Temperley (rock/pop corpus)
|
| 10 |
TEMPERLEY_MAJOR = [5.0, 2.0, 3.5, 2.0, 4.5, 4.0, 2.0, 4.5, 2.0, 3.5, 1.5, 4.0]
|
| 11 |
TEMPERLEY_MINOR = [5.0, 2.0, 3.5, 4.5, 2.0, 4.0, 2.0, 4.5, 3.5, 2.0, 1.5, 4.0]
|
|
@@ -14,14 +17,106 @@ TEMPERLEY_MINOR = [5.0, 2.0, 3.5, 4.5, 2.0, 4.0, 2.0, 4.5, 3.5, 2.0, 1.5, 4.0]
|
|
| 14 |
AARDEN_MAJOR = [17.77, 0.15, 14.93, 0.16, 19.80, 11.36, 0.29, 22.06, 0.15, 8.15, 0.23, 4.95]
|
| 15 |
AARDEN_MINOR = [18.16, 0.69, 12.99, 13.34, 1.07, 11.15, 1.38, 21.07, 7.49, 1.53, 0.92, 10.21]
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
def extract_bpm_from_midi(midi_file: mido.MidiFile) -> dict:
|
| 19 |
"""
|
| 20 |
Extract BPM from MIDI tempo map.
|
| 21 |
|
| 22 |
-
Args:
|
| 23 |
-
midi_file: Parsed MIDI file (mido.MidiFile)
|
| 24 |
-
|
| 25 |
Returns:
|
| 26 |
dict with "bpm", "tempo_changes", "confidence"
|
| 27 |
"""
|
|
@@ -30,137 +125,95 @@ def extract_bpm_from_midi(midi_file: mido.MidiFile) -> dict:
|
|
| 30 |
for track in midi_file.tracks:
|
| 31 |
for msg in track:
|
| 32 |
if msg.type == 'set_tempo':
|
| 33 |
-
# mido stores tempo as microseconds per beat
|
| 34 |
bpm = 60_000_000 / msg.tempo
|
| 35 |
-
tempo_changes.append({
|
| 36 |
-
"bpm": round(bpm, 2),
|
| 37 |
-
"tempo_us": msg.tempo
|
| 38 |
-
})
|
| 39 |
|
| 40 |
if not tempo_changes:
|
| 41 |
-
|
| 42 |
-
return {
|
| 43 |
-
"bpm": 120.0,
|
| 44 |
-
"tempo_changes": [],
|
| 45 |
-
"confidence": 0.5 # Lower confidence for default
|
| 46 |
-
}
|
| 47 |
|
| 48 |
-
return {
|
| 49 |
-
"bpm": tempo_changes[0]["bpm"], # Use first tempo
|
| 50 |
-
"tempo_changes": tempo_changes,
|
| 51 |
-
"confidence": 1.0 # Exact data from MIDI
|
| 52 |
-
}
|
| 53 |
|
| 54 |
|
| 55 |
def extract_key_from_midi(midi_file: mido.MidiFile) -> dict:
|
| 56 |
"""
|
| 57 |
-
Extract key from MIDI
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
|
| 62 |
Returns:
|
| 63 |
dict with "key", "mode", "confidence"
|
| 64 |
"""
|
| 65 |
-
# Build
|
| 66 |
pitch_histogram = np.zeros(12, dtype=np.float64)
|
|
|
|
| 67 |
|
| 68 |
for track in midi_file.tracks:
|
| 69 |
-
#
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
current_time = 0
|
|
|
|
| 72 |
|
| 73 |
for msg in track:
|
| 74 |
current_time += msg.time
|
| 75 |
|
| 76 |
if msg.type == 'note_on' and msg.velocity > 0:
|
| 77 |
active_notes[msg.note] = (current_time, msg.velocity)
|
|
|
|
| 78 |
elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0):
|
| 79 |
if msg.note in active_notes:
|
| 80 |
start_time, velocity = active_notes.pop(msg.note)
|
| 81 |
duration = current_time - start_time
|
|
|
|
|
|
|
| 82 |
pitch_class = msg.note % 12
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
pitch_histogram[pitch_class] += weight
|
| 86 |
-
|
| 87 |
-
# Handle case with no notes
|
| 88 |
-
if np.sum(pitch_histogram) == 0:
|
| 89 |
-
return {
|
| 90 |
-
"key": "C",
|
| 91 |
-
"mode": "major",
|
| 92 |
-
"confidence": 0.0
|
| 93 |
-
}
|
| 94 |
-
|
| 95 |
-
# Normalize histogram
|
| 96 |
-
pitch_histogram = pitch_histogram / np.sum(pitch_histogram)
|
| 97 |
-
|
| 98 |
-
# Run ensemble detection with multiple profiles
|
| 99 |
-
profiles = [
|
| 100 |
-
("temperley", TEMPERLEY_MAJOR, TEMPERLEY_MINOR),
|
| 101 |
-
("aarden", AARDEN_MAJOR, AARDEN_MINOR),
|
| 102 |
-
]
|
| 103 |
-
|
| 104 |
-
votes = {} # (key, mode) -> total weight
|
| 105 |
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
best_mode = None
|
| 109 |
-
best_corr = -1
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
# Rotate profile to match key
|
| 115 |
-
rotated_major = np.roll(major_profile, semitones)
|
| 116 |
-
rotated_minor = np.roll(minor_profile, semitones)
|
| 117 |
-
|
| 118 |
-
# Normalize profiles
|
| 119 |
-
rotated_major = rotated_major / np.sum(rotated_major)
|
| 120 |
-
rotated_minor = rotated_minor / np.sum(rotated_minor)
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
corr_minor = np.corrcoef(pitch_histogram, rotated_minor)[0, 1]
|
| 125 |
-
|
| 126 |
-
if corr_major > best_corr:
|
| 127 |
-
best_corr = corr_major
|
| 128 |
-
best_key = key_name
|
| 129 |
-
best_mode = "major"
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
best_key = key_name
|
| 134 |
-
best_mode = "minor"
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
| 139 |
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
confidence = votes[winner] / total_weight if total_weight > 0 else 0
|
| 144 |
|
| 145 |
-
return {
|
| 146 |
-
"key": winner[0],
|
| 147 |
-
"mode": winner[1],
|
| 148 |
-
"confidence": round(confidence, 3)
|
| 149 |
-
}
|
| 150 |
|
| 151 |
|
| 152 |
def validate_midi_audio_sync(midi_file: mido.MidiFile, audio_duration: float) -> bool:
|
| 153 |
"""
|
| 154 |
Check if MIDI duration matches audio duration within 500ms.
|
| 155 |
|
| 156 |
-
Args:
|
| 157 |
-
midi_file: Parsed MIDI file
|
| 158 |
-
audio_duration: Audio duration in seconds
|
| 159 |
-
|
| 160 |
Returns:
|
| 161 |
True if durations match within tolerance
|
| 162 |
"""
|
| 163 |
-
|
| 164 |
-
tolerance = 0.5 # 500ms
|
| 165 |
-
|
| 166 |
-
return abs(midi_duration - audio_duration) <= tolerance
|
|
|
|
| 5 |
|
| 6 |
from ..utils.music_theory import KEY_NAMES
|
| 7 |
|
| 8 |
+
# ---------------------------------------------------------------------------
|
| 9 |
+
# Key profiles
|
| 10 |
+
# ---------------------------------------------------------------------------
|
| 11 |
+
|
| 12 |
# Temperley (rock/pop corpus)
|
| 13 |
TEMPERLEY_MAJOR = [5.0, 2.0, 3.5, 2.0, 4.5, 4.0, 2.0, 4.5, 2.0, 3.5, 1.5, 4.0]
|
| 14 |
TEMPERLEY_MINOR = [5.0, 2.0, 3.5, 4.5, 2.0, 4.0, 2.0, 4.5, 3.5, 2.0, 1.5, 4.0]
|
|
|
|
| 17 |
AARDEN_MAJOR = [17.77, 0.15, 14.93, 0.16, 19.80, 11.36, 0.29, 22.06, 0.15, 8.15, 0.23, 4.95]
|
| 18 |
AARDEN_MINOR = [18.16, 0.69, 12.99, 13.34, 1.07, 11.15, 1.38, 21.07, 7.49, 1.53, 0.92, 10.21]
|
| 19 |
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
# Instrument role registry
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# keyword -> (role_label, key_detection_weight)
|
| 24 |
+
#
|
| 25 |
+
# bass → highest: almost always plays the root on the downbeat
|
| 26 |
+
# lead → very high: melody resolves to tonic
|
| 27 |
+
# keys → medium-high: full chords give harmonic context
|
| 28 |
+
# pad → medium: sustained harmony
|
| 29 |
+
# guitar → low: rhythm parts flood histogram with IV/V chord tones
|
| 30 |
+
# strings → low-medium: often doubling harmony
|
| 31 |
+
# drums → zero: unpitched, never use for key detection
|
| 32 |
+
|
| 33 |
+
INSTRUMENT_ROLES = {
|
| 34 |
+
# Drums — always zero
|
| 35 |
+
"drum": ("drums", 0.0),
|
| 36 |
+
"perc": ("drums", 0.0),
|
| 37 |
+
"kick": ("drums", 0.0),
|
| 38 |
+
"snare": ("drums", 0.0),
|
| 39 |
+
"hat": ("drums", 0.0),
|
| 40 |
+
"cymbal": ("drums", 0.0),
|
| 41 |
+
"xo": ("drums", 0.0),
|
| 42 |
+
"hh": ("drums", 0.0),
|
| 43 |
+
|
| 44 |
+
# Bass — highest weight
|
| 45 |
+
"bass": ("bass", 3.0),
|
| 46 |
+
"sub": ("bass", 2.5),
|
| 47 |
+
"808": ("bass", 2.5),
|
| 48 |
+
|
| 49 |
+
# Lead / melody — very high weight
|
| 50 |
+
"lead": ("lead", 2.5),
|
| 51 |
+
"synth": ("lead", 2.5),
|
| 52 |
+
"melody": ("lead", 2.5),
|
| 53 |
+
"vocal": ("lead", 2.5),
|
| 54 |
+
"vox": ("lead", 2.5),
|
| 55 |
+
"keys": ("keys", 2.0),
|
| 56 |
+
"piano": ("keys", 2.0),
|
| 57 |
+
"ep": ("keys", 2.0),
|
| 58 |
+
|
| 59 |
+
# Pads / atmosphere — medium
|
| 60 |
+
"pad": ("pad", 1.5),
|
| 61 |
+
"organ": ("pad", 1.5),
|
| 62 |
+
"choir": ("pad", 1.2),
|
| 63 |
+
|
| 64 |
+
# Rhythm / chords — low weight
|
| 65 |
+
"guitar": ("guitar", 0.5),
|
| 66 |
+
"gtr": ("guitar", 0.5),
|
| 67 |
+
"strings": ("strings", 0.8),
|
| 68 |
+
"str": ("strings", 0.8),
|
| 69 |
+
"brass": ("strings", 0.8),
|
| 70 |
+
"horn": ("strings", 0.8),
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _get_role_and_weight(name: str) -> tuple[str, float]:
|
| 75 |
+
"""Match a track or instrument name to a role and key-detection weight."""
|
| 76 |
+
name_lower = name.lower()
|
| 77 |
+
for keyword, (role, weight) in INSTRUMENT_ROLES.items():
|
| 78 |
+
if keyword in name_lower:
|
| 79 |
+
return role, weight
|
| 80 |
+
return "unknown", 1.0
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _run_key_profiles(pitch_histogram: np.ndarray) -> list[tuple[str, str, float]]:
|
| 84 |
+
"""
|
| 85 |
+
Run Temperley + Aarden-Essen profiles against a normalized pitch histogram.
|
| 86 |
+
Returns all 24 (key, mode, score) candidates sorted best-first.
|
| 87 |
+
"""
|
| 88 |
+
scores: dict[tuple[str, str], float] = {}
|
| 89 |
+
|
| 90 |
+
for major_profile, minor_profile in [
|
| 91 |
+
(TEMPERLEY_MAJOR, TEMPERLEY_MINOR),
|
| 92 |
+
(AARDEN_MAJOR, AARDEN_MINOR),
|
| 93 |
+
]:
|
| 94 |
+
maj = np.array(major_profile)
|
| 95 |
+
min_ = np.array(minor_profile)
|
| 96 |
+
|
| 97 |
+
for semitones in range(12):
|
| 98 |
+
key_name = KEY_NAMES[semitones]
|
| 99 |
+
|
| 100 |
+
rotated_maj = np.roll(maj, semitones) / maj.sum()
|
| 101 |
+
rotated_min = np.roll(min_, semitones) / min_.sum()
|
| 102 |
+
|
| 103 |
+
corr_maj = float(np.corrcoef(pitch_histogram, rotated_maj)[0, 1])
|
| 104 |
+
corr_min = float(np.corrcoef(pitch_histogram, rotated_min)[0, 1])
|
| 105 |
+
|
| 106 |
+
scores[(key_name, "major")] = scores.get((key_name, "major"), 0.0) + corr_maj
|
| 107 |
+
scores[(key_name, "minor")] = scores.get((key_name, "minor"), 0.0) + corr_min
|
| 108 |
+
|
| 109 |
+
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
# Public API
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
|
| 116 |
def extract_bpm_from_midi(midi_file: mido.MidiFile) -> dict:
|
| 117 |
"""
|
| 118 |
Extract BPM from MIDI tempo map.
|
| 119 |
|
|
|
|
|
|
|
|
|
|
| 120 |
Returns:
|
| 121 |
dict with "bpm", "tempo_changes", "confidence"
|
| 122 |
"""
|
|
|
|
| 125 |
for track in midi_file.tracks:
|
| 126 |
for msg in track:
|
| 127 |
if msg.type == 'set_tempo':
|
|
|
|
| 128 |
bpm = 60_000_000 / msg.tempo
|
| 129 |
+
tempo_changes.append({"bpm": round(bpm, 2), "tempo_us": msg.tempo})
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
if not tempo_changes:
|
| 132 |
+
return {"bpm": 120.0, "tempo_changes": [], "confidence": 0.5}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
+
return {"bpm": tempo_changes[0]["bpm"], "tempo_changes": tempo_changes, "confidence": 1.0}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
|
| 137 |
def extract_key_from_midi(midi_file: mido.MidiFile) -> dict:
|
| 138 |
"""
|
| 139 |
+
Extract key from MIDI using instrument-priority weighting.
|
| 140 |
|
| 141 |
+
Each track is weighted by its role (bass > lead/synth > keys/pad > strings > guitar).
|
| 142 |
+
Drum tracks (drum, xo, perc, etc.) are excluded entirely.
|
| 143 |
|
| 144 |
Returns:
|
| 145 |
dict with "key", "mode", "confidence"
|
| 146 |
"""
|
| 147 |
+
# Build a single pitch-class histogram weighted by duration * velocity * instrument_weight
|
| 148 |
pitch_histogram = np.zeros(12, dtype=np.float64)
|
| 149 |
+
tracks_used = []
|
| 150 |
|
| 151 |
for track in midi_file.tracks:
|
| 152 |
+
# Resolve track name: prefer track name, fall back to instrument_name message
|
| 153 |
+
track_name = track.name.strip()
|
| 154 |
+
instrument_name = ""
|
| 155 |
+
for msg in track:
|
| 156 |
+
if msg.type == 'instrument_name':
|
| 157 |
+
instrument_name = msg.name.strip()
|
| 158 |
+
break
|
| 159 |
+
|
| 160 |
+
display_name = track_name or instrument_name or "unknown"
|
| 161 |
+
role, track_weight = _get_role_and_weight(display_name)
|
| 162 |
+
|
| 163 |
+
# Skip drums and any zero-weight tracks
|
| 164 |
+
if track_weight == 0.0:
|
| 165 |
+
continue
|
| 166 |
+
|
| 167 |
+
active_notes: dict[int, tuple[int, int]] = {}
|
| 168 |
current_time = 0
|
| 169 |
+
track_contributed = False
|
| 170 |
|
| 171 |
for msg in track:
|
| 172 |
current_time += msg.time
|
| 173 |
|
| 174 |
if msg.type == 'note_on' and msg.velocity > 0:
|
| 175 |
active_notes[msg.note] = (current_time, msg.velocity)
|
| 176 |
+
|
| 177 |
elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0):
|
| 178 |
if msg.note in active_notes:
|
| 179 |
start_time, velocity = active_notes.pop(msg.note)
|
| 180 |
duration = current_time - start_time
|
| 181 |
+
if duration <= 0:
|
| 182 |
+
continue
|
| 183 |
pitch_class = msg.note % 12
|
| 184 |
+
pitch_histogram[pitch_class] += duration * (velocity / 127.0) * track_weight
|
| 185 |
+
track_contributed = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
+
if track_contributed:
|
| 188 |
+
tracks_used.append(f"{display_name}(w={track_weight})")
|
|
|
|
|
|
|
| 189 |
|
| 190 |
+
if tracks_used:
|
| 191 |
+
print(f"Key detection using tracks: {', '.join(tracks_used)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
+
if np.sum(pitch_histogram) == 0:
|
| 194 |
+
return {"key": "C", "mode": "major", "confidence": 0.0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
+
# Normalize
|
| 197 |
+
pitch_histogram /= pitch_histogram.sum()
|
|
|
|
|
|
|
| 198 |
|
| 199 |
+
# Run profiles
|
| 200 |
+
ranked = _run_key_profiles(pitch_histogram)
|
| 201 |
+
(winner_key, winner_mode), winner_score = ranked[0]
|
| 202 |
+
second_score = ranked[1][1] if len(ranked) > 1 else 0.0
|
| 203 |
+
last_score = ranked[-1][1]
|
| 204 |
|
| 205 |
+
score_range = winner_score - last_score
|
| 206 |
+
gap = winner_score - second_score
|
| 207 |
+
confidence = round(gap / score_range, 3) if score_range > 0 else 0.0
|
|
|
|
| 208 |
|
| 209 |
+
return {"key": winner_key, "mode": winner_mode, "confidence": confidence}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
|
| 212 |
def validate_midi_audio_sync(midi_file: mido.MidiFile, audio_duration: float) -> bool:
|
| 213 |
"""
|
| 214 |
Check if MIDI duration matches audio duration within 500ms.
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
Returns:
|
| 217 |
True if durations match within tolerance
|
| 218 |
"""
|
| 219 |
+
return abs(midi_file.length - audio_duration) <= 0.5
|
|
|
|
|
|
|
|
|
backend/utils/__pycache__/music_theory.cpython-310.pyc
CHANGED
|
Binary files a/backend/utils/__pycache__/music_theory.cpython-310.pyc and b/backend/utils/__pycache__/music_theory.cpython-310.pyc differ
|
|
|
backend/utils/music_theory.py
CHANGED
|
@@ -60,3 +60,189 @@ def transpose_key(key: str, semitones: int) -> str:
|
|
| 60 |
current = SEMITONE_MAP[key]
|
| 61 |
new = (current + semitones) % 12
|
| 62 |
return KEY_NAMES[new]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
current = SEMITONE_MAP[key]
|
| 61 |
new = (current + semitones) % 12
|
| 62 |
return KEY_NAMES[new]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
# Chord templates (intervals from root as pitch-class sets)
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
CHORD_TEMPLATES = {
|
| 70 |
+
"": [0, 4, 7], # Major triad
|
| 71 |
+
"m": [0, 3, 7], # Minor triad
|
| 72 |
+
"7": [0, 4, 7, 10], # Dominant 7th
|
| 73 |
+
"m7": [0, 3, 7, 10], # Minor 7th
|
| 74 |
+
"maj7": [0, 4, 7, 11], # Major 7th
|
| 75 |
+
"dim": [0, 3, 6], # Diminished
|
| 76 |
+
"aug": [0, 4, 8], # Augmented
|
| 77 |
+
"sus4": [0, 5, 7], # Suspended 4th
|
| 78 |
+
"sus2": [0, 2, 7], # Suspended 2nd
|
| 79 |
+
"m7b5": [0, 3, 6, 10], # Half-diminished
|
| 80 |
+
"dim7": [0, 3, 6, 9], # Diminished 7th
|
| 81 |
+
"6": [0, 4, 7, 9], # Major 6th
|
| 82 |
+
"m6": [0, 3, 7, 9], # Minor 6th
|
| 83 |
+
"9": [0, 4, 7, 10, 2], # Dominant 9th
|
| 84 |
+
"add9": [0, 4, 7, 2], # Add 9
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def match_chord(pitch_classes: dict[int, float]) -> tuple[str, float]:
|
| 89 |
+
"""
|
| 90 |
+
Match a weighted set of pitch classes to the best chord.
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
pitch_classes: dict mapping pitch class (0-11) to weight (duration*velocity)
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
(chord_name, score) e.g. ("Am7", 0.85)
|
| 97 |
+
"""
|
| 98 |
+
if not pitch_classes:
|
| 99 |
+
return ("N.C.", 0.0)
|
| 100 |
+
|
| 101 |
+
total_weight = sum(pitch_classes.values())
|
| 102 |
+
if total_weight == 0:
|
| 103 |
+
return ("N.C.", 0.0)
|
| 104 |
+
|
| 105 |
+
best_chord = "N.C."
|
| 106 |
+
best_score = 0.0
|
| 107 |
+
|
| 108 |
+
for root in range(12):
|
| 109 |
+
for suffix, intervals in CHORD_TEMPLATES.items():
|
| 110 |
+
template_pcs = set((root + i) % 12 for i in intervals)
|
| 111 |
+
|
| 112 |
+
# Score: sum of weights for pitch classes that match the template
|
| 113 |
+
matched_weight = sum(pitch_classes.get(pc, 0.0) for pc in template_pcs)
|
| 114 |
+
# Penalize notes outside the template
|
| 115 |
+
outside_weight = total_weight - matched_weight
|
| 116 |
+
score = (matched_weight - 0.3 * outside_weight) / total_weight
|
| 117 |
+
|
| 118 |
+
# Bonus for root being the strongest note
|
| 119 |
+
root_weight = pitch_classes.get(root, 0.0)
|
| 120 |
+
if root_weight > 0:
|
| 121 |
+
score += 0.1 * (root_weight / total_weight)
|
| 122 |
+
|
| 123 |
+
if score > best_score:
|
| 124 |
+
best_score = score
|
| 125 |
+
best_chord = f"{KEY_NAMES[root]}{suffix}"
|
| 126 |
+
|
| 127 |
+
return (best_chord, max(0.0, best_score))
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
# Scale definitions and suggestion logic
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
|
| 134 |
+
SCALE_INTERVALS = {
|
| 135 |
+
"Major Pentatonic": [0, 2, 4, 7, 9],
|
| 136 |
+
"Minor Pentatonic": [0, 3, 5, 7, 10],
|
| 137 |
+
"Blues": [0, 3, 5, 6, 7, 10],
|
| 138 |
+
"Natural Minor": [0, 2, 3, 5, 7, 8, 10],
|
| 139 |
+
"Major (Ionian)": [0, 2, 4, 5, 7, 9, 11],
|
| 140 |
+
"Dorian": [0, 2, 3, 5, 7, 9, 10],
|
| 141 |
+
"Mixolydian": [0, 2, 4, 5, 7, 9, 10],
|
| 142 |
+
"Harmonic Minor": [0, 2, 3, 5, 7, 8, 11],
|
| 143 |
+
"Melodic Minor": [0, 2, 3, 5, 7, 9, 11],
|
| 144 |
+
"Phrygian": [0, 1, 3, 5, 7, 8, 10],
|
| 145 |
+
"Lydian": [0, 2, 4, 6, 7, 9, 11],
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
# Scales that are especially idiomatic on guitar
|
| 149 |
+
GUITAR_FRIENDLY_SCALES = {
|
| 150 |
+
"Major Pentatonic", "Minor Pentatonic", "Blues",
|
| 151 |
+
"Dorian", "Mixolydian", "Natural Minor",
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
# Keys with open-string-friendly roots on guitar (standard tuning)
|
| 155 |
+
GUITAR_OPEN_KEYS = {"E", "A", "D", "G", "C", "B"}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def suggest_scales(
|
| 159 |
+
key: str, mode: str, chords: list[dict]
|
| 160 |
+
) -> list[dict]:
|
| 161 |
+
"""
|
| 162 |
+
Suggest scales ranked by fit to the key/mode and chord progression.
|
| 163 |
+
|
| 164 |
+
Args:
|
| 165 |
+
key: Detected key name (e.g., "A")
|
| 166 |
+
mode: "major" or "minor"
|
| 167 |
+
chords: List of chord dicts with "chord" field
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
List of scale suggestion dicts, sorted by fit_score descending.
|
| 171 |
+
"""
|
| 172 |
+
# Collect all pitch classes from chord progression
|
| 173 |
+
chord_pitch_classes = set()
|
| 174 |
+
for ch in chords:
|
| 175 |
+
chord_name = ch.get("chord", "")
|
| 176 |
+
if chord_name == "N.C.":
|
| 177 |
+
continue
|
| 178 |
+
root, suffix = _parse_chord_name(chord_name)
|
| 179 |
+
if root is None:
|
| 180 |
+
continue
|
| 181 |
+
root_pc = SEMITONE_MAP.get(root, 0)
|
| 182 |
+
template = CHORD_TEMPLATES.get(suffix, [0, 4, 7])
|
| 183 |
+
for interval in template:
|
| 184 |
+
chord_pitch_classes.add((root_pc + interval) % 12)
|
| 185 |
+
|
| 186 |
+
root_pc = SEMITONE_MAP.get(key, 0)
|
| 187 |
+
results = []
|
| 188 |
+
|
| 189 |
+
for scale_name, intervals in SCALE_INTERVALS.items():
|
| 190 |
+
scale_pcs = set((root_pc + i) % 12 for i in intervals)
|
| 191 |
+
scale_notes = [KEY_NAMES[(root_pc + i) % 12] for i in intervals]
|
| 192 |
+
|
| 193 |
+
# Fit score: how many chord tones are covered by this scale
|
| 194 |
+
if chord_pitch_classes:
|
| 195 |
+
covered = len(chord_pitch_classes & scale_pcs)
|
| 196 |
+
fit = covered / len(chord_pitch_classes)
|
| 197 |
+
else:
|
| 198 |
+
fit = 0.5
|
| 199 |
+
|
| 200 |
+
# Mode alignment bonus
|
| 201 |
+
if mode == "minor" and scale_name in (
|
| 202 |
+
"Minor Pentatonic", "Blues", "Natural Minor",
|
| 203 |
+
"Dorian", "Harmonic Minor", "Phrygian",
|
| 204 |
+
):
|
| 205 |
+
fit += 0.15
|
| 206 |
+
elif mode == "major" and scale_name in (
|
| 207 |
+
"Major Pentatonic", "Major (Ionian)", "Mixolydian", "Lydian",
|
| 208 |
+
):
|
| 209 |
+
fit += 0.15
|
| 210 |
+
|
| 211 |
+
guitar_friendly = scale_name in GUITAR_FRIENDLY_SCALES
|
| 212 |
+
if guitar_friendly:
|
| 213 |
+
fit += 0.05
|
| 214 |
+
if key in GUITAR_OPEN_KEYS:
|
| 215 |
+
fit += 0.02
|
| 216 |
+
|
| 217 |
+
results.append({
|
| 218 |
+
"name": f"{key} {scale_name}",
|
| 219 |
+
"notes": scale_notes,
|
| 220 |
+
"fit_score": round(min(fit, 1.0), 3),
|
| 221 |
+
"guitar_friendly": guitar_friendly,
|
| 222 |
+
})
|
| 223 |
+
|
| 224 |
+
results.sort(key=lambda x: x["fit_score"], reverse=True)
|
| 225 |
+
return results[:6]
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _parse_chord_name(chord_name: str) -> tuple:
|
| 229 |
+
"""Parse 'Am7' into ('A', 'm7'), 'F#dim' into ('F#', 'dim'), etc."""
|
| 230 |
+
if not chord_name or chord_name == "N.C.":
|
| 231 |
+
return (None, None)
|
| 232 |
+
|
| 233 |
+
# Try two-char root first (e.g., "F#", "A#")
|
| 234 |
+
if len(chord_name) >= 2 and chord_name[1] == '#':
|
| 235 |
+
root = chord_name[:2]
|
| 236 |
+
suffix = chord_name[2:]
|
| 237 |
+
else:
|
| 238 |
+
root = chord_name[0]
|
| 239 |
+
suffix = chord_name[1:]
|
| 240 |
+
|
| 241 |
+
if root not in SEMITONE_MAP:
|
| 242 |
+
return (None, None)
|
| 243 |
+
|
| 244 |
+
# Normalize suffix
|
| 245 |
+
if suffix not in CHORD_TEMPLATES:
|
| 246 |
+
suffix = "" # Default to major triad
|
| 247 |
+
|
| 248 |
+
return (root, suffix)
|
frontend/src/App.jsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
| 2 |
import FileUpload from './components/FileUpload'
|
| 3 |
import AnalysisDisplay from './components/AnalysisDisplay'
|
| 4 |
-
import
|
|
|
|
|
|
|
| 5 |
import StemMixer from './components/StemMixer'
|
| 6 |
import TransportBar from './components/TransportBar'
|
| 7 |
-
import Waveform from './components/Waveform'
|
| 8 |
import ProcessingOverlay from './components/ProcessingOverlay'
|
| 9 |
import { useSession } from './hooks/useSession'
|
| 10 |
import { useAudioEngine } from './hooks/useAudioEngine'
|
|
@@ -17,6 +18,7 @@ function App() {
|
|
| 17 |
sessionId,
|
| 18 |
stems,
|
| 19 |
detection,
|
|
|
|
| 20 |
loading,
|
| 21 |
error,
|
| 22 |
upload,
|
|
@@ -58,20 +60,18 @@ function App() {
|
|
| 58 |
|
| 59 |
const { cacheStatus } = usePresetCache()
|
| 60 |
|
| 61 |
-
// Tracks which preset is currently being loaded so handleStemsReady can check the cache
|
| 62 |
const currentPresetNameRef = useRef(null)
|
| 63 |
|
| 64 |
const [isProcessing, setIsProcessing] = useState(false)
|
| 65 |
const [isGenerating, setIsGenerating] = useState(false)
|
|
|
|
| 66 |
const [continuationReady, setContinuationReady] = useState(false)
|
| 67 |
const { progress: processingProgress, resetProgress } = useProcessingProgress(sessionId)
|
| 68 |
|
| 69 |
// Region selection state
|
| 70 |
const [regionStart, setRegionStart] = useState(null)
|
| 71 |
const [regionEnd, setRegionEnd] = useState(null)
|
| 72 |
-
// 'full' = playing full song stems, 'region' = playing processed region slice
|
| 73 |
const [playbackMode, setPlaybackMode] = useState('full')
|
| 74 |
-
// Store the full-song duration so we can show the region on the full-length bar
|
| 75 |
const [fullSongDuration, setFullSongDuration] = useState(0)
|
| 76 |
|
| 77 |
const hasRegion = regionStart !== null && regionEnd !== null
|
|
@@ -86,7 +86,6 @@ function App() {
|
|
| 86 |
await loadPreset(presetName)
|
| 87 |
}, [loadPreset])
|
| 88 |
|
| 89 |
-
// Main play button: always plays full song — clears any active raw-region loop first
|
| 90 |
const handlePlay = useCallback(() => {
|
| 91 |
if (isRawRegionActive) {
|
| 92 |
setRawRegion(null)
|
|
@@ -95,7 +94,6 @@ function App() {
|
|
| 95 |
play()
|
| 96 |
}, [isRawRegionActive, setRawRegion, setLoop, play])
|
| 97 |
|
| 98 |
-
// Play Section: loops the selected region using full-song buffers (no processing needed)
|
| 99 |
const handlePlaySection = useCallback(() => {
|
| 100 |
if (!hasRegion) return
|
| 101 |
stop()
|
|
@@ -109,6 +107,7 @@ function App() {
|
|
| 109 |
resetProgress()
|
| 110 |
|
| 111 |
try {
|
|
|
|
| 112 |
const result = await process(
|
| 113 |
semitones,
|
| 114 |
targetBpm,
|
|
@@ -117,14 +116,12 @@ function App() {
|
|
| 117 |
)
|
| 118 |
if (result?.success && sessionId && stems.length > 0) {
|
| 119 |
if (hasRegion) {
|
| 120 |
-
// Clear stale region cache (new processing produced new audio)
|
| 121 |
clearBufferCache('region')
|
| 122 |
await loadStems(sessionId, stems, { region: true })
|
| 123 |
-
setRawRegion(null)
|
| 124 |
setPlaybackMode('region')
|
| 125 |
setLoop(true)
|
| 126 |
} else {
|
| 127 |
-
// Clear stale full cache (new processing produced new audio)
|
| 128 |
clearBufferCache('full')
|
| 129 |
await loadStems(sessionId, stems)
|
| 130 |
setPlaybackMode('full')
|
|
@@ -153,31 +150,25 @@ function App() {
|
|
| 153 |
}, [generate, hasRegion, regionStart, regionEnd, resetProgress])
|
| 154 |
|
| 155 |
const handlePlayFullSong = useCallback(async () => {
|
| 156 |
-
// Stop current playback
|
| 157 |
stop()
|
| 158 |
setRawRegion(null)
|
| 159 |
setLoop(false)
|
| 160 |
setPlaybackMode('full')
|
| 161 |
|
| 162 |
if (sessionId && stems.length > 0) {
|
| 163 |
-
// Reload full stems (original or previously full-processed)
|
| 164 |
-
// Setting processed=true will get processed if available, else original
|
| 165 |
await loadStems(sessionId, stems)
|
| 166 |
}
|
| 167 |
}, [stop, setRawRegion, setLoop, sessionId, stems, loadStems])
|
| 168 |
|
| 169 |
const handleStemsReady = useCallback(async () => {
|
| 170 |
if (sessionId && stems.length > 0) {
|
| 171 |
-
console.log('=== handleStemsReady called ===')
|
| 172 |
const presetName = currentPresetNameRef.current
|
| 173 |
if (presetName) {
|
| 174 |
const cached = await getCachedPreset(presetName, stems)
|
| 175 |
if (cached) {
|
| 176 |
-
console.log(`[preset] Cache hit for '${presetName}' — loading from IndexedDB`)
|
| 177 |
await loadStemsFromBytes(cached)
|
| 178 |
return
|
| 179 |
}
|
| 180 |
-
console.log(`[preset] Cache miss for '${presetName}' — fetching from network`)
|
| 181 |
}
|
| 182 |
try {
|
| 183 |
await loadStems(sessionId, stems)
|
|
@@ -187,23 +178,22 @@ function App() {
|
|
| 187 |
}
|
| 188 |
}, [sessionId, stems, loadStems, loadStemsFromBytes])
|
| 189 |
|
| 190 |
-
// Track full song duration whenever we're in full mode and duration updates
|
| 191 |
useEffect(() => {
|
| 192 |
if (playbackMode === 'full' && duration > 0) {
|
| 193 |
setFullSongDuration(duration)
|
| 194 |
}
|
| 195 |
}, [playbackMode, duration])
|
| 196 |
|
| 197 |
-
//
|
| 198 |
if (!sessionId) {
|
| 199 |
return (
|
| 200 |
<div className="min-h-screen flex items-center justify-center p-4">
|
| 201 |
<div className="w-full max-w-2xl">
|
| 202 |
-
<h1 className="text-
|
| 203 |
Jam Track Studio
|
| 204 |
</h1>
|
| 205 |
-
<p className="text-gray-
|
| 206 |
-
Upload
|
| 207 |
</p>
|
| 208 |
|
| 209 |
<FileUpload
|
|
@@ -219,129 +209,163 @@ function App() {
|
|
| 219 |
)
|
| 220 |
}
|
| 221 |
|
| 222 |
-
//
|
| 223 |
return (
|
| 224 |
-
<div className="min-h-screen
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
<
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
{/* Main content
|
| 240 |
-
<div className="
|
| 241 |
-
{/*
|
| 242 |
-
<
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
/>
|
|
|
|
| 248 |
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
isGenerating={isGenerating}
|
| 255 |
-
onGenerate={handleGenerate}
|
| 256 |
-
sessionId={sessionId}
|
| 257 |
-
continuationReady={continuationReady}
|
| 258 |
/>
|
|
|
|
| 259 |
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
isPlaying={isPlaying}
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
onStop={stop}
|
| 276 |
-
onSeek={seek}
|
| 277 |
-
regionStart={regionStart}
|
| 278 |
-
regionEnd={regionEnd}
|
| 279 |
-
onRegionChange={(start, end) => {
|
| 280 |
-
setRegionStart(start)
|
| 281 |
-
setRegionEnd(end)
|
| 282 |
-
if (playbackMode !== 'region') {
|
| 283 |
-
setRawRegion(start, end)
|
| 284 |
-
setLoop(true)
|
| 285 |
-
}
|
| 286 |
-
}}
|
| 287 |
-
onClearRegion={() => {
|
| 288 |
-
setRegionStart(null)
|
| 289 |
-
setRegionEnd(null)
|
| 290 |
-
setRawRegion(null)
|
| 291 |
-
setLoop(false)
|
| 292 |
-
if (playbackMode === 'region') {
|
| 293 |
-
handlePlayFullSong()
|
| 294 |
-
}
|
| 295 |
-
}}
|
| 296 |
-
playbackMode={playbackMode}
|
| 297 |
-
onPlayFullSong={handlePlayFullSong}
|
| 298 |
-
fullSongDuration={fullSongDuration}
|
| 299 |
/>
|
| 300 |
-
|
| 301 |
-
<div className="glass rounded-xl p-4 text-center">
|
| 302 |
-
<div className="flex items-center justify-center gap-3 text-gray-400">
|
| 303 |
-
<div className="animate-spin text-2xl">⏳</div>
|
| 304 |
-
<span>Loading audio for playback...</span>
|
| 305 |
-
</div>
|
| 306 |
-
</div>
|
| 307 |
-
)}
|
| 308 |
</div>
|
|
|
|
|
|
|
| 309 |
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
</div>
|
|
|
|
| 329 |
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
onClick={clearError}
|
| 337 |
-
className="text-white/80 hover:text-white"
|
| 338 |
-
>
|
| 339 |
-
×
|
| 340 |
-
</button>
|
| 341 |
-
</div>
|
| 342 |
</div>
|
| 343 |
-
|
| 344 |
-
|
| 345 |
|
| 346 |
{/* Processing overlay */}
|
| 347 |
{isProcessing && (
|
|
|
|
| 1 |
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
| 2 |
import FileUpload from './components/FileUpload'
|
| 3 |
import AnalysisDisplay from './components/AnalysisDisplay'
|
| 4 |
+
import ChordDisplay from './components/ChordDisplay'
|
| 5 |
+
import LiveChordView from './components/LiveChordView'
|
| 6 |
+
import ScalesDisplay from './components/ScalesDisplay'
|
| 7 |
import StemMixer from './components/StemMixer'
|
| 8 |
import TransportBar from './components/TransportBar'
|
|
|
|
| 9 |
import ProcessingOverlay from './components/ProcessingOverlay'
|
| 10 |
import { useSession } from './hooks/useSession'
|
| 11 |
import { useAudioEngine } from './hooks/useAudioEngine'
|
|
|
|
| 18 |
sessionId,
|
| 19 |
stems,
|
| 20 |
detection,
|
| 21 |
+
chordsData,
|
| 22 |
loading,
|
| 23 |
error,
|
| 24 |
upload,
|
|
|
|
| 60 |
|
| 61 |
const { cacheStatus } = usePresetCache()
|
| 62 |
|
|
|
|
| 63 |
const currentPresetNameRef = useRef(null)
|
| 64 |
|
| 65 |
const [isProcessing, setIsProcessing] = useState(false)
|
| 66 |
const [isGenerating, setIsGenerating] = useState(false)
|
| 67 |
+
const [appliedSemitones, setAppliedSemitones] = useState(0)
|
| 68 |
const [continuationReady, setContinuationReady] = useState(false)
|
| 69 |
const { progress: processingProgress, resetProgress } = useProcessingProgress(sessionId)
|
| 70 |
|
| 71 |
// Region selection state
|
| 72 |
const [regionStart, setRegionStart] = useState(null)
|
| 73 |
const [regionEnd, setRegionEnd] = useState(null)
|
|
|
|
| 74 |
const [playbackMode, setPlaybackMode] = useState('full')
|
|
|
|
| 75 |
const [fullSongDuration, setFullSongDuration] = useState(0)
|
| 76 |
|
| 77 |
const hasRegion = regionStart !== null && regionEnd !== null
|
|
|
|
| 86 |
await loadPreset(presetName)
|
| 87 |
}, [loadPreset])
|
| 88 |
|
|
|
|
| 89 |
const handlePlay = useCallback(() => {
|
| 90 |
if (isRawRegionActive) {
|
| 91 |
setRawRegion(null)
|
|
|
|
| 94 |
play()
|
| 95 |
}, [isRawRegionActive, setRawRegion, setLoop, play])
|
| 96 |
|
|
|
|
| 97 |
const handlePlaySection = useCallback(() => {
|
| 98 |
if (!hasRegion) return
|
| 99 |
stop()
|
|
|
|
| 107 |
resetProgress()
|
| 108 |
|
| 109 |
try {
|
| 110 |
+
setAppliedSemitones(semitones)
|
| 111 |
const result = await process(
|
| 112 |
semitones,
|
| 113 |
targetBpm,
|
|
|
|
| 116 |
)
|
| 117 |
if (result?.success && sessionId && stems.length > 0) {
|
| 118 |
if (hasRegion) {
|
|
|
|
| 119 |
clearBufferCache('region')
|
| 120 |
await loadStems(sessionId, stems, { region: true })
|
| 121 |
+
setRawRegion(null)
|
| 122 |
setPlaybackMode('region')
|
| 123 |
setLoop(true)
|
| 124 |
} else {
|
|
|
|
| 125 |
clearBufferCache('full')
|
| 126 |
await loadStems(sessionId, stems)
|
| 127 |
setPlaybackMode('full')
|
|
|
|
| 150 |
}, [generate, hasRegion, regionStart, regionEnd, resetProgress])
|
| 151 |
|
| 152 |
const handlePlayFullSong = useCallback(async () => {
|
|
|
|
| 153 |
stop()
|
| 154 |
setRawRegion(null)
|
| 155 |
setLoop(false)
|
| 156 |
setPlaybackMode('full')
|
| 157 |
|
| 158 |
if (sessionId && stems.length > 0) {
|
|
|
|
|
|
|
| 159 |
await loadStems(sessionId, stems)
|
| 160 |
}
|
| 161 |
}, [stop, setRawRegion, setLoop, sessionId, stems, loadStems])
|
| 162 |
|
| 163 |
const handleStemsReady = useCallback(async () => {
|
| 164 |
if (sessionId && stems.length > 0) {
|
|
|
|
| 165 |
const presetName = currentPresetNameRef.current
|
| 166 |
if (presetName) {
|
| 167 |
const cached = await getCachedPreset(presetName, stems)
|
| 168 |
if (cached) {
|
|
|
|
| 169 |
await loadStemsFromBytes(cached)
|
| 170 |
return
|
| 171 |
}
|
|
|
|
| 172 |
}
|
| 173 |
try {
|
| 174 |
await loadStems(sessionId, stems)
|
|
|
|
| 178 |
}
|
| 179 |
}, [sessionId, stems, loadStems, loadStemsFromBytes])
|
| 180 |
|
|
|
|
| 181 |
useEffect(() => {
|
| 182 |
if (playbackMode === 'full' && duration > 0) {
|
| 183 |
setFullSongDuration(duration)
|
| 184 |
}
|
| 185 |
}, [playbackMode, duration])
|
| 186 |
|
| 187 |
+
// Upload screen
|
| 188 |
if (!sessionId) {
|
| 189 |
return (
|
| 190 |
<div className="min-h-screen flex items-center justify-center p-4">
|
| 191 |
<div className="w-full max-w-2xl">
|
| 192 |
+
<h1 className="text-3xl font-bold text-center mb-2 text-white">
|
| 193 |
Jam Track Studio
|
| 194 |
</h1>
|
| 195 |
+
<p className="text-gray-500 text-center mb-8 text-sm">
|
| 196 |
+
Upload stems, detect BPM & key, shift pitch and tempo, mix in real-time
|
| 197 |
</p>
|
| 198 |
|
| 199 |
<FileUpload
|
|
|
|
| 209 |
)
|
| 210 |
}
|
| 211 |
|
| 212 |
+
// Main layout
|
| 213 |
return (
|
| 214 |
+
<div className="min-h-screen pb-20">
|
| 215 |
+
{/* Header */}
|
| 216 |
+
<header className="px-6 py-4 flex items-center justify-between border-b border-white/[0.04]">
|
| 217 |
+
<h1 className="text-lg font-semibold text-white">
|
| 218 |
+
Jam Track Studio
|
| 219 |
+
</h1>
|
| 220 |
+
<button
|
| 221 |
+
onClick={() => window.location.reload()}
|
| 222 |
+
className="text-xs text-gray-500 hover:text-white transition-colors"
|
| 223 |
+
>
|
| 224 |
+
Change song
|
| 225 |
+
</button>
|
| 226 |
+
</header>
|
| 227 |
+
|
| 228 |
+
<div className="flex">
|
| 229 |
+
{/* Main content — scrollable */}
|
| 230 |
+
<div className="flex-1 lg:pr-80 px-6 py-5 space-y-4">
|
| 231 |
+
{/* Merged BPM + Key controls */}
|
| 232 |
+
<AnalysisDisplay
|
| 233 |
+
detection={detection}
|
| 234 |
+
loading={loading}
|
| 235 |
+
onReady={handleStemsReady}
|
| 236 |
+
appliedSemitones={appliedSemitones}
|
| 237 |
+
onProcess={handleProcess}
|
| 238 |
+
isProcessing={isProcessing}
|
| 239 |
+
hasRegion={hasRegion}
|
| 240 |
+
/>
|
| 241 |
+
|
| 242 |
+
{/* Chord Summary (all unique chords) */}
|
| 243 |
+
{chordsData && (
|
| 244 |
+
<ChordDisplay
|
| 245 |
+
chordsData={chordsData}
|
| 246 |
+
semitones={appliedSemitones}
|
| 247 |
/>
|
| 248 |
+
)}
|
| 249 |
|
| 250 |
+
{/* Suggested Scales */}
|
| 251 |
+
{chordsData?.scales && (
|
| 252 |
+
<ScalesDisplay
|
| 253 |
+
scales={chordsData.scales}
|
| 254 |
+
semitones={appliedSemitones}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
/>
|
| 256 |
+
)}
|
| 257 |
|
| 258 |
+
{/* Live Chord Teleprompter (right above transport bar) */}
|
| 259 |
+
{chordsData && (
|
| 260 |
+
<LiveChordView
|
| 261 |
+
chordsData={chordsData}
|
| 262 |
+
currentTime={currentTime}
|
| 263 |
+
isPlaying={isPlaying}
|
| 264 |
+
semitones={appliedSemitones}
|
| 265 |
+
onSeek={seek}
|
| 266 |
+
/>
|
| 267 |
+
)}
|
| 268 |
+
|
| 269 |
+
{/* Loading indicator when stems not ready */}
|
| 270 |
+
{!isLoaded && detection && (
|
| 271 |
+
<div className="card rounded-xl p-4 text-center">
|
| 272 |
+
<span className="text-gray-500 text-sm">Loading audio...</span>
|
| 273 |
</div>
|
| 274 |
+
)}
|
| 275 |
+
</div>
|
| 276 |
+
|
| 277 |
+
{/* Right sidebar — Stem Mixer (fixed) */}
|
| 278 |
+
<div className="hidden lg:block fixed right-0 top-0 h-screen w-80 border-l border-white/[0.06] bg-surface-card overflow-y-auto pt-16">
|
| 279 |
+
<StemMixer
|
| 280 |
+
stems={stems}
|
| 281 |
+
volumes={volumes}
|
| 282 |
+
solos={solos}
|
| 283 |
+
mutes={mutes}
|
| 284 |
+
reverbs={reverbs}
|
| 285 |
+
pans={pans}
|
| 286 |
+
isPlaying={isPlaying}
|
| 287 |
+
onVolumeChange={setVolume}
|
| 288 |
+
onSoloToggle={setSolo}
|
| 289 |
+
onMuteToggle={setMute}
|
| 290 |
+
onReverbChange={setReverb}
|
| 291 |
+
onPanChange={setPan}
|
| 292 |
+
onReset={resetVolumes}
|
| 293 |
+
/>
|
| 294 |
+
</div>
|
| 295 |
|
| 296 |
+
{/* Mobile stem mixer (below content) */}
|
| 297 |
+
<div className="lg:hidden">
|
| 298 |
+
<div className="px-6 pb-4">
|
| 299 |
+
<div className="card rounded-xl">
|
| 300 |
+
<StemMixer
|
| 301 |
+
stems={stems}
|
| 302 |
+
volumes={volumes}
|
| 303 |
+
solos={solos}
|
| 304 |
+
mutes={mutes}
|
| 305 |
+
reverbs={reverbs}
|
| 306 |
+
pans={pans}
|
| 307 |
isPlaying={isPlaying}
|
| 308 |
+
onVolumeChange={setVolume}
|
| 309 |
+
onSoloToggle={setSolo}
|
| 310 |
+
onMuteToggle={setMute}
|
| 311 |
+
onReverbChange={setReverb}
|
| 312 |
+
onPanChange={setPan}
|
| 313 |
+
onReset={resetVolumes}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
/>
|
| 315 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
</div>
|
| 317 |
+
</div>
|
| 318 |
+
</div>
|
| 319 |
|
| 320 |
+
{/* Pinned bottom transport bar */}
|
| 321 |
+
{isLoaded && (
|
| 322 |
+
<div className="fixed bottom-0 left-0 right-0 lg:right-80 z-40">
|
| 323 |
+
<TransportBar
|
| 324 |
+
isPlaying={isPlaying}
|
| 325 |
+
currentTime={currentTime}
|
| 326 |
+
duration={duration}
|
| 327 |
+
onPlay={handlePlay}
|
| 328 |
+
onPlaySection={handlePlaySection}
|
| 329 |
+
isRawRegionActive={isRawRegionActive}
|
| 330 |
+
onPause={pause}
|
| 331 |
+
onStop={stop}
|
| 332 |
+
onSeek={seek}
|
| 333 |
+
regionStart={regionStart}
|
| 334 |
+
regionEnd={regionEnd}
|
| 335 |
+
bpm={detection?.bpm}
|
| 336 |
+
onRegionChange={(start, end) => {
|
| 337 |
+
setRegionStart(start)
|
| 338 |
+
setRegionEnd(end)
|
| 339 |
+
if (playbackMode !== 'region') {
|
| 340 |
+
setRawRegion(start, end)
|
| 341 |
+
setLoop(true)
|
| 342 |
+
}
|
| 343 |
+
}}
|
| 344 |
+
onClearRegion={() => {
|
| 345 |
+
setRegionStart(null)
|
| 346 |
+
setRegionEnd(null)
|
| 347 |
+
setRawRegion(null)
|
| 348 |
+
setLoop(false)
|
| 349 |
+
if (playbackMode === 'region') {
|
| 350 |
+
handlePlayFullSong()
|
| 351 |
+
}
|
| 352 |
+
}}
|
| 353 |
+
playbackMode={playbackMode}
|
| 354 |
+
onPlayFullSong={handlePlayFullSong}
|
| 355 |
+
fullSongDuration={fullSongDuration}
|
| 356 |
+
/>
|
| 357 |
</div>
|
| 358 |
+
)}
|
| 359 |
|
| 360 |
+
{/* Error toast */}
|
| 361 |
+
{error && (
|
| 362 |
+
<div className="fixed bottom-20 right-4 lg:right-84 bg-red-500/90 text-white px-4 py-3 rounded-lg shadow-lg animate-fade-in z-50">
|
| 363 |
+
<div className="flex items-center gap-3">
|
| 364 |
+
<span className="text-sm">{error}</span>
|
| 365 |
+
<button onClick={clearError} className="text-white/80 hover:text-white">×</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
</div>
|
| 367 |
+
</div>
|
| 368 |
+
)}
|
| 369 |
|
| 370 |
{/* Processing overlay */}
|
| 371 |
{isProcessing && (
|
frontend/src/components/AnalysisDisplay.jsx
CHANGED
|
@@ -1,71 +1,168 @@
|
|
| 1 |
-
import React, { useEffect } from 'react'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
function AnalysisDisplay({ detection, loading, onReady }) {
|
| 4 |
useEffect(() => {
|
| 5 |
if (detection && onReady) {
|
| 6 |
-
console.log('=== AnalysisDisplay: detection ready, calling onReady ===')
|
| 7 |
-
console.log('Detection data:', detection)
|
| 8 |
onReady()
|
| 9 |
}
|
| 10 |
}, [detection, onReady])
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
if (loading) {
|
| 13 |
return (
|
| 14 |
-
<div className="
|
| 15 |
-
<div className="flex
|
| 16 |
-
<div className="
|
| 17 |
-
<div className="
|
| 18 |
-
<div className="h-6 bg-gray-700/50 rounded w-1/2"></div>
|
| 19 |
-
<div className="h-4 bg-gray-700/50 rounded w-1/3"></div>
|
| 20 |
-
</div>
|
| 21 |
</div>
|
| 22 |
</div>
|
| 23 |
)
|
| 24 |
}
|
| 25 |
|
| 26 |
-
if (!detection)
|
| 27 |
-
return null
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
const getConfidenceColor = (confidence) => {
|
| 31 |
-
if (confidence >= 0.8) return 'bg-green-500'
|
| 32 |
-
if (confidence >= 0.6) return 'bg-yellow-500'
|
| 33 |
-
return 'bg-red-500'
|
| 34 |
-
}
|
| 35 |
|
| 36 |
-
const
|
| 37 |
-
|
| 38 |
-
if (confidence >= 0.6) return 'Medium'
|
| 39 |
-
return 'Low'
|
| 40 |
-
}
|
| 41 |
|
| 42 |
return (
|
| 43 |
-
<div className="
|
| 44 |
-
<
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
<
|
| 50 |
-
<
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
</div>
|
| 56 |
|
| 57 |
-
{/*
|
| 58 |
-
<div className="
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
</span>
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
</div>
|
| 70 |
</div>
|
| 71 |
)
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
| 2 |
+
|
| 3 |
+
const ALL_KEYS_WITH_MODES = [
|
| 4 |
+
'C major', 'C minor', 'C# major', 'C# minor',
|
| 5 |
+
'D major', 'D minor', 'D# major', 'D# minor',
|
| 6 |
+
'E major', 'E minor', 'F major', 'F minor',
|
| 7 |
+
'F# major', 'F# minor', 'G major', 'G minor',
|
| 8 |
+
'G# major', 'G# minor', 'A major', 'A minor',
|
| 9 |
+
'A# major', 'A# minor', 'B major', 'B minor'
|
| 10 |
+
]
|
| 11 |
+
|
| 12 |
+
const KEY_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
| 13 |
+
|
| 14 |
+
function AnalysisDisplay({
|
| 15 |
+
detection,
|
| 16 |
+
loading,
|
| 17 |
+
onReady,
|
| 18 |
+
appliedSemitones = 0,
|
| 19 |
+
onProcess,
|
| 20 |
+
isProcessing,
|
| 21 |
+
hasRegion
|
| 22 |
+
}) {
|
| 23 |
+
const [targetKeyWithMode, setTargetKeyWithMode] = useState('C major')
|
| 24 |
+
const [targetBpm, setTargetBpm] = useState(120)
|
| 25 |
|
|
|
|
| 26 |
useEffect(() => {
|
| 27 |
if (detection && onReady) {
|
|
|
|
|
|
|
| 28 |
onReady()
|
| 29 |
}
|
| 30 |
}, [detection, onReady])
|
| 31 |
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
if (detection) {
|
| 34 |
+
setTargetKeyWithMode(`${detection.key} ${detection.mode}`)
|
| 35 |
+
setTargetBpm(detection.bpm)
|
| 36 |
+
}
|
| 37 |
+
}, [detection])
|
| 38 |
+
|
| 39 |
+
const targetKey = targetKeyWithMode.split(' ')[0]
|
| 40 |
+
|
| 41 |
+
const semitones = useMemo(() => {
|
| 42 |
+
if (!detection || !targetKey) return 0
|
| 43 |
+
const fromIdx = KEY_NAMES.indexOf(detection.key)
|
| 44 |
+
const toIdx = KEY_NAMES.indexOf(targetKey)
|
| 45 |
+
let diff = toIdx - fromIdx
|
| 46 |
+
if (diff > 6) diff -= 12
|
| 47 |
+
if (diff < -6) diff += 12
|
| 48 |
+
return diff
|
| 49 |
+
}, [detection, targetKey])
|
| 50 |
+
|
| 51 |
+
const handleKeyShift = useCallback((shift) => {
|
| 52 |
+
const currentIdx = KEY_NAMES.indexOf(targetKey)
|
| 53 |
+
const newIdx = (currentIdx + shift + 12) % 12
|
| 54 |
+
const mode = targetKeyWithMode.split(' ')[1]
|
| 55 |
+
setTargetKeyWithMode(`${KEY_NAMES[newIdx]} ${mode}`)
|
| 56 |
+
}, [targetKey, targetKeyWithMode])
|
| 57 |
+
|
| 58 |
+
const handleApply = useCallback(() => {
|
| 59 |
+
if (!detection || !onProcess) return
|
| 60 |
+
const newBpm = Math.abs(targetBpm - detection.bpm) > 0.1 ? targetBpm : null
|
| 61 |
+
onProcess(semitones, newBpm)
|
| 62 |
+
}, [onProcess, semitones, targetBpm, detection])
|
| 63 |
+
|
| 64 |
+
const hasChanges = detection && (semitones !== 0 || Math.abs(targetBpm - detection.bpm) > 0.1)
|
| 65 |
+
|
| 66 |
if (loading) {
|
| 67 |
return (
|
| 68 |
+
<div className="card rounded-xl p-5 animate-pulse">
|
| 69 |
+
<div className="flex gap-6">
|
| 70 |
+
<div className="h-12 bg-white/5 rounded-lg w-40"></div>
|
| 71 |
+
<div className="h-12 bg-white/5 rounded-lg w-40"></div>
|
|
|
|
|
|
|
|
|
|
| 72 |
</div>
|
| 73 |
</div>
|
| 74 |
)
|
| 75 |
}
|
| 76 |
|
| 77 |
+
if (!detection) return null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
+
const btnClass = 'w-8 h-8 rounded-lg font-bold text-sm transition-all bg-white/[0.06] hover:bg-white/[0.12] text-gray-300 border border-white/[0.08] flex items-center justify-center flex-shrink-0'
|
| 80 |
+
const inputClass = 'bg-surface-elevated border border-white/[0.08] rounded-lg text-white text-sm text-center focus:outline-none focus:border-accent-500 transition-colors'
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
return (
|
| 83 |
+
<div className="card rounded-xl p-5 animate-fade-in">
|
| 84 |
+
<div className="flex items-center gap-6 flex-wrap">
|
| 85 |
+
|
| 86 |
+
{/* BPM Control: - [editable input] + */}
|
| 87 |
+
<div className="flex items-center gap-1.5">
|
| 88 |
+
<span className="text-[10px] uppercase tracking-widest text-accent-500 font-medium mr-2">BPM</span>
|
| 89 |
+
<button onClick={() => setTargetBpm(v => Math.max(20, v - 1))} className={btnClass}>-</button>
|
| 90 |
+
<input
|
| 91 |
+
type="number"
|
| 92 |
+
value={Math.round(targetBpm)}
|
| 93 |
+
onChange={(e) => {
|
| 94 |
+
const val = parseFloat(e.target.value)
|
| 95 |
+
if (!isNaN(val) && val >= 20 && val <= 400) setTargetBpm(val)
|
| 96 |
+
}}
|
| 97 |
+
min={20}
|
| 98 |
+
max={400}
|
| 99 |
+
className={`${inputClass} w-20 px-2 py-1.5 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none`}
|
| 100 |
+
/>
|
| 101 |
+
<button onClick={() => setTargetBpm(v => Math.min(400, v + 1))} className={btnClass}>+</button>
|
| 102 |
+
{Math.abs(targetBpm - detection.bpm) > 0.1 && (
|
| 103 |
+
<button
|
| 104 |
+
onClick={() => setTargetBpm(detection.bpm)}
|
| 105 |
+
className="text-[10px] text-gray-500 hover:text-gray-300 transition-colors ml-1"
|
| 106 |
+
>
|
| 107 |
+
Reset
|
| 108 |
+
</button>
|
| 109 |
+
)}
|
| 110 |
</div>
|
| 111 |
|
| 112 |
+
{/* Divider */}
|
| 113 |
+
<div className="w-px h-10 bg-white/[0.08]"></div>
|
| 114 |
+
|
| 115 |
+
{/* Key Control: - [dropdown] + */}
|
| 116 |
+
<div className="flex items-center gap-1.5">
|
| 117 |
+
<span className="text-[10px] uppercase tracking-widest text-accent-500 font-medium mr-2">Key</span>
|
| 118 |
+
<button onClick={() => handleKeyShift(-1)} className={btnClass}>-</button>
|
| 119 |
+
<select
|
| 120 |
+
value={targetKeyWithMode}
|
| 121 |
+
onChange={(e) => setTargetKeyWithMode(e.target.value)}
|
| 122 |
+
className={`${inputClass} w-28 px-2 py-1.5 cursor-pointer`}
|
| 123 |
+
>
|
| 124 |
+
{ALL_KEYS_WITH_MODES.map(k => (
|
| 125 |
+
<option key={k} value={k}>{k}</option>
|
| 126 |
+
))}
|
| 127 |
+
</select>
|
| 128 |
+
<button onClick={() => handleKeyShift(1)} className={btnClass}>+</button>
|
| 129 |
+
{semitones !== 0 && (
|
| 130 |
+
<span className="text-xs text-gray-500 font-mono ml-1">
|
| 131 |
+
{semitones > 0 ? '+' : ''}{semitones}st
|
| 132 |
</span>
|
| 133 |
+
)}
|
| 134 |
+
{semitones !== 0 && (
|
| 135 |
+
<button
|
| 136 |
+
onClick={() => setTargetKeyWithMode(`${detection.key} ${detection.mode}`)}
|
| 137 |
+
className="text-[10px] text-gray-500 hover:text-gray-300 transition-colors ml-1"
|
| 138 |
+
>
|
| 139 |
+
Reset
|
| 140 |
+
</button>
|
| 141 |
+
)}
|
| 142 |
</div>
|
| 143 |
+
|
| 144 |
+
{/* Divider */}
|
| 145 |
+
<div className="w-px h-10 bg-white/[0.08]"></div>
|
| 146 |
+
|
| 147 |
+
{/* Apply Button */}
|
| 148 |
+
<button
|
| 149 |
+
onClick={handleApply}
|
| 150 |
+
disabled={!hasChanges || isProcessing}
|
| 151 |
+
className={`px-5 py-2 rounded-lg text-sm font-medium transition-all ${
|
| 152 |
+
!hasChanges || isProcessing
|
| 153 |
+
? 'bg-white/[0.04] text-gray-600 cursor-not-allowed'
|
| 154 |
+
: 'bg-accent-500 hover:bg-accent-400 text-white'
|
| 155 |
+
}`}
|
| 156 |
+
>
|
| 157 |
+
{isProcessing ? 'Processing...' : hasChanges ? (hasRegion ? 'Apply to Selection' : 'Apply') : 'No Changes'}
|
| 158 |
+
</button>
|
| 159 |
+
|
| 160 |
+
{/* Original values */}
|
| 161 |
+
{(semitones !== 0 || Math.abs(targetBpm - detection.bpm) > 0.1) && (
|
| 162 |
+
<span className="text-[10px] text-gray-600 ml-auto">
|
| 163 |
+
Original: {detection.key} {detection.mode} / {detection.bpm.toFixed(1)} BPM
|
| 164 |
+
</span>
|
| 165 |
+
)}
|
| 166 |
</div>
|
| 167 |
</div>
|
| 168 |
)
|
frontend/src/components/ChordDisplay.jsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useMemo } from 'react'
|
| 2 |
+
|
| 3 |
+
const KEY_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
| 4 |
+
|
| 5 |
+
export function transposeChord(chord, semitones) {
|
| 6 |
+
if (!chord || chord === 'N.C.' || semitones === 0) return chord
|
| 7 |
+
let root, suffix
|
| 8 |
+
if (chord.length >= 2 && chord[1] === '#') {
|
| 9 |
+
root = chord.slice(0, 2)
|
| 10 |
+
suffix = chord.slice(2)
|
| 11 |
+
} else {
|
| 12 |
+
root = chord[0]
|
| 13 |
+
suffix = chord.slice(1)
|
| 14 |
+
}
|
| 15 |
+
const idx = KEY_NAMES.indexOf(root)
|
| 16 |
+
if (idx === -1) return chord
|
| 17 |
+
return KEY_NAMES[(idx + semitones + 120) % 12] + suffix
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function ChordDisplay({ chordsData, semitones = 0 }) {
|
| 21 |
+
const chords = chordsData?.chords || []
|
| 22 |
+
|
| 23 |
+
// Build unique chord list
|
| 24 |
+
const uniqueChords = useMemo(() => {
|
| 25 |
+
const seen = new Set()
|
| 26 |
+
return chords
|
| 27 |
+
.filter(c => c.chord !== 'N.C.')
|
| 28 |
+
.map(c => transposeChord(c.chord, semitones))
|
| 29 |
+
.filter(c => {
|
| 30 |
+
if (seen.has(c)) return false
|
| 31 |
+
seen.add(c)
|
| 32 |
+
return true
|
| 33 |
+
})
|
| 34 |
+
}, [chords, semitones])
|
| 35 |
+
|
| 36 |
+
if (!chordsData || uniqueChords.length === 0) return null
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="card rounded-xl p-5 animate-fade-in">
|
| 40 |
+
<div className="flex items-center justify-between mb-3">
|
| 41 |
+
<h2 className="text-sm font-semibold text-white">Chords in this Song</h2>
|
| 42 |
+
<span className="text-[10px] text-gray-600 uppercase tracking-wider">
|
| 43 |
+
{chordsData.chord_source === 'midi' ? 'MIDI' : 'Audio'}
|
| 44 |
+
</span>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div className="flex flex-wrap gap-2">
|
| 48 |
+
{uniqueChords.map((chord, i) => (
|
| 49 |
+
<span
|
| 50 |
+
key={i}
|
| 51 |
+
className="px-3 py-1.5 rounded-lg bg-surface-elevated border border-white/[0.06] text-sm font-semibold text-gray-300"
|
| 52 |
+
>
|
| 53 |
+
{chord}
|
| 54 |
+
</span>
|
| 55 |
+
))}
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export default React.memo(ChordDisplay)
|
frontend/src/components/FileUpload.jsx
CHANGED
|
@@ -88,18 +88,18 @@ function FileUpload({ onUpload, onLoadPreset, loading, error, onClearError, cach
|
|
| 88 |
*/
|
| 89 |
|
| 90 |
return (
|
| 91 |
-
<div className="
|
| 92 |
<h2 className="text-xl font-semibold mb-6 text-center">Load a Demo Track</h2>
|
| 93 |
|
| 94 |
{/* Demo presets dropdown */}
|
| 95 |
{presets.length > 0 && (
|
| 96 |
-
<div className="p-4 bg-
|
| 97 |
<div className="flex gap-3">
|
| 98 |
<select
|
| 99 |
value={selectedPreset}
|
| 100 |
onChange={(e) => setSelectedPreset(e.target.value)}
|
| 101 |
disabled={loading}
|
| 102 |
-
className="flex-1 bg-
|
| 103 |
>
|
| 104 |
<option value="">Select a demo...</option>
|
| 105 |
{presets.map(p => {
|
|
@@ -115,7 +115,7 @@ function FileUpload({ onUpload, onLoadPreset, loading, error, onClearError, cach
|
|
| 115 |
<button
|
| 116 |
onClick={() => selectedPreset && onLoadPreset(selectedPreset)}
|
| 117 |
disabled={loading || !selectedPreset}
|
| 118 |
-
className="px-4 py-2 rounded-lg text-sm font-medium transition-all disabled:bg-
|
| 119 |
>
|
| 120 |
{loading ? 'Loading...' : 'Load'}
|
| 121 |
</button>
|
|
|
|
| 88 |
*/
|
| 89 |
|
| 90 |
return (
|
| 91 |
+
<div className="card rounded-2xl p-6 animate-fade-in max-w-4xl mx-auto">
|
| 92 |
<h2 className="text-xl font-semibold mb-6 text-center">Load a Demo Track</h2>
|
| 93 |
|
| 94 |
{/* Demo presets dropdown */}
|
| 95 |
{presets.length > 0 && (
|
| 96 |
+
<div className="p-4 bg-surface-elevated rounded-xl border border-white/[0.08]">
|
| 97 |
<div className="flex gap-3">
|
| 98 |
<select
|
| 99 |
value={selectedPreset}
|
| 100 |
onChange={(e) => setSelectedPreset(e.target.value)}
|
| 101 |
disabled={loading}
|
| 102 |
+
className="flex-1 bg-surface border border-white/[0.08] rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-accent-500"
|
| 103 |
>
|
| 104 |
<option value="">Select a demo...</option>
|
| 105 |
{presets.map(p => {
|
|
|
|
| 115 |
<button
|
| 116 |
onClick={() => selectedPreset && onLoadPreset(selectedPreset)}
|
| 117 |
disabled={loading || !selectedPreset}
|
| 118 |
+
className="px-4 py-2 rounded-lg text-sm font-medium transition-all disabled:bg-white/[0.04] disabled:text-gray-600 disabled:cursor-not-allowed bg-accent-500 hover:bg-accent-400 text-white"
|
| 119 |
>
|
| 120 |
{loading ? 'Loading...' : 'Load'}
|
| 121 |
</button>
|
frontend/src/components/LiveChordView.jsx
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useMemo, useRef, useEffect, useState } from 'react'
|
| 2 |
+
import { transposeChord } from './ChordDisplay'
|
| 3 |
+
|
| 4 |
+
const VISIBLE_SIDES = 3 // number of chords to show on each side
|
| 5 |
+
|
| 6 |
+
function LiveChordView({ chordsData, currentTime, isPlaying, semitones = 0, onSeek }) {
|
| 7 |
+
const containerRef = useRef(null)
|
| 8 |
+
const [prevIndex, setPrevIndex] = useState(-1)
|
| 9 |
+
|
| 10 |
+
const chords = chordsData?.chords || []
|
| 11 |
+
const hasChords = chords.length > 0
|
| 12 |
+
|
| 13 |
+
// Binary search for active chord
|
| 14 |
+
const activeIndex = useMemo(() => {
|
| 15 |
+
if (!hasChords) return -1
|
| 16 |
+
let lo = 0, hi = chords.length - 1
|
| 17 |
+
while (lo <= hi) {
|
| 18 |
+
const mid = (lo + hi) >> 1
|
| 19 |
+
if (currentTime < chords[mid].start_time) hi = mid - 1
|
| 20 |
+
else if (currentTime >= chords[mid].end_time) lo = mid + 1
|
| 21 |
+
else return mid
|
| 22 |
+
}
|
| 23 |
+
return -1
|
| 24 |
+
}, [chords, currentTime, hasChords])
|
| 25 |
+
|
| 26 |
+
// Track previous index for transition direction
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
if (activeIndex !== -1 && activeIndex !== prevIndex) {
|
| 29 |
+
setPrevIndex(activeIndex)
|
| 30 |
+
}
|
| 31 |
+
}, [activeIndex, prevIndex])
|
| 32 |
+
|
| 33 |
+
if (!chordsData || !hasChords) return null
|
| 34 |
+
|
| 35 |
+
// Progress within the current chord (0-1)
|
| 36 |
+
const chordProgress = activeIndex >= 0
|
| 37 |
+
? (() => {
|
| 38 |
+
const c = chords[activeIndex]
|
| 39 |
+
const span = c.end_time - c.start_time
|
| 40 |
+
return span > 0 ? Math.max(0, Math.min(1, (currentTime - c.start_time) / span)) : 0
|
| 41 |
+
})()
|
| 42 |
+
: 0
|
| 43 |
+
|
| 44 |
+
// Build the visible window of chords
|
| 45 |
+
const windowStart = Math.max(0, (activeIndex >= 0 ? activeIndex : 0) - VISIBLE_SIDES)
|
| 46 |
+
const windowEnd = Math.min(chords.length - 1, (activeIndex >= 0 ? activeIndex : 0) + VISIBLE_SIDES)
|
| 47 |
+
|
| 48 |
+
const visibleChords = []
|
| 49 |
+
for (let i = windowStart; i <= windowEnd; i++) {
|
| 50 |
+
visibleChords.push({ ...chords[i], index: i })
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<div className="card rounded-xl p-6 animate-fade-in">
|
| 55 |
+
{/* Teleprompter row */}
|
| 56 |
+
<div
|
| 57 |
+
ref={containerRef}
|
| 58 |
+
className="flex items-center justify-center gap-2 overflow-hidden py-4"
|
| 59 |
+
style={{ minHeight: '100px' }}
|
| 60 |
+
>
|
| 61 |
+
{visibleChords.map((entry) => {
|
| 62 |
+
const i = entry.index
|
| 63 |
+
const isActive = i === activeIndex
|
| 64 |
+
const isPast = activeIndex >= 0 && i < activeIndex
|
| 65 |
+
const isFuture = activeIndex >= 0 && i > activeIndex
|
| 66 |
+
const displayChord = transposeChord(entry.chord, semitones)
|
| 67 |
+
const isNC = displayChord === 'N.C.'
|
| 68 |
+
|
| 69 |
+
// Distance from active chord (0 = active, 1 = adjacent, etc.)
|
| 70 |
+
const distance = activeIndex >= 0 ? Math.abs(i - activeIndex) : 0
|
| 71 |
+
|
| 72 |
+
// Opacity and scale based on distance
|
| 73 |
+
let opacity, scale, textSize
|
| 74 |
+
if (isActive) {
|
| 75 |
+
opacity = 1
|
| 76 |
+
scale = 1
|
| 77 |
+
textSize = 'text-5xl'
|
| 78 |
+
} else if (distance === 1) {
|
| 79 |
+
opacity = isPast ? 0.35 : 0.5
|
| 80 |
+
scale = 0.7
|
| 81 |
+
textSize = 'text-2xl'
|
| 82 |
+
} else if (distance === 2) {
|
| 83 |
+
opacity = isPast ? 0.18 : 0.25
|
| 84 |
+
scale = 0.55
|
| 85 |
+
textSize = 'text-xl'
|
| 86 |
+
} else {
|
| 87 |
+
opacity = isPast ? 0.08 : 0.12
|
| 88 |
+
scale = 0.45
|
| 89 |
+
textSize = 'text-lg'
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
if (isNC && !isActive) {
|
| 93 |
+
opacity *= 0.4
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return (
|
| 97 |
+
<button
|
| 98 |
+
key={i}
|
| 99 |
+
onClick={() => onSeek && onSeek(entry.start_time)}
|
| 100 |
+
className={`
|
| 101 |
+
flex-shrink-0 transition-all duration-300 ease-out cursor-pointer
|
| 102 |
+
font-bold tracking-tight px-3
|
| 103 |
+
${textSize}
|
| 104 |
+
${isActive ? 'text-white' : 'text-gray-400'}
|
| 105 |
+
`}
|
| 106 |
+
style={{
|
| 107 |
+
opacity,
|
| 108 |
+
transform: `scale(${scale})`,
|
| 109 |
+
}}
|
| 110 |
+
>
|
| 111 |
+
{displayChord}
|
| 112 |
+
</button>
|
| 113 |
+
)
|
| 114 |
+
})}
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
{/* Subtle label */}
|
| 118 |
+
{activeIndex >= 0 && (
|
| 119 |
+
<p className="text-center text-[10px] text-gray-600 mt-1 uppercase tracking-wider">
|
| 120 |
+
Now Playing
|
| 121 |
+
</p>
|
| 122 |
+
)}
|
| 123 |
+
</div>
|
| 124 |
+
)
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
export default React.memo(LiveChordView)
|
frontend/src/components/ProcessingOverlay.jsx
CHANGED
|
@@ -11,7 +11,7 @@ function ProcessingOverlay({ stems, progress }) {
|
|
| 11 |
|
| 12 |
return (
|
| 13 |
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in">
|
| 14 |
-
<div className="
|
| 15 |
<div className="text-center mb-6">
|
| 16 |
<div className="inline-block animate-spin-slow text-5xl mb-4">
|
| 17 |
⚙️
|
|
@@ -68,7 +68,7 @@ function ProcessingOverlay({ stems, progress }) {
|
|
| 68 |
<div className="mt-6">
|
| 69 |
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
| 70 |
<div
|
| 71 |
-
className="h-full bg-
|
| 72 |
style={{
|
| 73 |
width: `${(doneCount / Math.max(totalSteps, 1)) * 100}%`
|
| 74 |
}}
|
|
|
|
| 11 |
|
| 12 |
return (
|
| 13 |
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in">
|
| 14 |
+
<div className="card rounded-2xl p-8 max-w-md w-full mx-4">
|
| 15 |
<div className="text-center mb-6">
|
| 16 |
<div className="inline-block animate-spin-slow text-5xl mb-4">
|
| 17 |
⚙️
|
|
|
|
| 68 |
<div className="mt-6">
|
| 69 |
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
| 70 |
<div
|
| 71 |
+
className="h-full bg-accent-500 transition-all duration-300"
|
| 72 |
style={{
|
| 73 |
width: `${(doneCount / Math.max(totalSteps, 1)) * 100}%`
|
| 74 |
}}
|
frontend/src/components/ScalesDisplay.jsx
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
|
| 3 |
+
const KEY_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
| 4 |
+
|
| 5 |
+
function transposeNote(note, semitones) {
|
| 6 |
+
if (!note || semitones === 0) return note
|
| 7 |
+
const idx = KEY_NAMES.indexOf(note)
|
| 8 |
+
if (idx === -1) return note
|
| 9 |
+
return KEY_NAMES[(idx + semitones + 120) % 12]
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
function ScalesDisplay({ scales, semitones = 0 }) {
|
| 13 |
+
if (!scales || scales.length === 0) return null
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div className="card rounded-xl p-5 animate-fade-in">
|
| 17 |
+
<h2 className="text-sm font-semibold text-white mb-3">Suggested Scales</h2>
|
| 18 |
+
|
| 19 |
+
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-thin">
|
| 20 |
+
{scales.map((scale, i) => {
|
| 21 |
+
const parts = scale.name.split(' ')
|
| 22 |
+
const scaleRoot = parts[0]
|
| 23 |
+
const scaleSuffix = parts.slice(1).join(' ')
|
| 24 |
+
const transposedRoot = transposeNote(scaleRoot, semitones)
|
| 25 |
+
const transposedNotes = scale.notes.map(n => transposeNote(n, semitones))
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<div
|
| 29 |
+
key={i}
|
| 30 |
+
className={`
|
| 31 |
+
flex-shrink-0 rounded-xl px-4 py-3 border transition-colors min-w-[200px]
|
| 32 |
+
${i === 0
|
| 33 |
+
? 'bg-accent-500/[0.08] border-accent-500/20'
|
| 34 |
+
: 'bg-surface-elevated border-white/[0.06]'
|
| 35 |
+
}
|
| 36 |
+
`}
|
| 37 |
+
>
|
| 38 |
+
{/* Header row */}
|
| 39 |
+
<div className="flex items-center gap-2 mb-2">
|
| 40 |
+
<span className="text-sm font-semibold text-white">
|
| 41 |
+
{transposedRoot} {scaleSuffix}
|
| 42 |
+
</span>
|
| 43 |
+
{scale.guitar_friendly && (
|
| 44 |
+
<span className="text-[10px] text-amber-400 font-medium">guitar</span>
|
| 45 |
+
)}
|
| 46 |
+
{i === 0 && (
|
| 47 |
+
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent-500/20 text-accent-400 font-medium ml-auto">
|
| 48 |
+
Best fit
|
| 49 |
+
</span>
|
| 50 |
+
)}
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
{/* Note chips */}
|
| 54 |
+
<div className="flex flex-wrap gap-1">
|
| 55 |
+
{transposedNotes.map((note, j) => (
|
| 56 |
+
<span
|
| 57 |
+
key={j}
|
| 58 |
+
className="text-[11px] px-1.5 py-0.5 rounded bg-surface text-gray-400"
|
| 59 |
+
>
|
| 60 |
+
{note}
|
| 61 |
+
</span>
|
| 62 |
+
))}
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
)
|
| 66 |
+
})}
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
)
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export default React.memo(ScalesDisplay)
|
frontend/src/components/StemMixer.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React, { useRef } from 'react'
|
| 2 |
|
| 3 |
const STEM_ICONS = {
|
| 4 |
bass: '🎸',
|
|
@@ -13,11 +13,13 @@ const STEM_ICONS = {
|
|
| 13 |
other: '🎵'
|
| 14 |
}
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
function VolumeSlider({ value, onChange, disabled }) {
|
| 18 |
const containerRef = useRef(null)
|
| 19 |
const dragging = useRef(false)
|
| 20 |
-
|
| 21 |
const pct = Math.max(0, Math.min(1, value)) * 100
|
| 22 |
|
| 23 |
const calcValue = (clientX) => {
|
|
@@ -42,54 +44,45 @@ function VolumeSlider({ value, onChange, disabled }) {
|
|
| 42 |
return (
|
| 43 |
<div
|
| 44 |
ref={containerRef}
|
| 45 |
-
className="relative h-
|
| 46 |
style={{ cursor: disabled ? 'default' : 'pointer', touchAction: 'none' }}
|
| 47 |
onPointerDown={handlePointerDown}
|
| 48 |
onPointerMove={handlePointerMove}
|
| 49 |
onPointerUp={handlePointerUp}
|
| 50 |
>
|
| 51 |
-
{/* Track */}
|
| 52 |
<div
|
| 53 |
className="absolute inset-x-0 rounded-full"
|
| 54 |
style={{ height: 4, top: '50%', transform: 'translateY(-50%)' }}
|
| 55 |
>
|
| 56 |
-
<div className="w-full h-full rounded-full bg-
|
| 57 |
<div
|
| 58 |
className="absolute top-0 left-0 h-full rounded-full"
|
| 59 |
-
style={{ width: `${pct}%`, background: disabled ? '#
|
| 60 |
/>
|
| 61 |
</div>
|
| 62 |
-
|
| 63 |
-
{/* Thumb — circular, centered on the track */}
|
| 64 |
<div
|
| 65 |
className="absolute rounded-full pointer-events-none"
|
| 66 |
style={{
|
| 67 |
-
width:
|
| 68 |
-
height:
|
| 69 |
top: '50%',
|
| 70 |
left: `${pct}%`,
|
| 71 |
transform: 'translate(-50%, -50%)',
|
| 72 |
background: disabled ? '#374151' : 'white',
|
| 73 |
-
border: `2px solid ${disabled ? '#
|
| 74 |
-
boxShadow: disabled ? 'none' : '0 0 0 3px rgba(168,85,247,0.25), 0 0 6px rgba(168,85,247,0.4)',
|
| 75 |
}}
|
| 76 |
/>
|
| 77 |
</div>
|
| 78 |
)
|
| 79 |
}
|
| 80 |
|
| 81 |
-
// ─── SVG rotary knob ─────────────────────────────────────────────────────────
|
| 82 |
-
// True rotary: tracks the angle of the mouse around the knob center.
|
| 83 |
-
// Dragging clockwise increases value; counterclockwise decreases it.
|
| 84 |
function Knob({ value, min, max, onChange, label, color, showCenterTick = false, disabled, endLabels }) {
|
| 85 |
const dragRef = useRef(null)
|
| 86 |
-
|
| 87 |
const norm = Math.max(0, Math.min(1, (value - min) / (max - min)))
|
| 88 |
-
const SIZE =
|
| 89 |
const cx = SIZE / 2
|
| 90 |
const cy = SIZE / 2
|
| 91 |
-
const R =
|
| 92 |
-
|
| 93 |
const START = -135
|
| 94 |
const angleDeg = START + norm * 270
|
| 95 |
|
|
@@ -106,18 +99,7 @@ function Knob({ value, min, max, onChange, label, color, showCenterTick = false,
|
|
| 106 |
}
|
| 107 |
|
| 108 |
const tip = toXY(angleDeg, R - 3)
|
| 109 |
-
const minTick = toXY(START, R + 1)
|
| 110 |
-
const minTickIn = toXY(START, R - 2)
|
| 111 |
-
const maxTick = toXY(135, R + 1)
|
| 112 |
-
const maxTickIn = toXY(135, R - 2)
|
| 113 |
-
const cTick = toXY(0, R + 1)
|
| 114 |
-
const cTickIn = toXY(0, R - 3)
|
| 115 |
-
|
| 116 |
-
// End label positions — just outside the disc near min/max ticks
|
| 117 |
-
const minLabelPos = endLabels ? toXY(START, R + 8) : null
|
| 118 |
-
const maxLabelPos = endLabels ? toXY(135, R + 8) : null
|
| 119 |
|
| 120 |
-
// Returns angle in degrees, clockwise from 12 o'clock, for a pointer event
|
| 121 |
const getAngle = (e, rect) => {
|
| 122 |
const kx = rect.left + rect.width / 2
|
| 123 |
const ky = rect.top + rect.height / 2
|
|
@@ -128,34 +110,24 @@ function Knob({ value, min, max, onChange, label, color, showCenterTick = false,
|
|
| 128 |
if (disabled) return
|
| 129 |
const rect = e.currentTarget.getBoundingClientRect()
|
| 130 |
e.currentTarget.setPointerCapture(e.pointerId)
|
| 131 |
-
dragRef.current = {
|
| 132 |
-
rect,
|
| 133 |
-
lastAngle: getAngle(e, rect),
|
| 134 |
-
accumulated: 0,
|
| 135 |
-
startValue: value,
|
| 136 |
-
}
|
| 137 |
}
|
| 138 |
|
| 139 |
const handlePointerMove = (e) => {
|
| 140 |
if (!dragRef.current) return
|
| 141 |
const currentAngle = getAngle(e, dragRef.current.rect)
|
| 142 |
let delta = currentAngle - dragRef.current.lastAngle
|
| 143 |
-
// Normalize to [-180, 180] to handle the 360°→0° wraparound
|
| 144 |
if (delta > 180) delta -= 360
|
| 145 |
if (delta < -180) delta += 360
|
| 146 |
dragRef.current.accumulated += delta
|
| 147 |
dragRef.current.lastAngle = currentAngle
|
| 148 |
const range = max - min
|
| 149 |
-
|
| 150 |
-
const newVal = Math.max(min, Math.min(max,
|
| 151 |
-
dragRef.current.startValue + dragRef.current.accumulated * (range / 270)
|
| 152 |
-
))
|
| 153 |
onChange(newVal)
|
| 154 |
}
|
| 155 |
|
| 156 |
const handlePointerUp = () => { dragRef.current = null }
|
| 157 |
|
| 158 |
-
// Value display
|
| 159 |
let display
|
| 160 |
if (showCenterTick) {
|
| 161 |
if (Math.abs(value) < 0.01) display = 'C'
|
|
@@ -164,8 +136,6 @@ function Knob({ value, min, max, onChange, label, color, showCenterTick = false,
|
|
| 164 |
display = `${Math.round(value * 100)}%`
|
| 165 |
}
|
| 166 |
|
| 167 |
-
const cursor = disabled ? 'default' : 'grab'
|
| 168 |
-
|
| 169 |
return (
|
| 170 |
<div className="flex flex-col items-center select-none" style={{ gap: 1 }}>
|
| 171 |
<svg
|
|
@@ -173,60 +143,20 @@ function Knob({ value, min, max, onChange, label, color, showCenterTick = false,
|
|
| 173 |
onPointerDown={handlePointerDown}
|
| 174 |
onPointerMove={handlePointerMove}
|
| 175 |
onPointerUp={handlePointerUp}
|
| 176 |
-
style={{ cursor, touchAction: 'none', overflow: 'visible' }}
|
| 177 |
>
|
| 178 |
-
{
|
| 179 |
-
<
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
strokeWidth="1"
|
| 184 |
-
/>
|
| 185 |
-
{/* Range arc — thin, shows full travel */}
|
| 186 |
-
<path
|
| 187 |
-
d={arcPath(R, START, 135)}
|
| 188 |
-
fill="none" stroke="#374151" strokeWidth="2" strokeLinecap="butt"
|
| 189 |
-
/>
|
| 190 |
-
{/* Range limit ticks */}
|
| 191 |
-
<line x1={minTick.x.toFixed(2)} y1={minTick.y.toFixed(2)} x2={minTickIn.x.toFixed(2)} y2={minTickIn.y.toFixed(2)} stroke="#6b7280" strokeWidth="1.5" strokeLinecap="round" />
|
| 192 |
-
<line x1={maxTick.x.toFixed(2)} y1={maxTick.y.toFixed(2)} x2={maxTickIn.x.toFixed(2)} y2={maxTickIn.y.toFixed(2)} stroke="#6b7280" strokeWidth="1.5" strokeLinecap="round" />
|
| 193 |
-
{/* Center tick for pan */}
|
| 194 |
-
{showCenterTick && (
|
| 195 |
-
<line x1={cTick.x.toFixed(2)} y1={cTick.y.toFixed(2)} x2={cTickIn.x.toFixed(2)} y2={cTickIn.y.toFixed(2)} stroke="#6b7280" strokeWidth="1" strokeLinecap="round" />
|
| 196 |
-
)}
|
| 197 |
-
{/* End labels — L/R for pan, 0/50 for reverb */}
|
| 198 |
-
{endLabels && (
|
| 199 |
-
<>
|
| 200 |
-
<text
|
| 201 |
-
x={minLabelPos.x.toFixed(1)} y={minLabelPos.y.toFixed(1)}
|
| 202 |
-
textAnchor="middle" dominantBaseline="middle"
|
| 203 |
-
fill="#6b7280" fontSize="6.5" fontFamily="monospace"
|
| 204 |
-
>{endLabels[0]}</text>
|
| 205 |
-
<text
|
| 206 |
-
x={maxLabelPos.x.toFixed(1)} y={maxLabelPos.y.toFixed(1)}
|
| 207 |
-
textAnchor="middle" dominantBaseline="middle"
|
| 208 |
-
fill="#6b7280" fontSize="6.5" fontFamily="monospace"
|
| 209 |
-
>{endLabels[1]}</text>
|
| 210 |
-
</>
|
| 211 |
-
)}
|
| 212 |
-
{/* Pointer line from center to tip */}
|
| 213 |
-
<line
|
| 214 |
-
x1={cx} y1={cy}
|
| 215 |
-
x2={tip.x.toFixed(2)} y2={tip.y.toFixed(2)}
|
| 216 |
-
stroke={color} strokeWidth="2" strokeLinecap="round"
|
| 217 |
-
/>
|
| 218 |
-
{/* Center dot */}
|
| 219 |
-
<circle cx={cx} cy={cy} r="2" fill="rgba(255,255,255,0.3)" />
|
| 220 |
-
{/* Tip dot */}
|
| 221 |
-
<circle cx={tip.x.toFixed(2)} cy={tip.y.toFixed(2)} r="2" fill={color} />
|
| 222 |
</svg>
|
| 223 |
-
<span className="text-[
|
| 224 |
-
<span className="text-[
|
| 225 |
</div>
|
| 226 |
)
|
| 227 |
}
|
| 228 |
|
| 229 |
-
// ─── Main mixer ──────────────────────────────────────────────────────────────
|
| 230 |
function StemMixer({
|
| 231 |
stems,
|
| 232 |
volumes,
|
|
@@ -242,6 +172,8 @@ function StemMixer({
|
|
| 242 |
onPanChange,
|
| 243 |
onReset
|
| 244 |
}) {
|
|
|
|
|
|
|
| 245 |
const getIcon = (stemName) => {
|
| 246 |
const name = stemName.toLowerCase()
|
| 247 |
for (const [key, icon] of Object.entries(STEM_ICONS)) {
|
|
@@ -252,9 +184,9 @@ function StemMixer({
|
|
| 252 |
|
| 253 |
if (!stems || stems.length === 0) {
|
| 254 |
return (
|
| 255 |
-
<div className="
|
| 256 |
-
<h2 className="text-
|
| 257 |
-
<p className="text-gray-
|
| 258 |
</div>
|
| 259 |
)
|
| 260 |
}
|
|
@@ -262,94 +194,101 @@ function StemMixer({
|
|
| 262 |
const hasSolos = Object.values(solos).some(s => s)
|
| 263 |
|
| 264 |
return (
|
| 265 |
-
<div className="
|
| 266 |
-
<div className="flex items-center justify-between mb-
|
| 267 |
-
<h2 className="text-
|
| 268 |
<button
|
| 269 |
onClick={onReset}
|
| 270 |
-
className="text-
|
| 271 |
>
|
| 272 |
-
Reset
|
| 273 |
</button>
|
| 274 |
</div>
|
| 275 |
|
| 276 |
-
<div className="space-y-
|
| 277 |
-
{stems.map((stem) => {
|
| 278 |
const volume = volumes[stem] ?? 1
|
| 279 |
const isSolo = solos[stem] ?? false
|
| 280 |
const isMuted = mutes[stem] ?? false
|
| 281 |
-
const reverb = reverbs?.[stem] ?? 0.
|
| 282 |
const pan = pans?.[stem] ?? 0
|
| 283 |
const isAudible = !isMuted && (hasSolos ? isSolo : true) && volume > 0
|
|
|
|
| 284 |
|
| 285 |
return (
|
| 286 |
-
<div
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
>
|
| 296 |
-
{/* Row 1: icon + name | solo + mute */}
|
| 297 |
-
<div className="flex items-center justify-between mb-1">
|
| 298 |
-
<div className="flex items-center gap-1.5">
|
| 299 |
-
<span className="text-sm">{getIcon(stem)}</span>
|
| 300 |
-
<span className="text-xs text-white font-medium capitalize">
|
| 301 |
-
{stem.replace(/_/g, ' ')}
|
| 302 |
-
</span>
|
| 303 |
-
</div>
|
| 304 |
-
<div className="flex gap-1">
|
| 305 |
<button
|
| 306 |
-
onClick={() =>
|
| 307 |
-
className=
|
| 308 |
-
|
| 309 |
-
}
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
</div>
|
| 320 |
-
</div>
|
| 321 |
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
onChange={(v) => onReverbChange && onReverbChange(stem, v)}
|
| 347 |
-
label="Reverb"
|
| 348 |
-
color="#60a5fa"
|
| 349 |
-
disabled={isMuted}
|
| 350 |
-
endLabels={['Min', 'Max']}
|
| 351 |
-
/>
|
| 352 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
</div>
|
| 354 |
)
|
| 355 |
})}
|
|
|
|
| 1 |
+
import React, { useRef, useState } from 'react'
|
| 2 |
|
| 3 |
const STEM_ICONS = {
|
| 4 |
bass: '🎸',
|
|
|
|
| 13 |
other: '🎵'
|
| 14 |
}
|
| 15 |
|
| 16 |
+
const STEM_DISPLAY_NAMES = {
|
| 17 |
+
synth: 'Keys',
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
function VolumeSlider({ value, onChange, disabled }) {
|
| 21 |
const containerRef = useRef(null)
|
| 22 |
const dragging = useRef(false)
|
|
|
|
| 23 |
const pct = Math.max(0, Math.min(1, value)) * 100
|
| 24 |
|
| 25 |
const calcValue = (clientX) => {
|
|
|
|
| 44 |
return (
|
| 45 |
<div
|
| 46 |
ref={containerRef}
|
| 47 |
+
className="relative h-6 flex-1 select-none"
|
| 48 |
style={{ cursor: disabled ? 'default' : 'pointer', touchAction: 'none' }}
|
| 49 |
onPointerDown={handlePointerDown}
|
| 50 |
onPointerMove={handlePointerMove}
|
| 51 |
onPointerUp={handlePointerUp}
|
| 52 |
>
|
|
|
|
| 53 |
<div
|
| 54 |
className="absolute inset-x-0 rounded-full"
|
| 55 |
style={{ height: 4, top: '50%', transform: 'translateY(-50%)' }}
|
| 56 |
>
|
| 57 |
+
<div className="w-full h-full rounded-full bg-white/[0.08]" />
|
| 58 |
<div
|
| 59 |
className="absolute top-0 left-0 h-full rounded-full"
|
| 60 |
+
style={{ width: `${pct}%`, background: disabled ? '#374151' : '#14B8A6' }}
|
| 61 |
/>
|
| 62 |
</div>
|
|
|
|
|
|
|
| 63 |
<div
|
| 64 |
className="absolute rounded-full pointer-events-none"
|
| 65 |
style={{
|
| 66 |
+
width: 12,
|
| 67 |
+
height: 12,
|
| 68 |
top: '50%',
|
| 69 |
left: `${pct}%`,
|
| 70 |
transform: 'translate(-50%, -50%)',
|
| 71 |
background: disabled ? '#374151' : 'white',
|
| 72 |
+
border: `2px solid ${disabled ? '#4b5563' : '#14B8A6'}`,
|
|
|
|
| 73 |
}}
|
| 74 |
/>
|
| 75 |
</div>
|
| 76 |
)
|
| 77 |
}
|
| 78 |
|
|
|
|
|
|
|
|
|
|
| 79 |
function Knob({ value, min, max, onChange, label, color, showCenterTick = false, disabled, endLabels }) {
|
| 80 |
const dragRef = useRef(null)
|
|
|
|
| 81 |
const norm = Math.max(0, Math.min(1, (value - min) / (max - min)))
|
| 82 |
+
const SIZE = 36
|
| 83 |
const cx = SIZE / 2
|
| 84 |
const cy = SIZE / 2
|
| 85 |
+
const R = 11
|
|
|
|
| 86 |
const START = -135
|
| 87 |
const angleDeg = START + norm * 270
|
| 88 |
|
|
|
|
| 99 |
}
|
| 100 |
|
| 101 |
const tip = toXY(angleDeg, R - 3)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
|
|
|
| 103 |
const getAngle = (e, rect) => {
|
| 104 |
const kx = rect.left + rect.width / 2
|
| 105 |
const ky = rect.top + rect.height / 2
|
|
|
|
| 110 |
if (disabled) return
|
| 111 |
const rect = e.currentTarget.getBoundingClientRect()
|
| 112 |
e.currentTarget.setPointerCapture(e.pointerId)
|
| 113 |
+
dragRef.current = { rect, lastAngle: getAngle(e, rect), accumulated: 0, startValue: value }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
}
|
| 115 |
|
| 116 |
const handlePointerMove = (e) => {
|
| 117 |
if (!dragRef.current) return
|
| 118 |
const currentAngle = getAngle(e, dragRef.current.rect)
|
| 119 |
let delta = currentAngle - dragRef.current.lastAngle
|
|
|
|
| 120 |
if (delta > 180) delta -= 360
|
| 121 |
if (delta < -180) delta += 360
|
| 122 |
dragRef.current.accumulated += delta
|
| 123 |
dragRef.current.lastAngle = currentAngle
|
| 124 |
const range = max - min
|
| 125 |
+
const newVal = Math.max(min, Math.min(max, dragRef.current.startValue + dragRef.current.accumulated * (range / 270)))
|
|
|
|
|
|
|
|
|
|
| 126 |
onChange(newVal)
|
| 127 |
}
|
| 128 |
|
| 129 |
const handlePointerUp = () => { dragRef.current = null }
|
| 130 |
|
|
|
|
| 131 |
let display
|
| 132 |
if (showCenterTick) {
|
| 133 |
if (Math.abs(value) < 0.01) display = 'C'
|
|
|
|
| 136 |
display = `${Math.round(value * 100)}%`
|
| 137 |
}
|
| 138 |
|
|
|
|
|
|
|
| 139 |
return (
|
| 140 |
<div className="flex flex-col items-center select-none" style={{ gap: 1 }}>
|
| 141 |
<svg
|
|
|
|
| 143 |
onPointerDown={handlePointerDown}
|
| 144 |
onPointerMove={handlePointerMove}
|
| 145 |
onPointerUp={handlePointerUp}
|
| 146 |
+
style={{ cursor: disabled ? 'default' : 'grab', touchAction: 'none', overflow: 'visible' }}
|
| 147 |
>
|
| 148 |
+
<circle cx={cx} cy={cy} r={R + 3} fill="rgba(255,255,255,0.03)" stroke="rgba(255,255,255,0.06)" strokeWidth="1" />
|
| 149 |
+
<path d={arcPath(R, START, 135)} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="2" strokeLinecap="butt" />
|
| 150 |
+
<line x1={cx} y1={cy} x2={tip.x.toFixed(2)} y2={tip.y.toFixed(2)} stroke={color} strokeWidth="2" strokeLinecap="round" />
|
| 151 |
+
<circle cx={cx} cy={cy} r="1.5" fill="rgba(255,255,255,0.2)" />
|
| 152 |
+
<circle cx={tip.x.toFixed(2)} cy={tip.y.toFixed(2)} r="1.5" fill={color} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
</svg>
|
| 154 |
+
<span className="text-[9px] text-gray-500 leading-none">{label}</span>
|
| 155 |
+
<span className="text-[9px] font-mono text-gray-400 leading-none">{display}</span>
|
| 156 |
</div>
|
| 157 |
)
|
| 158 |
}
|
| 159 |
|
|
|
|
| 160 |
function StemMixer({
|
| 161 |
stems,
|
| 162 |
volumes,
|
|
|
|
| 172 |
onPanChange,
|
| 173 |
onReset
|
| 174 |
}) {
|
| 175 |
+
const [expandedStem, setExpandedStem] = useState(null)
|
| 176 |
+
|
| 177 |
const getIcon = (stemName) => {
|
| 178 |
const name = stemName.toLowerCase()
|
| 179 |
for (const [key, icon] of Object.entries(STEM_ICONS)) {
|
|
|
|
| 184 |
|
| 185 |
if (!stems || stems.length === 0) {
|
| 186 |
return (
|
| 187 |
+
<div className="p-5">
|
| 188 |
+
<h2 className="text-sm font-semibold text-white mb-3">Stem Mixer</h2>
|
| 189 |
+
<p className="text-gray-500 text-sm">No stems loaded</p>
|
| 190 |
</div>
|
| 191 |
)
|
| 192 |
}
|
|
|
|
| 194 |
const hasSolos = Object.values(solos).some(s => s)
|
| 195 |
|
| 196 |
return (
|
| 197 |
+
<div className="p-4">
|
| 198 |
+
<div className="flex items-center justify-between mb-4">
|
| 199 |
+
<h2 className="text-sm font-semibold text-white">Stems</h2>
|
| 200 |
<button
|
| 201 |
onClick={onReset}
|
| 202 |
+
className="text-[10px] px-2 py-1 bg-white/[0.06] hover:bg-white/[0.1] rounded text-gray-400 transition-colors"
|
| 203 |
>
|
| 204 |
+
Reset
|
| 205 |
</button>
|
| 206 |
</div>
|
| 207 |
|
| 208 |
+
<div className="space-y-0">
|
| 209 |
+
{stems.map((stem, idx) => {
|
| 210 |
const volume = volumes[stem] ?? 1
|
| 211 |
const isSolo = solos[stem] ?? false
|
| 212 |
const isMuted = mutes[stem] ?? false
|
| 213 |
+
const reverb = reverbs?.[stem] ?? 0.0
|
| 214 |
const pan = pans?.[stem] ?? 0
|
| 215 |
const isAudible = !isMuted && (hasSolos ? isSolo : true) && volume > 0
|
| 216 |
+
const isExpanded = expandedStem === stem
|
| 217 |
|
| 218 |
return (
|
| 219 |
+
<div key={stem}>
|
| 220 |
+
<div
|
| 221 |
+
className={`px-3 py-3 transition-all duration-200 ${
|
| 222 |
+
isMuted ? 'opacity-40' : ''
|
| 223 |
+
} ${isPlaying && isAudible ? 'bg-accent-500/[0.06]' : ''}`}
|
| 224 |
+
>
|
| 225 |
+
{/* Main row: icon + name | volume slider | S M */}
|
| 226 |
+
<div className="flex items-center gap-3">
|
| 227 |
+
{/* Icon + name */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
<button
|
| 229 |
+
onClick={() => setExpandedStem(isExpanded ? null : stem)}
|
| 230 |
+
className="flex items-center gap-2 min-w-[90px] text-left"
|
| 231 |
+
>
|
| 232 |
+
<span className="text-sm">{getIcon(stem)}</span>
|
| 233 |
+
<span className="text-xs text-white font-medium capitalize truncate">
|
| 234 |
+
{STEM_DISPLAY_NAMES[stem.toLowerCase()] || stem.replace(/_/g, ' ')}
|
| 235 |
+
</span>
|
| 236 |
+
</button>
|
| 237 |
+
|
| 238 |
+
{/* Volume slider */}
|
| 239 |
+
<VolumeSlider
|
| 240 |
+
value={volume}
|
| 241 |
+
onChange={(v) => onVolumeChange(stem, v)}
|
| 242 |
+
disabled={isMuted}
|
| 243 |
+
/>
|
| 244 |
+
|
| 245 |
+
{/* Solo / Mute pills */}
|
| 246 |
+
<div className="flex gap-1 flex-shrink-0">
|
| 247 |
+
<button
|
| 248 |
+
onClick={() => onSoloToggle(stem, !isSolo)}
|
| 249 |
+
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold transition-all ${
|
| 250 |
+
isSolo ? 'bg-amber-500 text-black' : 'bg-white/[0.06] text-gray-500 hover:bg-white/[0.1]'
|
| 251 |
+
}`}
|
| 252 |
+
>S</button>
|
| 253 |
+
<button
|
| 254 |
+
onClick={() => onMuteToggle(stem, !isMuted)}
|
| 255 |
+
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold transition-all ${
|
| 256 |
+
isMuted ? 'bg-red-500 text-white' : 'bg-white/[0.06] text-gray-500 hover:bg-white/[0.1]'
|
| 257 |
+
}`}
|
| 258 |
+
>M</button>
|
| 259 |
+
</div>
|
| 260 |
</div>
|
|
|
|
| 261 |
|
| 262 |
+
{/* Expanded: Pan + Reverb knobs */}
|
| 263 |
+
{isExpanded && (
|
| 264 |
+
<div className="flex justify-center gap-6 mt-2 pt-2 border-t border-white/[0.04]">
|
| 265 |
+
<Knob
|
| 266 |
+
value={pan}
|
| 267 |
+
min={-1}
|
| 268 |
+
max={1}
|
| 269 |
+
onChange={(v) => onPanChange && onPanChange(stem, v)}
|
| 270 |
+
label="Pan"
|
| 271 |
+
color="#14B8A6"
|
| 272 |
+
showCenterTick
|
| 273 |
+
disabled={isMuted}
|
| 274 |
+
/>
|
| 275 |
+
<Knob
|
| 276 |
+
value={reverb}
|
| 277 |
+
min={0}
|
| 278 |
+
max={0.5}
|
| 279 |
+
onChange={(v) => onReverbChange && onReverbChange(stem, v)}
|
| 280 |
+
label="Reverb"
|
| 281 |
+
color="#14B8A6"
|
| 282 |
+
disabled={isMuted}
|
| 283 |
+
/>
|
| 284 |
+
</div>
|
| 285 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
</div>
|
| 287 |
+
|
| 288 |
+
{/* Divider */}
|
| 289 |
+
{idx < stems.length - 1 && (
|
| 290 |
+
<div className="h-px bg-white/[0.04] mx-3"></div>
|
| 291 |
+
)}
|
| 292 |
</div>
|
| 293 |
)
|
| 294 |
})}
|
frontend/src/components/TransportBar.jsx
CHANGED
|
@@ -28,6 +28,35 @@ function parseTimeInput(str) {
|
|
| 28 |
|
| 29 |
const HANDLE_WIDTH = 8 // pixels for drag handle hit area
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
function TransportBar({
|
| 32 |
isPlaying,
|
| 33 |
currentTime,
|
|
@@ -44,8 +73,19 @@ function TransportBar({
|
|
| 44 |
onPlayFullSong,
|
| 45 |
onPlaySection,
|
| 46 |
isRawRegionActive,
|
| 47 |
-
fullSongDuration
|
|
|
|
| 48 |
}) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
// In region mode, map the playhead position into the region's span on the full bar
|
| 50 |
const progress = (() => {
|
| 51 |
if (duration <= 0) return 0
|
|
@@ -119,12 +159,17 @@ function TransportBar({
|
|
| 119 |
return Math.abs(clientX - handleX) <= HANDLE_WIDTH
|
| 120 |
}, [])
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
// Document-level drag listeners — attached when isDragging becomes true
|
| 123 |
useEffect(() => {
|
| 124 |
if (!isDragging) return
|
| 125 |
|
| 126 |
const onMouseMove = (e) => {
|
| 127 |
-
const
|
|
|
|
| 128 |
const MIN_REGION = 0.1
|
| 129 |
const dt = dragTypeRef.current
|
| 130 |
|
|
@@ -186,7 +231,8 @@ function TransportBar({
|
|
| 186 |
}
|
| 187 |
|
| 188 |
// Start creating a new region
|
| 189 |
-
const
|
|
|
|
| 190 |
dragOriginRef.current = time
|
| 191 |
dragTypeRef.current = 'create'
|
| 192 |
setDragType('create')
|
|
@@ -211,7 +257,8 @@ function TransportBar({
|
|
| 211 |
const handleStartInputBlur = () => {
|
| 212 |
const parsed = parseTimeInput(startInputValue)
|
| 213 |
if (parsed !== null && parsed >= 0 && parsed < barDuration) {
|
| 214 |
-
const
|
|
|
|
| 215 |
onRegionChange(Math.max(0, newStart), regionEnd || barDuration)
|
| 216 |
}
|
| 217 |
setEditingStart(false)
|
|
@@ -220,7 +267,8 @@ function TransportBar({
|
|
| 220 |
const handleEndInputBlur = () => {
|
| 221 |
const parsed = parseTimeInput(endInputValue)
|
| 222 |
if (parsed !== null && parsed > 0 && parsed <= barDuration) {
|
| 223 |
-
const
|
|
|
|
| 224 |
onRegionChange(regionStart || 0, Math.min(barDuration, newEnd))
|
| 225 |
}
|
| 226 |
setEditingEnd(false)
|
|
@@ -236,21 +284,21 @@ function TransportBar({
|
|
| 236 |
}
|
| 237 |
|
| 238 |
return (
|
| 239 |
-
<div className="
|
| 240 |
{/* Main transport controls */}
|
| 241 |
-
<div className="flex items-center gap-
|
| 242 |
{/* Play/Pause button */}
|
| 243 |
<button
|
| 244 |
onClick={isPlaying ? onPause : onPlay}
|
| 245 |
-
className="w-
|
| 246 |
>
|
| 247 |
{isPlaying ? (
|
| 248 |
-
<svg className="w-
|
| 249 |
<rect x="6" y="4" width="4" height="16" />
|
| 250 |
<rect x="14" y="4" width="4" height="16" />
|
| 251 |
</svg>
|
| 252 |
) : (
|
| 253 |
-
<svg className="w-
|
| 254 |
<polygon points="5,3 19,12 5,21" />
|
| 255 |
</svg>
|
| 256 |
)}
|
|
@@ -259,9 +307,9 @@ function TransportBar({
|
|
| 259 |
{/* Stop button */}
|
| 260 |
<button
|
| 261 |
onClick={onStop}
|
| 262 |
-
className="w-
|
| 263 |
>
|
| 264 |
-
<svg className="w-
|
| 265 |
<rect x="4" y="4" width="16" height="16" />
|
| 266 |
</svg>
|
| 267 |
</button>
|
|
@@ -269,7 +317,7 @@ function TransportBar({
|
|
| 269 |
|
| 270 |
{/* Progress bar with region selection */}
|
| 271 |
<div className="flex-1 flex items-center gap-3">
|
| 272 |
-
<span className="text-
|
| 273 |
{formatTime(currentTime)}
|
| 274 |
</span>
|
| 275 |
|
|
@@ -281,10 +329,32 @@ function TransportBar({
|
|
| 281 |
className="flex-1 cursor-crosshair overflow-visible relative select-none py-3"
|
| 282 |
>
|
| 283 |
{/* Visual progress bar */}
|
| 284 |
-
<div className="h-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
{/* Playback progress fill */}
|
| 286 |
<div
|
| 287 |
-
className="absolute top-0 left-0 h-full bg-
|
| 288 |
style={{
|
| 289 |
width: `${progress}%`,
|
| 290 |
transition: isDragging ? 'none' : 'width 0.1s linear'
|
|
@@ -295,7 +365,7 @@ function TransportBar({
|
|
| 295 |
{hasRegion && (
|
| 296 |
<>
|
| 297 |
<div
|
| 298 |
-
className="absolute top-0 h-full bg-
|
| 299 |
style={{
|
| 300 |
left: `${regionStartPct}%`,
|
| 301 |
width: `${regionEndPct - regionStartPct}%`
|
|
@@ -303,12 +373,12 @@ function TransportBar({
|
|
| 303 |
/>
|
| 304 |
{/* Start handle */}
|
| 305 |
<div
|
| 306 |
-
className="absolute top-1/2 -translate-y-1/2 w-1.5 h-5 bg-
|
| 307 |
style={{ left: `${regionStartPct}%`, transform: `translateX(-50%) translateY(-50%)` }}
|
| 308 |
/>
|
| 309 |
{/* End handle */}
|
| 310 |
<div
|
| 311 |
-
className="absolute top-1/2 -translate-y-1/2 w-1.5 h-5 bg-
|
| 312 |
style={{ left: `${regionEndPct}%`, transform: `translateX(-50%) translateY(-50%)` }}
|
| 313 |
/>
|
| 314 |
</>
|
|
@@ -316,7 +386,7 @@ function TransportBar({
|
|
| 316 |
</div>
|
| 317 |
</div>
|
| 318 |
|
| 319 |
-
<span className="text-
|
| 320 |
{formatTime(barDuration)}
|
| 321 |
</span>
|
| 322 |
</div>
|
|
@@ -325,11 +395,11 @@ function TransportBar({
|
|
| 325 |
{/* Region controls row — shown when a region is selected */}
|
| 326 |
{hasRegion && (
|
| 327 |
<div className="mt-3 flex items-center gap-4 flex-wrap">
|
| 328 |
-
{/* Region
|
| 329 |
<div className="flex items-center gap-2 text-sm">
|
| 330 |
<span className="text-gray-400">Region:</span>
|
| 331 |
|
| 332 |
-
{/* Start
|
| 333 |
{editingStart ? (
|
| 334 |
<input
|
| 335 |
type="text"
|
|
@@ -338,7 +408,7 @@ function TransportBar({
|
|
| 338 |
onChange={(e) => setStartInputValue(e.target.value)}
|
| 339 |
onBlur={handleStartInputBlur}
|
| 340 |
onKeyDown={(e) => handleInputKeyDown(e, handleStartInputBlur)}
|
| 341 |
-
className="w-16 bg-
|
| 342 |
/>
|
| 343 |
) : (
|
| 344 |
<button
|
|
@@ -346,15 +416,17 @@ function TransportBar({
|
|
| 346 |
setStartInputValue(formatTimeDetailed(regionStart))
|
| 347 |
setEditingStart(true)
|
| 348 |
}}
|
| 349 |
-
className="px-1.5 py-0.5 bg-
|
| 350 |
>
|
| 351 |
-
{
|
|
|
|
|
|
|
| 352 |
</button>
|
| 353 |
)}
|
| 354 |
|
| 355 |
<span className="text-gray-500">-</span>
|
| 356 |
|
| 357 |
-
{/* End
|
| 358 |
{editingEnd ? (
|
| 359 |
<input
|
| 360 |
type="text"
|
|
@@ -363,7 +435,7 @@ function TransportBar({
|
|
| 363 |
onChange={(e) => setEndInputValue(e.target.value)}
|
| 364 |
onBlur={handleEndInputBlur}
|
| 365 |
onKeyDown={(e) => handleInputKeyDown(e, handleEndInputBlur)}
|
| 366 |
-
className="w-16 bg-
|
| 367 |
/>
|
| 368 |
) : (
|
| 369 |
<button
|
|
@@ -371,14 +443,19 @@ function TransportBar({
|
|
| 371 |
setEndInputValue(formatTimeDetailed(regionEnd))
|
| 372 |
setEditingEnd(true)
|
| 373 |
}}
|
| 374 |
-
className="px-1.5 py-0.5 bg-
|
| 375 |
>
|
| 376 |
-
{
|
|
|
|
|
|
|
| 377 |
</button>
|
| 378 |
)}
|
| 379 |
|
|
|
|
| 380 |
<span className="text-gray-500 text-xs">
|
| 381 |
-
({
|
|
|
|
|
|
|
| 382 |
</span>
|
| 383 |
</div>
|
| 384 |
|
|
@@ -386,7 +463,7 @@ function TransportBar({
|
|
| 386 |
{playbackMode !== 'region' && (
|
| 387 |
<button
|
| 388 |
onClick={onPlaySection}
|
| 389 |
-
className="flex items-center gap-1.5 px-3 py-1 rounded-lg bg-
|
| 390 |
>
|
| 391 |
<svg className="w-3 h-3 fill-current" viewBox="0 0 24 24">
|
| 392 |
<polygon points="5,3 19,12 5,21" />
|
|
@@ -395,10 +472,30 @@ function TransportBar({
|
|
| 395 |
</button>
|
| 396 |
)}
|
| 397 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
{/* Clear region button */}
|
| 399 |
<button
|
| 400 |
onClick={onClearRegion}
|
| 401 |
-
className="text-xs text-gray-400 hover:text-red-400 transition-colors px-2 py-1 rounded hover:bg-
|
| 402 |
>
|
| 403 |
Clear Selection
|
| 404 |
</button>
|
|
@@ -406,7 +503,7 @@ function TransportBar({
|
|
| 406 |
{/* Looping indicator + Play Full Song button — shown when section is looping */}
|
| 407 |
{(playbackMode === 'region' || isRawRegionActive) && (
|
| 408 |
<>
|
| 409 |
-
<span className="text-xs text-
|
| 410 |
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 411 |
<path d="M17 2l4 4-4 4" />
|
| 412 |
<path d="M3 11V9a4 4 0 014-4h14" />
|
|
@@ -418,7 +515,7 @@ function TransportBar({
|
|
| 418 |
|
| 419 |
<button
|
| 420 |
onClick={onPlayFullSong}
|
| 421 |
-
className="text-xs bg-
|
| 422 |
>
|
| 423 |
Play Full Song
|
| 424 |
</button>
|
|
|
|
| 28 |
|
| 29 |
const HANDLE_WIDTH = 8 // pixels for drag handle hit area
|
| 30 |
|
| 31 |
+
function timeToBarBeat(seconds, bpm) {
|
| 32 |
+
if (!bpm || !seconds || !isFinite(seconds)) return null
|
| 33 |
+
const beatDuration = 60.0 / bpm
|
| 34 |
+
const totalBeats = seconds / beatDuration
|
| 35 |
+
const bar = Math.floor(totalBeats / 4) + 1
|
| 36 |
+
const beat = Math.floor(totalBeats % 4) + 1
|
| 37 |
+
return { bar, beat }
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function formatBarBeat(seconds, bpm) {
|
| 41 |
+
const bb = timeToBarBeat(seconds, bpm)
|
| 42 |
+
if (!bb) return null
|
| 43 |
+
return `Bar ${bb.bar}`
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function formatBarBeatDetailed(seconds, bpm) {
|
| 47 |
+
const bb = timeToBarBeat(seconds, bpm)
|
| 48 |
+
if (!bb) return null
|
| 49 |
+
return `${bb.bar}.${bb.beat}`
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function formatDurationBars(seconds, bpm) {
|
| 53 |
+
if (!bpm || !seconds) return null
|
| 54 |
+
const beatDuration = 60.0 / bpm
|
| 55 |
+
const bars = seconds / (beatDuration * 4)
|
| 56 |
+
if (bars === Math.floor(bars)) return `${Math.floor(bars)} bars`
|
| 57 |
+
return `${bars.toFixed(1)} bars`
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
function TransportBar({
|
| 61 |
isPlaying,
|
| 62 |
currentTime,
|
|
|
|
| 73 |
onPlayFullSong,
|
| 74 |
onPlaySection,
|
| 75 |
isRawRegionActive,
|
| 76 |
+
fullSongDuration,
|
| 77 |
+
bpm = null
|
| 78 |
}) {
|
| 79 |
+
// Snap modes: 'off', 'beat', 'bar'
|
| 80 |
+
const [snapMode, setSnapMode] = useState('bar')
|
| 81 |
+
|
| 82 |
+
// Snap a time value to the nearest beat or bar boundary
|
| 83 |
+
const snapTime = useCallback((time) => {
|
| 84 |
+
if (!bpm || snapMode === 'off') return time
|
| 85 |
+
const beatDuration = 60.0 / bpm
|
| 86 |
+
const snapUnit = snapMode === 'bar' ? beatDuration * 4 : beatDuration
|
| 87 |
+
return Math.round(time / snapUnit) * snapUnit
|
| 88 |
+
}, [bpm, snapMode])
|
| 89 |
// In region mode, map the playhead position into the region's span on the full bar
|
| 90 |
const progress = (() => {
|
| 91 |
if (duration <= 0) return 0
|
|
|
|
| 159 |
return Math.abs(clientX - handleX) <= HANDLE_WIDTH
|
| 160 |
}, [])
|
| 161 |
|
| 162 |
+
// Keep snapTime ref current for use in drag listeners
|
| 163 |
+
const snapTimeRef = useRef(snapTime)
|
| 164 |
+
snapTimeRef.current = snapTime
|
| 165 |
+
|
| 166 |
// Document-level drag listeners — attached when isDragging becomes true
|
| 167 |
useEffect(() => {
|
| 168 |
if (!isDragging) return
|
| 169 |
|
| 170 |
const onMouseMove = (e) => {
|
| 171 |
+
const rawTime = pixelToTimeRef.current(e.clientX)
|
| 172 |
+
const time = snapTimeRef.current(rawTime)
|
| 173 |
const MIN_REGION = 0.1
|
| 174 |
const dt = dragTypeRef.current
|
| 175 |
|
|
|
|
| 231 |
}
|
| 232 |
|
| 233 |
// Start creating a new region
|
| 234 |
+
const rawTime = pixelToTime(e.clientX)
|
| 235 |
+
const time = snapTime(rawTime)
|
| 236 |
dragOriginRef.current = time
|
| 237 |
dragTypeRef.current = 'create'
|
| 238 |
setDragType('create')
|
|
|
|
| 257 |
const handleStartInputBlur = () => {
|
| 258 |
const parsed = parseTimeInput(startInputValue)
|
| 259 |
if (parsed !== null && parsed >= 0 && parsed < barDuration) {
|
| 260 |
+
const snapped = snapTime(parsed)
|
| 261 |
+
const newStart = Math.min(snapped, (regionEnd || barDuration) - 0.1)
|
| 262 |
onRegionChange(Math.max(0, newStart), regionEnd || barDuration)
|
| 263 |
}
|
| 264 |
setEditingStart(false)
|
|
|
|
| 267 |
const handleEndInputBlur = () => {
|
| 268 |
const parsed = parseTimeInput(endInputValue)
|
| 269 |
if (parsed !== null && parsed > 0 && parsed <= barDuration) {
|
| 270 |
+
const snapped = snapTime(parsed)
|
| 271 |
+
const newEnd = Math.max(snapped, (regionStart || 0) + 0.1)
|
| 272 |
onRegionChange(regionStart || 0, Math.min(barDuration, newEnd))
|
| 273 |
}
|
| 274 |
setEditingEnd(false)
|
|
|
|
| 284 |
}
|
| 285 |
|
| 286 |
return (
|
| 287 |
+
<div className="bg-surface border-t border-white/[0.08] px-5 py-3">
|
| 288 |
{/* Main transport controls */}
|
| 289 |
+
<div className="flex items-center gap-3">
|
| 290 |
{/* Play/Pause button */}
|
| 291 |
<button
|
| 292 |
onClick={isPlaying ? onPause : onPlay}
|
| 293 |
+
className="w-10 h-10 rounded-full bg-accent-500 hover:bg-accent-400 flex items-center justify-center transition-all"
|
| 294 |
>
|
| 295 |
{isPlaying ? (
|
| 296 |
+
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
|
| 297 |
<rect x="6" y="4" width="4" height="16" />
|
| 298 |
<rect x="14" y="4" width="4" height="16" />
|
| 299 |
</svg>
|
| 300 |
) : (
|
| 301 |
+
<svg className="w-4 h-4 fill-white ml-0.5" viewBox="0 0 24 24">
|
| 302 |
<polygon points="5,3 19,12 5,21" />
|
| 303 |
</svg>
|
| 304 |
)}
|
|
|
|
| 307 |
{/* Stop button */}
|
| 308 |
<button
|
| 309 |
onClick={onStop}
|
| 310 |
+
className="w-8 h-8 rounded-full bg-white/[0.06] hover:bg-white/[0.1] flex items-center justify-center transition-colors"
|
| 311 |
>
|
| 312 |
+
<svg className="w-3 h-3 fill-white" viewBox="0 0 24 24">
|
| 313 |
<rect x="4" y="4" width="16" height="16" />
|
| 314 |
</svg>
|
| 315 |
</button>
|
|
|
|
| 317 |
|
| 318 |
{/* Progress bar with region selection */}
|
| 319 |
<div className="flex-1 flex items-center gap-3">
|
| 320 |
+
<span className="text-xs text-gray-400 w-10 text-right font-mono">
|
| 321 |
{formatTime(currentTime)}
|
| 322 |
</span>
|
| 323 |
|
|
|
|
| 329 |
className="flex-1 cursor-crosshair overflow-visible relative select-none py-3"
|
| 330 |
>
|
| 331 |
{/* Visual progress bar */}
|
| 332 |
+
<div className="h-1.5 bg-white/[0.08] rounded-full relative overflow-hidden">
|
| 333 |
+
{/* Bar grid lines */}
|
| 334 |
+
{bpm && snapMode !== 'off' && barDuration > 0 && (() => {
|
| 335 |
+
const beatDuration = 60.0 / bpm
|
| 336 |
+
const barDurationSecs = beatDuration * 4
|
| 337 |
+
const numBars = Math.floor(barDuration / barDurationSecs)
|
| 338 |
+
// Only show if reasonable number of bars (avoid thousands of lines)
|
| 339 |
+
if (numBars > 200) return null
|
| 340 |
+
const lines = []
|
| 341 |
+
for (let i = 1; i <= numBars; i++) {
|
| 342 |
+
const pct = (i * barDurationSecs / barDuration) * 100
|
| 343 |
+
if (pct >= 100) break
|
| 344 |
+
lines.push(
|
| 345 |
+
<div
|
| 346 |
+
key={i}
|
| 347 |
+
className="absolute top-0 h-full w-px bg-white/[0.1] pointer-events-none"
|
| 348 |
+
style={{ left: `${pct}%` }}
|
| 349 |
+
/>
|
| 350 |
+
)
|
| 351 |
+
}
|
| 352 |
+
return lines
|
| 353 |
+
})()}
|
| 354 |
+
|
| 355 |
{/* Playback progress fill */}
|
| 356 |
<div
|
| 357 |
+
className="absolute top-0 left-0 h-full bg-accent-500 rounded-full pointer-events-none"
|
| 358 |
style={{
|
| 359 |
width: `${progress}%`,
|
| 360 |
transition: isDragging ? 'none' : 'width 0.1s linear'
|
|
|
|
| 365 |
{hasRegion && (
|
| 366 |
<>
|
| 367 |
<div
|
| 368 |
+
className="absolute top-0 h-full bg-accent-500/20 border-y border-accent-500/40 pointer-events-none"
|
| 369 |
style={{
|
| 370 |
left: `${regionStartPct}%`,
|
| 371 |
width: `${regionEndPct - regionStartPct}%`
|
|
|
|
| 373 |
/>
|
| 374 |
{/* Start handle */}
|
| 375 |
<div
|
| 376 |
+
className="absolute top-1/2 -translate-y-1/2 w-1.5 h-5 bg-accent-400 rounded-sm cursor-ew-resize hover:bg-accent-300 transition-colors"
|
| 377 |
style={{ left: `${regionStartPct}%`, transform: `translateX(-50%) translateY(-50%)` }}
|
| 378 |
/>
|
| 379 |
{/* End handle */}
|
| 380 |
<div
|
| 381 |
+
className="absolute top-1/2 -translate-y-1/2 w-1.5 h-5 bg-accent-400 rounded-sm cursor-ew-resize hover:bg-accent-300 transition-colors"
|
| 382 |
style={{ left: `${regionEndPct}%`, transform: `translateX(-50%) translateY(-50%)` }}
|
| 383 |
/>
|
| 384 |
</>
|
|
|
|
| 386 |
</div>
|
| 387 |
</div>
|
| 388 |
|
| 389 |
+
<span className="text-xs text-gray-400 w-10 font-mono">
|
| 390 |
{formatTime(barDuration)}
|
| 391 |
</span>
|
| 392 |
</div>
|
|
|
|
| 395 |
{/* Region controls row — shown when a region is selected */}
|
| 396 |
{hasRegion && (
|
| 397 |
<div className="mt-3 flex items-center gap-4 flex-wrap">
|
| 398 |
+
{/* Region display */}
|
| 399 |
<div className="flex items-center gap-2 text-sm">
|
| 400 |
<span className="text-gray-400">Region:</span>
|
| 401 |
|
| 402 |
+
{/* Start */}
|
| 403 |
{editingStart ? (
|
| 404 |
<input
|
| 405 |
type="text"
|
|
|
|
| 408 |
onChange={(e) => setStartInputValue(e.target.value)}
|
| 409 |
onBlur={handleStartInputBlur}
|
| 410 |
onKeyDown={(e) => handleInputKeyDown(e, handleStartInputBlur)}
|
| 411 |
+
className="w-16 bg-surface-elevated border border-accent-500/40 rounded px-1.5 py-0.5 text-accent-400 text-sm font-mono text-center focus:outline-none"
|
| 412 |
/>
|
| 413 |
) : (
|
| 414 |
<button
|
|
|
|
| 416 |
setStartInputValue(formatTimeDetailed(regionStart))
|
| 417 |
setEditingStart(true)
|
| 418 |
}}
|
| 419 |
+
className="px-1.5 py-0.5 bg-surface-elevated rounded text-accent-400 font-mono hover:bg-white/[0.08] transition-colors"
|
| 420 |
>
|
| 421 |
+
{snapMode !== 'off' && bpm
|
| 422 |
+
? formatBarBeatDetailed(regionStart, bpm)
|
| 423 |
+
: formatTimeDetailed(regionStart)}
|
| 424 |
</button>
|
| 425 |
)}
|
| 426 |
|
| 427 |
<span className="text-gray-500">-</span>
|
| 428 |
|
| 429 |
+
{/* End */}
|
| 430 |
{editingEnd ? (
|
| 431 |
<input
|
| 432 |
type="text"
|
|
|
|
| 435 |
onChange={(e) => setEndInputValue(e.target.value)}
|
| 436 |
onBlur={handleEndInputBlur}
|
| 437 |
onKeyDown={(e) => handleInputKeyDown(e, handleEndInputBlur)}
|
| 438 |
+
className="w-16 bg-surface-elevated border border-accent-500/40 rounded px-1.5 py-0.5 text-accent-400 text-sm font-mono text-center focus:outline-none"
|
| 439 |
/>
|
| 440 |
) : (
|
| 441 |
<button
|
|
|
|
| 443 |
setEndInputValue(formatTimeDetailed(regionEnd))
|
| 444 |
setEditingEnd(true)
|
| 445 |
}}
|
| 446 |
+
className="px-1.5 py-0.5 bg-surface-elevated rounded text-accent-400 font-mono hover:bg-white/[0.08] transition-colors"
|
| 447 |
>
|
| 448 |
+
{snapMode !== 'off' && bpm
|
| 449 |
+
? formatBarBeatDetailed(regionEnd, bpm)
|
| 450 |
+
: formatTimeDetailed(regionEnd)}
|
| 451 |
</button>
|
| 452 |
)}
|
| 453 |
|
| 454 |
+
{/* Duration in bars or time */}
|
| 455 |
<span className="text-gray-500 text-xs">
|
| 456 |
+
({snapMode !== 'off' && bpm
|
| 457 |
+
? formatDurationBars(regionEnd - regionStart, bpm)
|
| 458 |
+
: formatTimeDetailed(regionEnd - regionStart)})
|
| 459 |
</span>
|
| 460 |
</div>
|
| 461 |
|
|
|
|
| 463 |
{playbackMode !== 'region' && (
|
| 464 |
<button
|
| 465 |
onClick={onPlaySection}
|
| 466 |
+
className="flex items-center gap-1.5 px-3 py-1 rounded-lg bg-accent-500/20 hover:bg-accent-500/30 border border-accent-500/40 text-accent-400 text-xs font-medium transition-colors"
|
| 467 |
>
|
| 468 |
<svg className="w-3 h-3 fill-current" viewBox="0 0 24 24">
|
| 469 |
<polygon points="5,3 19,12 5,21" />
|
|
|
|
| 472 |
</button>
|
| 473 |
)}
|
| 474 |
|
| 475 |
+
{/* Snap mode toggle */}
|
| 476 |
+
{bpm && (
|
| 477 |
+
<div className="flex items-center gap-1 text-xs">
|
| 478 |
+
<span className="text-gray-500">Snap:</span>
|
| 479 |
+
{['off', 'beat', 'bar'].map((mode) => (
|
| 480 |
+
<button
|
| 481 |
+
key={mode}
|
| 482 |
+
onClick={() => setSnapMode(mode)}
|
| 483 |
+
className={`px-2 py-0.5 rounded transition-colors capitalize ${
|
| 484 |
+
snapMode === mode
|
| 485 |
+
? 'bg-cyan-500/30 text-cyan-300 border border-cyan-500/40'
|
| 486 |
+
: 'text-gray-400 hover:text-gray-200 hover:bg-white/[0.06]'
|
| 487 |
+
}`}
|
| 488 |
+
>
|
| 489 |
+
{mode}
|
| 490 |
+
</button>
|
| 491 |
+
))}
|
| 492 |
+
</div>
|
| 493 |
+
)}
|
| 494 |
+
|
| 495 |
{/* Clear region button */}
|
| 496 |
<button
|
| 497 |
onClick={onClearRegion}
|
| 498 |
+
className="text-xs text-gray-400 hover:text-red-400 transition-colors px-2 py-1 rounded hover:bg-white/[0.06]"
|
| 499 |
>
|
| 500 |
Clear Selection
|
| 501 |
</button>
|
|
|
|
| 503 |
{/* Looping indicator + Play Full Song button — shown when section is looping */}
|
| 504 |
{(playbackMode === 'region' || isRawRegionActive) && (
|
| 505 |
<>
|
| 506 |
+
<span className="text-xs text-accent-400 flex items-center gap-1">
|
| 507 |
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 508 |
<path d="M17 2l4 4-4 4" />
|
| 509 |
<path d="M3 11V9a4 4 0 014-4h14" />
|
|
|
|
| 515 |
|
| 516 |
<button
|
| 517 |
onClick={onPlayFullSong}
|
| 518 |
+
className="text-xs bg-white/[0.06] hover:bg-white/[0.1] text-gray-400 hover:text-white px-3 py-1 rounded transition-colors border border-white/[0.08]"
|
| 519 |
>
|
| 520 |
Play Full Song
|
| 521 |
</button>
|
frontend/src/hooks/useAudioEngine.js
CHANGED
|
@@ -245,7 +245,7 @@ export function useAudioEngine() {
|
|
| 245 |
const newReverbs = {}
|
| 246 |
const newPans = {}
|
| 247 |
Object.keys(buffersRef.current).forEach(stem => {
|
| 248 |
-
newReverbs[stem] = 0.
|
| 249 |
newPans[stem] = pannersRef.current[stem]?.pan.value || 0
|
| 250 |
})
|
| 251 |
|
|
@@ -337,7 +337,7 @@ export function useAudioEngine() {
|
|
| 337 |
const newReverbs = {}
|
| 338 |
const newPans = {}
|
| 339 |
Object.keys(buffersRef.current).forEach(stem => {
|
| 340 |
-
newReverbs[stem] = 0.
|
| 341 |
newPans[stem] = pannersRef.current[stem]?.pan.value || 0
|
| 342 |
})
|
| 343 |
|
|
@@ -483,7 +483,7 @@ export function useAudioEngine() {
|
|
| 483 |
useEffect(() => {
|
| 484 |
Object.entries(reverbSendsRef.current).forEach(([stem, reverbSend]) => {
|
| 485 |
if (!reverbSend) return
|
| 486 |
-
const reverbAmount = reverbs[stem] ?? 0.
|
| 487 |
reverbSend.gain.setValueAtTime(reverbAmount, audioContextRef.current?.currentTime || 0)
|
| 488 |
})
|
| 489 |
|
|
|
|
| 245 |
const newReverbs = {}
|
| 246 |
const newPans = {}
|
| 247 |
Object.keys(buffersRef.current).forEach(stem => {
|
| 248 |
+
newReverbs[stem] = 0.0 // 0% reverb by default — stems typically have reverb baked in
|
| 249 |
newPans[stem] = pannersRef.current[stem]?.pan.value || 0
|
| 250 |
})
|
| 251 |
|
|
|
|
| 337 |
const newReverbs = {}
|
| 338 |
const newPans = {}
|
| 339 |
Object.keys(buffersRef.current).forEach(stem => {
|
| 340 |
+
newReverbs[stem] = 0.0
|
| 341 |
newPans[stem] = pannersRef.current[stem]?.pan.value || 0
|
| 342 |
})
|
| 343 |
|
|
|
|
| 483 |
useEffect(() => {
|
| 484 |
Object.entries(reverbSendsRef.current).forEach(([stem, reverbSend]) => {
|
| 485 |
if (!reverbSend) return
|
| 486 |
+
const reverbAmount = reverbs[stem] ?? 0.0
|
| 487 |
reverbSend.gain.setValueAtTime(reverbAmount, audioContextRef.current?.currentTime || 0)
|
| 488 |
})
|
| 489 |
|
frontend/src/hooks/useSession.js
CHANGED
|
@@ -4,6 +4,7 @@ export function useSession() {
|
|
| 4 |
const [sessionId, setSessionId] = useState(null)
|
| 5 |
const [stems, setStems] = useState([])
|
| 6 |
const [detection, setDetection] = useState(null)
|
|
|
|
| 7 |
const [loading, setLoading] = useState(false)
|
| 8 |
const [error, setError] = useState(null)
|
| 9 |
|
|
@@ -48,6 +49,12 @@ export function useSession() {
|
|
| 48 |
console.log('Detection response:', detectData)
|
| 49 |
setDetection(detectData)
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
return { upload: uploadData, detection: detectData }
|
| 52 |
} catch (err) {
|
| 53 |
console.error('Error:', err)
|
|
@@ -169,6 +176,12 @@ export function useSession() {
|
|
| 169 |
const detectData = await detectResponse.json()
|
| 170 |
setDetection(detectData)
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
return { upload: uploadData, detection: detectData }
|
| 173 |
} catch (err) {
|
| 174 |
setError(err.message)
|
|
@@ -178,6 +191,22 @@ export function useSession() {
|
|
| 178 |
}
|
| 179 |
}, [])
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
const generate = useCallback(async (regionStart, regionEnd, duration = 15.0, prompt = null) => {
|
| 182 |
if (!sessionId) {
|
| 183 |
setError('No session to generate')
|
|
@@ -220,12 +249,14 @@ export function useSession() {
|
|
| 220 |
sessionId,
|
| 221 |
stems,
|
| 222 |
detection,
|
|
|
|
| 223 |
loading,
|
| 224 |
error,
|
| 225 |
upload,
|
| 226 |
detect,
|
| 227 |
process,
|
| 228 |
generate,
|
|
|
|
| 229 |
getStems,
|
| 230 |
loadPreset,
|
| 231 |
clearError
|
|
|
|
| 4 |
const [sessionId, setSessionId] = useState(null)
|
| 5 |
const [stems, setStems] = useState([])
|
| 6 |
const [detection, setDetection] = useState(null)
|
| 7 |
+
const [chordsData, setChordsData] = useState(null)
|
| 8 |
const [loading, setLoading] = useState(false)
|
| 9 |
const [error, setError] = useState(null)
|
| 10 |
|
|
|
|
| 49 |
console.log('Detection response:', detectData)
|
| 50 |
setDetection(detectData)
|
| 51 |
|
| 52 |
+
// Auto-fetch chord progression and scale suggestions
|
| 53 |
+
fetch(`/api/chords/${uploadData.session_id}`)
|
| 54 |
+
.then(r => r.ok ? r.json() : null)
|
| 55 |
+
.then(data => { if (data) setChordsData(data) })
|
| 56 |
+
.catch(() => {})
|
| 57 |
+
|
| 58 |
return { upload: uploadData, detection: detectData }
|
| 59 |
} catch (err) {
|
| 60 |
console.error('Error:', err)
|
|
|
|
| 176 |
const detectData = await detectResponse.json()
|
| 177 |
setDetection(detectData)
|
| 178 |
|
| 179 |
+
// Auto-fetch chord progression and scale suggestions
|
| 180 |
+
fetch(`/api/chords/${uploadData.session_id}`)
|
| 181 |
+
.then(r => r.ok ? r.json() : null)
|
| 182 |
+
.then(data => { if (data) setChordsData(data) })
|
| 183 |
+
.catch(() => {})
|
| 184 |
+
|
| 185 |
return { upload: uploadData, detection: detectData }
|
| 186 |
} catch (err) {
|
| 187 |
setError(err.message)
|
|
|
|
| 191 |
}
|
| 192 |
}, [])
|
| 193 |
|
| 194 |
+
const fetchChords = useCallback(async (sid = null) => {
|
| 195 |
+
const id = sid || sessionId
|
| 196 |
+
if (!id) return null
|
| 197 |
+
try {
|
| 198 |
+
const response = await fetch(`/api/chords/${id}`)
|
| 199 |
+
if (response.ok) {
|
| 200 |
+
const data = await response.json()
|
| 201 |
+
setChordsData(data)
|
| 202 |
+
return data
|
| 203 |
+
}
|
| 204 |
+
} catch (err) {
|
| 205 |
+
console.warn('Chord detection failed:', err.message)
|
| 206 |
+
}
|
| 207 |
+
return null
|
| 208 |
+
}, [sessionId])
|
| 209 |
+
|
| 210 |
const generate = useCallback(async (regionStart, regionEnd, duration = 15.0, prompt = null) => {
|
| 211 |
if (!sessionId) {
|
| 212 |
setError('No session to generate')
|
|
|
|
| 249 |
sessionId,
|
| 250 |
stems,
|
| 251 |
detection,
|
| 252 |
+
chordsData,
|
| 253 |
loading,
|
| 254 |
error,
|
| 255 |
upload,
|
| 256 |
detect,
|
| 257 |
process,
|
| 258 |
generate,
|
| 259 |
+
fetchChords,
|
| 260 |
getStems,
|
| 261 |
loadPreset,
|
| 262 |
clearError
|
frontend/src/index.css
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
|
|
|
|
|
| 1 |
@tailwind base;
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
|
@@ -5,37 +7,33 @@
|
|
| 5 |
body {
|
| 6 |
margin: 0;
|
| 7 |
min-height: 100vh;
|
| 8 |
-
background:
|
| 9 |
color: white;
|
| 10 |
-
font-family: system-ui, -apple-system,
|
| 11 |
-
}
|
| 12 |
-
|
| 13 |
-
.glass {
|
| 14 |
-
background: rgba(255, 255, 255, 0.05);
|
| 15 |
-
backdrop-filter: blur(10px);
|
| 16 |
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 17 |
}
|
| 18 |
|
| 19 |
-
.
|
| 20 |
-
background:
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
/* Custom scrollbar */
|
| 24 |
::-webkit-scrollbar {
|
| 25 |
-
width:
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
::-webkit-scrollbar-track {
|
| 29 |
-
background:
|
| 30 |
}
|
| 31 |
|
| 32 |
::-webkit-scrollbar-thumb {
|
| 33 |
-
background: rgba(
|
| 34 |
-
border-radius:
|
| 35 |
}
|
| 36 |
|
| 37 |
::-webkit-scrollbar-thumb:hover {
|
| 38 |
-
background: rgba(
|
| 39 |
}
|
| 40 |
|
| 41 |
/* Slider styling */
|
|
@@ -46,17 +44,17 @@ input[type="range"] {
|
|
| 46 |
}
|
| 47 |
|
| 48 |
input[type="range"]::-webkit-slider-track {
|
| 49 |
-
height:
|
| 50 |
-
background: rgba(255, 255, 255, 0.
|
| 51 |
-
border-radius:
|
| 52 |
}
|
| 53 |
|
| 54 |
input[type="range"]::-webkit-slider-thumb {
|
| 55 |
-webkit-appearance: none;
|
| 56 |
appearance: none;
|
| 57 |
-
width:
|
| 58 |
-
height:
|
| 59 |
-
background: #
|
| 60 |
border-radius: 50%;
|
| 61 |
cursor: pointer;
|
| 62 |
margin-top: -5px;
|
|
@@ -64,17 +62,17 @@ input[type="range"]::-webkit-slider-thumb {
|
|
| 64 |
}
|
| 65 |
|
| 66 |
input[type="range"]::-webkit-slider-thumb:hover {
|
| 67 |
-
transform: scale(1.
|
| 68 |
}
|
| 69 |
|
| 70 |
/* Animation keyframes */
|
| 71 |
@keyframes fadeIn {
|
| 72 |
-
from { opacity: 0; transform: translateY(
|
| 73 |
to { opacity: 1; transform: translateY(0); }
|
| 74 |
}
|
| 75 |
|
| 76 |
.animate-fade-in {
|
| 77 |
-
animation: fadeIn 0.
|
| 78 |
}
|
| 79 |
|
| 80 |
@keyframes spin-slow {
|
|
@@ -86,10 +84,9 @@ input[type="range"]::-webkit-slider-thumb:hover {
|
|
| 86 |
animation: spin-slow 3s linear infinite;
|
| 87 |
}
|
| 88 |
|
| 89 |
-
/* Pulsing border animation */
|
| 90 |
@keyframes pulse-border {
|
| 91 |
-
0%, 100% { border-color: rgba(
|
| 92 |
-
50% { border-color: rgba(
|
| 93 |
}
|
| 94 |
|
| 95 |
.animate-pulse-border {
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
| 2 |
+
|
| 3 |
@tailwind base;
|
| 4 |
@tailwind components;
|
| 5 |
@tailwind utilities;
|
|
|
|
| 7 |
body {
|
| 8 |
margin: 0;
|
| 9 |
min-height: 100vh;
|
| 10 |
+
background: #0D0F1A;
|
| 11 |
color: white;
|
| 12 |
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
+
.card {
|
| 16 |
+
background: #141726;
|
| 17 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 18 |
}
|
| 19 |
|
| 20 |
/* Custom scrollbar */
|
| 21 |
::-webkit-scrollbar {
|
| 22 |
+
width: 6px;
|
| 23 |
+
height: 6px;
|
| 24 |
}
|
| 25 |
|
| 26 |
::-webkit-scrollbar-track {
|
| 27 |
+
background: transparent;
|
| 28 |
}
|
| 29 |
|
| 30 |
::-webkit-scrollbar-thumb {
|
| 31 |
+
background: rgba(20, 184, 166, 0.3);
|
| 32 |
+
border-radius: 3px;
|
| 33 |
}
|
| 34 |
|
| 35 |
::-webkit-scrollbar-thumb:hover {
|
| 36 |
+
background: rgba(20, 184, 166, 0.5);
|
| 37 |
}
|
| 38 |
|
| 39 |
/* Slider styling */
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
input[type="range"]::-webkit-slider-track {
|
| 47 |
+
height: 4px;
|
| 48 |
+
background: rgba(255, 255, 255, 0.08);
|
| 49 |
+
border-radius: 2px;
|
| 50 |
}
|
| 51 |
|
| 52 |
input[type="range"]::-webkit-slider-thumb {
|
| 53 |
-webkit-appearance: none;
|
| 54 |
appearance: none;
|
| 55 |
+
width: 14px;
|
| 56 |
+
height: 14px;
|
| 57 |
+
background: #14B8A6;
|
| 58 |
border-radius: 50%;
|
| 59 |
cursor: pointer;
|
| 60 |
margin-top: -5px;
|
|
|
|
| 62 |
}
|
| 63 |
|
| 64 |
input[type="range"]::-webkit-slider-thumb:hover {
|
| 65 |
+
transform: scale(1.15);
|
| 66 |
}
|
| 67 |
|
| 68 |
/* Animation keyframes */
|
| 69 |
@keyframes fadeIn {
|
| 70 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 71 |
to { opacity: 1; transform: translateY(0); }
|
| 72 |
}
|
| 73 |
|
| 74 |
.animate-fade-in {
|
| 75 |
+
animation: fadeIn 0.25s ease-out;
|
| 76 |
}
|
| 77 |
|
| 78 |
@keyframes spin-slow {
|
|
|
|
| 84 |
animation: spin-slow 3s linear infinite;
|
| 85 |
}
|
| 86 |
|
|
|
|
| 87 |
@keyframes pulse-border {
|
| 88 |
+
0%, 100% { border-color: rgba(20, 184, 166, 0.3); }
|
| 89 |
+
50% { border-color: rgba(20, 184, 166, 0.8); }
|
| 90 |
}
|
| 91 |
|
| 92 |
.animate-pulse-border {
|
frontend/tailwind.config.js
CHANGED
|
@@ -7,24 +7,20 @@ export default {
|
|
| 7 |
theme: {
|
| 8 |
extend: {
|
| 9 |
colors: {
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
300: '#93c5fd',
|
| 15 |
-
400: '#60a5fa',
|
| 16 |
-
500: '#3b82f6',
|
| 17 |
-
600: '#2563eb',
|
| 18 |
-
700: '#1d4ed8',
|
| 19 |
-
800: '#1e40af',
|
| 20 |
-
900: '#1e3a8a',
|
| 21 |
},
|
| 22 |
accent: {
|
| 23 |
-
400: '#
|
| 24 |
-
500: '#
|
| 25 |
-
600: '#
|
| 26 |
}
|
| 27 |
},
|
|
|
|
|
|
|
|
|
|
| 28 |
animation: {
|
| 29 |
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
| 30 |
'bounce-slow': 'bounce 2s infinite',
|
|
|
|
| 7 |
theme: {
|
| 8 |
extend: {
|
| 9 |
colors: {
|
| 10 |
+
surface: {
|
| 11 |
+
DEFAULT: '#0D0F1A',
|
| 12 |
+
card: '#141726',
|
| 13 |
+
elevated: '#1C1F33',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
},
|
| 15 |
accent: {
|
| 16 |
+
400: '#2DD4BF',
|
| 17 |
+
500: '#14B8A6',
|
| 18 |
+
600: '#0D9488',
|
| 19 |
}
|
| 20 |
},
|
| 21 |
+
fontFamily: {
|
| 22 |
+
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
| 23 |
+
},
|
| 24 |
animation: {
|
| 25 |
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
| 26 |
'bounce-slow': 'bounce 2s infinite',
|