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 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.key_detector import detect_key
9
- from ..services.midi_analyzer import (
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 - prefer MIDI (instant)
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
- # Use MIDI directly
84
- key_result = extract_key_from_midi(session.midi_data)
85
- if key_result["confidence"] > 0: # Accept any MIDI key result
 
 
86
  key_source = "midi"
87
- print(f"Key from MIDI: {key_result['key']} {key_result['mode']}")
88
  else:
89
- key_result = None
 
 
 
 
 
 
 
 
 
 
90
 
91
  if key_result is None:
92
- # Fall back to audio analysis (slower)
93
- print("Falling back to audio key detection...")
94
- mix_audio = session.full_mix.audio
95
- mix_sr = session.full_mix.sample_rate
96
- if mix_audio.ndim == 2:
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
- # Key profiles for MIDI key detection (pop/rock/folk optimized)
 
 
 
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
- # Default MIDI tempo is 120 BPM if not specified
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 note data using ensemble voting.
58
 
59
- Args:
60
- midi_file: Parsed MIDI file (mido.MidiFile)
61
 
62
  Returns:
63
  dict with "key", "mode", "confidence"
64
  """
65
- # Build pitch class histogram weighted by duration and velocity
66
  pitch_histogram = np.zeros(12, dtype=np.float64)
 
67
 
68
  for track in midi_file.tracks:
69
- # Track active notes for duration calculation
70
- active_notes = {} # note_number -> (start_time, velocity)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Weight by duration and velocity
84
- weight = duration * (velocity / 127.0)
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
- for profile_name, major_profile, minor_profile in profiles:
107
- best_key = None
108
- best_mode = None
109
- best_corr = -1
110
 
111
- for semitones in range(12):
112
- key_name = KEY_NAMES[semitones]
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
- # Correlate
123
- corr_major = np.corrcoef(pitch_histogram, rotated_major)[0, 1]
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
- if corr_minor > best_corr:
132
- best_corr = corr_minor
133
- best_key = key_name
134
- best_mode = "minor"
135
 
136
- # Add weighted vote
137
- vote_key = (best_key, best_mode)
138
- votes[vote_key] = votes.get(vote_key, 0) + best_corr
 
 
139
 
140
- # Find winner
141
- winner = max(votes.keys(), key=lambda k: votes[k])
142
- total_weight = sum(votes.values())
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
- midi_duration = midi_file.length # mido provides this property
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 ControlPanel from './components/ControlPanel'
 
 
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) // processed region takes over; raw region no longer needed
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
- // Show upload screen if no session
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-4xl font-bold text-center mb-2 bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
203
  Jam Track Studio
204
  </h1>
205
- <p className="text-gray-400 text-center mb-8">
206
- Upload your stems, detect BPM & key, shift pitch and tempo, mix in real-time
207
  </p>
208
 
209
  <FileUpload
@@ -219,129 +209,163 @@ function App() {
219
  )
220
  }
221
 
222
- // Show analysis and controls
223
  return (
224
- <div className="min-h-screen p-4 md:p-8">
225
- <div className="max-w-full mx-auto px-4">
226
- {/* Header */}
227
- <header className="mb-6 flex items-center justify-between">
228
- <h1 className="text-2xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
229
- Jam Track Studio
230
- </h1>
231
- <button
232
- onClick={() => window.location.reload()}
233
- className="text-sm text-gray-400 hover:text-white transition-colors"
234
- >
235
- Choose a different song
236
- </button>
237
- </header>
238
-
239
- {/* Main content grid */}
240
- <div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
241
- {/* Left column: Analysis + Controls */}
242
- <div className="lg:col-span-3 space-y-6">
243
- <AnalysisDisplay
244
- detection={detection}
245
- loading={loading}
246
- onReady={handleStemsReady}
 
 
 
 
 
 
 
 
 
 
247
  />
 
248
 
249
- <ControlPanel
250
- detection={detection}
251
- onProcess={handleProcess}
252
- isProcessing={isProcessing}
253
- hasRegion={hasRegion}
254
- isGenerating={isGenerating}
255
- onGenerate={handleGenerate}
256
- sessionId={sessionId}
257
- continuationReady={continuationReady}
258
  />
 
259
 
260
- {/* Waveform visualization */}
261
- <div className="glass rounded-xl p-4">
262
- <Waveform analyserData={analyserData} isPlaying={isPlaying} />
 
 
 
 
 
 
 
 
 
 
 
 
263
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
- {/* Transport controls */}
266
- {isLoaded ? (
267
- <TransportBar
 
 
 
 
 
 
 
 
268
  isPlaying={isPlaying}
269
- currentTime={currentTime}
270
- duration={duration}
271
- onPlay={handlePlay}
272
- onPlaySection={handlePlaySection}
273
- isRawRegionActive={isRawRegionActive}
274
- onPause={pause}
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
- {/* Right column: Stem Mixer */}
311
- <div className="lg:col-span-2">
312
- <StemMixer
313
- stems={stems}
314
- volumes={volumes}
315
- solos={solos}
316
- mutes={mutes}
317
- reverbs={reverbs}
318
- pans={pans}
319
- isPlaying={isPlaying}
320
- onVolumeChange={setVolume}
321
- onSoloToggle={setSolo}
322
- onMuteToggle={setMute}
323
- onReverbChange={setReverb}
324
- onPanChange={setPan}
325
- onReset={resetVolumes}
326
- />
327
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  </div>
 
329
 
330
- {/* Error display */}
331
- {error && (
332
- <div className="fixed bottom-4 right-4 bg-red-500/90 text-white px-4 py-3 rounded-lg shadow-lg animate-fade-in">
333
- <div className="flex items-center gap-3">
334
- <span>{error}</span>
335
- <button
336
- onClick={clearError}
337
- className="text-white/80 hover:text-white"
338
- >
339
- &times;
340
- </button>
341
- </div>
342
  </div>
343
- )}
344
- </div>
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">&times;</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="glass rounded-xl p-6 animate-pulse">
15
- <div className="flex items-center gap-4">
16
- <div className="w-24 h-24 bg-gray-700/50 rounded-lg"></div>
17
- <div className="flex-1 space-y-3">
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 getConfidenceLabel = (confidence) => {
37
- if (confidence >= 0.8) return 'High'
38
- if (confidence >= 0.6) return 'Medium'
39
- return 'Low'
40
- }
41
 
42
  return (
43
- <div className="glass rounded-xl p-6 animate-fade-in">
44
- <h2 className="text-lg font-semibold mb-4 text-gray-300">Analysis Results</h2>
45
-
46
- <div className="grid grid-cols-2 gap-6">
47
- {/* BPM Display */}
48
- <div className="bg-gray-800/60 border border-purple-500/30 rounded-xl p-4 flex flex-col items-center gap-1">
49
- <span className="text-xs text-gray-500 uppercase tracking-widest">BPM</span>
50
- <div className="flex items-baseline gap-2">
51
- <span className="text-4xl font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
52
- {detection.bpm.toFixed(1)}
53
- </span>
54
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  </div>
56
 
57
- {/* Key Display */}
58
- <div className="bg-gray-800/60 border border-cyan-500/30 rounded-xl p-4 flex flex-col items-center gap-1">
59
- <span className="text-xs text-gray-500 uppercase tracking-widest">Key</span>
60
- <div className="flex items-baseline gap-2">
61
- <span className="text-4xl font-bold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent">
62
- {detection.key}
63
- </span>
64
- <span className="text-gray-400 text-lg capitalize">
65
- {detection.mode}
 
 
 
 
 
 
 
 
 
 
 
66
  </span>
67
- </div>
 
 
 
 
 
 
 
 
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="glass 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-gray-800/50 rounded-xl border border-gray-600">
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-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-primary-500"
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-gray-700 disabled:text-gray-500 disabled:cursor-not-allowed bg-gradient-to-r from-primary-600 to-accent-600 hover:from-primary-500 hover:to-accent-500"
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="glass 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,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-gradient-to-r from-primary-500 to-accent-500 transition-all duration-300"
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
- // ─── Custom volume slider ────────────────────────────────────────────────────
 
 
 
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-5 select-none"
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-gray-700/80" />
57
  <div
58
  className="absolute top-0 left-0 h-full rounded-full"
59
- style={{ width: `${pct}%`, background: disabled ? '#4b5563' : '#a855f7' }}
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: 14,
68
- height: 14,
69
  top: '50%',
70
  left: `${pct}%`,
71
  transform: 'translate(-50%, -50%)',
72
  background: disabled ? '#374151' : 'white',
73
- border: `2px solid ${disabled ? '#6b7280' : '#a855f7'}`,
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 = 40
89
  const cx = SIZE / 2
90
  const cy = SIZE / 2
91
- const R = 12
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
- // 270° of rotation = full range
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
- {/* Disc background */}
179
- <circle
180
- cx={cx} cy={cy} r={R + 3}
181
- fill="rgba(255,255,255,0.05)"
182
- stroke="rgba(255,255,255,0.1)"
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-[10px] text-gray-400 leading-none">{label}</span>
224
- <span className="text-[10px] font-mono text-gray-300 leading-none">{display}</span>
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="glass rounded-xl p-4">
256
- <h2 className="text-lg font-semibold mb-3 text-white">Stem Mixer</h2>
257
- <p className="text-gray-400 text-sm">No stems loaded yet</p>
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="glass rounded-xl p-4 h-fit sticky top-4">
266
- <div className="flex items-center justify-between mb-3">
267
- <h2 className="text-lg font-semibold text-white">Stem Mixer</h2>
268
  <button
269
  onClick={onReset}
270
- className="text-xs px-2.5 py-1 bg-white/10 hover:bg-white/20 rounded text-gray-300 transition-colors"
271
  >
272
- Reset All
273
  </button>
274
  </div>
275
 
276
- <div className="space-y-1.5">
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.15
282
  const pan = pans?.[stem] ?? 0
283
  const isAudible = !isMuted && (hasSolos ? isSolo : true) && volume > 0
 
284
 
285
  return (
286
- <div
287
- key={stem}
288
- className={`px-2.5 py-1.5 rounded-lg transition-all duration-300 ${
289
- isMuted
290
- ? 'bg-gray-900/50 opacity-50'
291
- : isPlaying && isAudible
292
- ? 'bg-purple-500/20 shadow-lg shadow-purple-500/10'
293
- : 'bg-white/5'
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={() => onSoloToggle(stem, !isSolo)}
307
- className={`w-6 h-6 rounded text-[10px] font-semibold transition-all ${
308
- isSolo ? 'bg-yellow-500 text-black' : 'bg-white/10 text-gray-400 hover:bg-white/20'
309
- }`}
310
- title="Solo"
311
- >S</button>
312
- <button
313
- onClick={() => onMuteToggle(stem, !isMuted)}
314
- className={`w-6 h-6 rounded text-[10px] font-semibold transition-all ${
315
- isMuted ? 'bg-red-500 text-white' : 'bg-white/10 text-gray-400 hover:bg-white/20'
316
- }`}
317
- title="Mute"
318
- >M</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  </div>
320
- </div>
321
 
322
- {/* Row 2: volume slider */}
323
- <VolumeSlider
324
- value={volume}
325
- onChange={(v) => onVolumeChange(stem, v)}
326
- disabled={isMuted}
327
- />
328
-
329
- {/* Row 3: knobs side by side beneath slider */}
330
- <div className="flex justify-around mt-1">
331
- <Knob
332
- value={pan}
333
- min={-1}
334
- max={1}
335
- onChange={(v) => onPanChange && onPanChange(stem, v)}
336
- label="Pan"
337
- color="#22d3ee"
338
- showCenterTick
339
- disabled={isMuted}
340
- endLabels={['L', 'R']}
341
- />
342
- <Knob
343
- value={reverb}
344
- min={0}
345
- max={0.5}
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 time = pixelToTimeRef.current(e.clientX)
 
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 time = pixelToTime(e.clientX)
 
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 newStart = Math.min(parsed, (regionEnd || barDuration) - 0.1)
 
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 newEnd = Math.max(parsed, (regionStart || 0) + 0.1)
 
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="glass rounded-xl p-4 animate-fade-in">
240
  {/* Main transport controls */}
241
- <div className="flex items-center gap-4">
242
  {/* Play/Pause button */}
243
  <button
244
  onClick={isPlaying ? onPause : onPlay}
245
- className="w-14 h-14 rounded-full bg-gradient-to-r from-primary-600 to-accent-600 hover:from-primary-500 hover:to-accent-500 flex items-center justify-center transition-all hover:scale-105"
246
  >
247
  {isPlaying ? (
248
- <svg className="w-6 h-6 fill-white" viewBox="0 0 24 24">
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-6 h-6 fill-white ml-1" viewBox="0 0 24 24">
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-10 h-10 rounded-lg bg-gray-700/50 hover:bg-gray-600/50 flex items-center justify-center transition-colors"
263
  >
264
- <svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
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-sm text-gray-400 w-12 text-right">
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-3 bg-gray-700/50 rounded-full relative">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  {/* Playback progress fill */}
286
  <div
287
- className="absolute top-0 left-0 h-full bg-gradient-to-r from-primary-500 to-accent-500 rounded-full pointer-events-none"
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-yellow-400/30 border-y border-yellow-400/50 pointer-events-none"
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-yellow-400 rounded-sm cursor-ew-resize hover:bg-yellow-300 transition-colors"
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-yellow-400 rounded-sm cursor-ew-resize hover:bg-yellow-300 transition-colors"
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-sm text-gray-400 w-12">
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 time inputs */}
329
  <div className="flex items-center gap-2 text-sm">
330
  <span className="text-gray-400">Region:</span>
331
 
332
- {/* Start time */}
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-gray-800 border border-yellow-400/50 rounded px-1.5 py-0.5 text-yellow-300 text-sm font-mono text-center focus:outline-none"
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-gray-800/80 rounded text-yellow-300 font-mono hover:bg-gray-700 transition-colors"
350
  >
351
- {formatTimeDetailed(regionStart)}
 
 
352
  </button>
353
  )}
354
 
355
  <span className="text-gray-500">-</span>
356
 
357
- {/* End time */}
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-gray-800 border border-yellow-400/50 rounded px-1.5 py-0.5 text-yellow-300 text-sm font-mono text-center focus:outline-none"
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-gray-800/80 rounded text-yellow-300 font-mono hover:bg-gray-700 transition-colors"
375
  >
376
- {formatTimeDetailed(regionEnd)}
 
 
377
  </button>
378
  )}
379
 
 
380
  <span className="text-gray-500 text-xs">
381
- ({formatTimeDetailed(regionEnd - regionStart)})
 
 
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-yellow-500/20 hover:bg-yellow-500/30 border border-yellow-500/40 text-yellow-300 text-xs font-medium transition-colors"
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-gray-700/50"
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-yellow-400 flex items-center gap-1">
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-gray-700/70 hover:bg-gray-600/70 text-gray-300 hover:text-white px-3 py-1 rounded transition-colors border border-gray-600"
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.15 // 15% reverb by default for studio sound
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.15
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.15
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: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%);
9
  color: white;
10
- font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
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
- .glass-hover:hover {
20
- background: rgba(255, 255, 255, 0.1);
 
21
  }
22
 
23
  /* Custom scrollbar */
24
  ::-webkit-scrollbar {
25
- width: 8px;
 
26
  }
27
 
28
  ::-webkit-scrollbar-track {
29
- background: rgba(255, 255, 255, 0.05);
30
  }
31
 
32
  ::-webkit-scrollbar-thumb {
33
- background: rgba(139, 92, 246, 0.5);
34
- border-radius: 4px;
35
  }
36
 
37
  ::-webkit-scrollbar-thumb:hover {
38
- background: rgba(139, 92, 246, 0.7);
39
  }
40
 
41
  /* Slider styling */
@@ -46,17 +44,17 @@ input[type="range"] {
46
  }
47
 
48
  input[type="range"]::-webkit-slider-track {
49
- height: 6px;
50
- background: rgba(255, 255, 255, 0.1);
51
- border-radius: 3px;
52
  }
53
 
54
  input[type="range"]::-webkit-slider-thumb {
55
  -webkit-appearance: none;
56
  appearance: none;
57
- width: 16px;
58
- height: 16px;
59
- background: #8b5cf6;
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.2);
68
  }
69
 
70
  /* Animation keyframes */
71
  @keyframes fadeIn {
72
- from { opacity: 0; transform: translateY(10px); }
73
  to { opacity: 1; transform: translateY(0); }
74
  }
75
 
76
  .animate-fade-in {
77
- animation: fadeIn 0.3s ease-out;
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(139, 92, 246, 0.3); }
92
- 50% { border-color: rgba(139, 92, 246, 0.8); }
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
- primary: {
11
- 50: '#eff6ff',
12
- 100: '#dbeafe',
13
- 200: '#bfdbfe',
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: '#a78bfa',
24
- 500: '#8b5cf6',
25
- 600: '#7c3aed',
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',