rlackey commited on
Commit
1e9fec0
·
1 Parent(s): 9c68baa

Add chord detection, stems, and reaper modules

Browse files

- Add modules/chords.py with real chord detection using librosa
- Add modules/stems.py for Demucs stem separation
- Add modules/reaper.py for Reaper project generation
- Update app.py to use real chord detection instead of placeholder
- Lower chord confidence threshold from 0.25 to 0.12 for better detection

Files changed (5) hide show
  1. app.py +30 -2
  2. modules/__init__.py +4 -0
  3. modules/chords.py +298 -0
  4. modules/reaper.py +465 -0
  5. modules/stems.py +132 -0
app.py CHANGED
@@ -61,6 +61,14 @@ except ImportError:
61
  HAS_CATALOG = False
62
  print("Catalog module not available")
63
 
 
 
 
 
 
 
 
 
64
  # AI Generator (optional)
65
  HAS_AUDIOCRAFT = False
66
  try:
@@ -390,10 +398,30 @@ def process_song(audio, yt_url, name, lyrics, do_stems, do_chords, do_daw, user_
390
 
391
  # Chords
392
  chords_text = None
 
393
  if do_chords:
394
  progress(0.7, "Detecting chords...")
395
- chords_text = f"# {song_name}\nKey: {analysis.get('key', 'C')}\nBPM: {analysis.get('bpm', 120)}\n\n[Chord detection - implement with chord-extractor]"
396
- log.append("Chord chart generated")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
 
398
  # Save to catalog
399
  if HAS_CATALOG:
 
61
  HAS_CATALOG = False
62
  print("Catalog module not available")
63
 
64
+ # Chord detection module
65
+ try:
66
+ from modules.chords import extract_chords
67
+ HAS_CHORD_DETECTION = True
68
+ except ImportError:
69
+ HAS_CHORD_DETECTION = False
70
+ print("Chord detection module not available")
71
+
72
  # AI Generator (optional)
73
  HAS_AUDIOCRAFT = False
74
  try:
 
398
 
399
  # Chords
400
  chords_text = None
401
+ chords_list = []
402
  if do_chords:
403
  progress(0.7, "Detecting chords...")
404
+ if HAS_CHORD_DETECTION:
405
+ try:
406
+ chords_list = extract_chords(audio_path, min_duration=1.0)
407
+ if chords_list:
408
+ # Format as chord chart
409
+ lines = [f"# {song_name}", f"Key: {analysis.get('key', 'C')} | BPM: {analysis.get('bpm', 120)}", ""]
410
+ for time, chord in chords_list:
411
+ mins = int(time // 60)
412
+ secs = time % 60
413
+ lines.append(f"[{mins}:{secs:05.2f}] {chord}")
414
+ chords_text = "\n".join(lines)
415
+ log.append(f"Chord chart: {len(chords_list)} changes detected")
416
+ else:
417
+ chords_text = f"# {song_name}\nKey: {analysis.get('key', 'C')}\nBPM: {analysis.get('bpm', 120)}\n\n(No chord changes detected)"
418
+ log.append("Chord detection: no changes found")
419
+ except Exception as e:
420
+ chords_text = f"# {song_name}\nChord detection error: {str(e)}"
421
+ log.append(f"Chord detection error: {e}")
422
+ else:
423
+ chords_text = f"# {song_name}\nKey: {analysis.get('key', 'C')}\nBPM: {analysis.get('bpm', 120)}\n\n(Chord detection not available)"
424
+ log.append("Chord detection not available")
425
 
426
  # Save to catalog
427
  if HAS_CATALOG:
modules/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # VYNL Modules
2
+ from .chords import extract_chords, extract_chords_multi_stem
3
+ from .stems import separate_stems as separate_stems_demucs, list_stems
4
+ from .reaper import create_reaper_project
modules/chords.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Multi-Source Chord Detection
3
+ Analyzes stems AND full mix for best results
4
+ """
5
+
6
+ import numpy as np
7
+ import warnings
8
+ warnings.filterwarnings('ignore')
9
+
10
+ try:
11
+ import librosa
12
+ import scipy.ndimage
13
+ LIBROSA_AVAILABLE = True
14
+ except ImportError:
15
+ LIBROSA_AVAILABLE = False
16
+
17
+
18
+ def extract_chords_multi_stem(stems_dir, original_audio=None, min_duration=0.5):
19
+ """
20
+ Extract chords from multiple sources - stems AND full track
21
+
22
+ Args:
23
+ stems_dir: Path to directory containing stems
24
+ original_audio: Path to original full mix (optional but recommended)
25
+ min_duration: Minimum chord duration
26
+
27
+ Returns:
28
+ List of (timestamp, chord_name) tuples
29
+ """
30
+
31
+ if not LIBROSA_AVAILABLE:
32
+ print(" [WARN] Chord extraction skipped (librosa not installed)")
33
+ return []
34
+
35
+ from pathlib import Path
36
+ stems_dir = Path(stems_dir)
37
+
38
+ print(f" Analyzing multiple sources for chord detection...")
39
+
40
+ chord_candidates = []
41
+
42
+ # SOURCE 1: Original full mix (HIGHEST WEIGHT)
43
+ if original_audio and Path(original_audio).exists():
44
+ print(f" • Full mix (original audio)")
45
+ full_chords = detect_chords_from_stem(original_audio, focus='harmony')
46
+ if full_chords:
47
+ chord_candidates.append(('full_mix', full_chords, 4.0))
48
+
49
+ # SOURCE 2: Bass stem
50
+ for stem_file in stems_dir.glob('*.mp3'):
51
+ if 'bass' in stem_file.stem.lower():
52
+ print(f" • Bass stem")
53
+ bass_chords = detect_chords_from_stem(stem_file, focus='bass')
54
+ if bass_chords:
55
+ chord_candidates.append(('bass', bass_chords, 3.0))
56
+ break
57
+
58
+ # SOURCE 3: Guitar stem
59
+ for stem_file in stems_dir.glob('*.mp3'):
60
+ if 'guitar' in stem_file.stem.lower():
61
+ print(f" • Guitar stem")
62
+ guitar_chords = detect_chords_from_stem(stem_file, focus='harmony')
63
+ if guitar_chords:
64
+ chord_candidates.append(('guitar', guitar_chords, 2.5))
65
+ break
66
+
67
+ # SOURCE 4: Piano/Keys
68
+ for stem_file in stems_dir.glob('*.mp3'):
69
+ name_lower = stem_file.stem.lower()
70
+ if 'piano' in name_lower or 'keys' in name_lower:
71
+ print(f" • Piano/Keys stem")
72
+ piano_chords = detect_chords_from_stem(stem_file, focus='harmony')
73
+ if piano_chords:
74
+ chord_candidates.append(('piano', piano_chords, 2.0))
75
+ break
76
+
77
+ # SOURCE 5: Other stem
78
+ for stem_file in stems_dir.glob('*.mp3'):
79
+ if 'other' in stem_file.stem.lower():
80
+ print(f" • Other stem")
81
+ other_chords = detect_chords_from_stem(stem_file, focus='harmony')
82
+ if other_chords:
83
+ chord_candidates.append(('other', other_chords, 1.5))
84
+ break
85
+
86
+ if not chord_candidates:
87
+ print(" [WARN] No suitable sources found")
88
+ return []
89
+
90
+ print(f" Merging results from {len(chord_candidates)} sources...")
91
+ merged_chords = merge_chord_detections(chord_candidates, min_duration)
92
+
93
+ return merged_chords
94
+
95
+
96
+ def detect_chords_from_stem(stem_file, focus='harmony'):
97
+ """Detect chords - FULL SONG"""
98
+
99
+ try:
100
+ y, sr = librosa.load(str(stem_file), sr=22050, duration=None)
101
+ hop_length = 256 if focus == 'bass' else 512
102
+ chroma = librosa.feature.chroma_cqt(y=y, sr=sr, hop_length=hop_length)
103
+ chroma = scipy.ndimage.median_filter(chroma, size=(1, 9))
104
+
105
+ templates = create_chord_templates()
106
+ chords = []
107
+ last_chord = None
108
+
109
+ for i in range(chroma.shape[1]):
110
+ frame = chroma[:, i]
111
+ time = librosa.frames_to_time(i, sr=sr, hop_length=hop_length)
112
+ chord, confidence = match_chord_template_with_confidence(frame, templates, focus)
113
+
114
+ if chord != last_chord and confidence > 0.12:
115
+ chords.append((float(time), chord, float(confidence)))
116
+ last_chord = chord
117
+
118
+ return chords
119
+
120
+ except Exception as e:
121
+ from pathlib import Path
122
+ print(f" [WARN] Failed to analyze {Path(stem_file).name}: {e}")
123
+ return []
124
+
125
+
126
+ def merge_chord_detections(chord_candidates, min_duration=0.5):
127
+ """Merge - if only one source, just use it directly"""
128
+
129
+ # If only one source, don't filter - just use it
130
+ if len(chord_candidates) == 1:
131
+ name, chords, weight = chord_candidates[0]
132
+ # Convert (time, chord, conf) to (time, chord)
133
+ return [(time, chord) for time, chord, conf in chords]
134
+
135
+ # Multiple sources - merge
136
+ all_times = set()
137
+ for name, chords, weight in chord_candidates:
138
+ for time, chord, conf in chords:
139
+ all_times.add(time)
140
+
141
+ all_times = sorted(all_times)
142
+
143
+ if not all_times:
144
+ return []
145
+
146
+ time_grid = np.arange(0, max(all_times) + 1, 0.5)
147
+
148
+ merged = []
149
+ last_chord = None
150
+ last_time = 0
151
+
152
+ for grid_time in time_grid:
153
+ votes = {}
154
+ total_weight = 0
155
+
156
+ for name, chords, weight in chord_candidates:
157
+ active_chord = get_chord_at_time(chords, grid_time)
158
+
159
+ if active_chord:
160
+ chord, conf = active_chord
161
+ vote_strength = conf * weight
162
+
163
+ if chord in votes:
164
+ votes[chord] += vote_strength
165
+ else:
166
+ votes[chord] = vote_strength
167
+
168
+ total_weight += weight
169
+
170
+ if votes:
171
+ best_chord = max(votes.items(), key=lambda x: x[1])[0]
172
+
173
+ # Less strict threshold
174
+ if best_chord != last_chord:
175
+ if last_chord is not None:
176
+ duration = grid_time - last_time
177
+ if duration >= min_duration:
178
+ merged.append((last_time, last_chord))
179
+
180
+ last_chord = best_chord
181
+ last_time = grid_time
182
+
183
+ if last_chord:
184
+ merged.append((last_time, last_chord))
185
+
186
+ return merged
187
+
188
+
189
+ def get_chord_at_time(chords, time):
190
+ """Find active chord"""
191
+ active_chord = None
192
+ for chord_time, chord, conf in chords:
193
+ if chord_time <= time:
194
+ active_chord = (chord, conf)
195
+ else:
196
+ break
197
+ return active_chord
198
+
199
+
200
+ def create_chord_templates():
201
+ """Enhanced chord templates"""
202
+ notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
203
+ templates = {}
204
+
205
+ for i, root in enumerate(notes):
206
+ # Major
207
+ template = np.zeros(12)
208
+ template[(i + 0) % 12] = 1.0
209
+ template[(i + 4) % 12] = 0.8
210
+ template[(i + 7) % 12] = 0.6
211
+ templates[root] = template
212
+
213
+ # Minor
214
+ template = np.zeros(12)
215
+ template[(i + 0) % 12] = 1.0
216
+ template[(i + 3) % 12] = 0.8
217
+ template[(i + 7) % 12] = 0.6
218
+ templates[root + 'm'] = template
219
+
220
+ # Dominant 7
221
+ template = np.zeros(12)
222
+ template[(i + 0) % 12] = 1.0
223
+ template[(i + 4) % 12] = 0.7
224
+ template[(i + 7) % 12] = 0.5
225
+ template[(i + 10) % 12] = 0.4
226
+ templates[root + '7'] = template
227
+
228
+ # Major 7
229
+ template = np.zeros(12)
230
+ template[(i + 0) % 12] = 1.0
231
+ template[(i + 4) % 12] = 0.7
232
+ template[(i + 7) % 12] = 0.5
233
+ template[(i + 11) % 12] = 0.4
234
+ templates[root + 'maj7'] = template
235
+
236
+ # Minor 7
237
+ template = np.zeros(12)
238
+ template[(i + 0) % 12] = 1.0
239
+ template[(i + 3) % 12] = 0.7
240
+ template[(i + 7) % 12] = 0.5
241
+ template[(i + 10) % 12] = 0.4
242
+ templates[root + 'm7'] = template
243
+
244
+ return templates
245
+
246
+
247
+ def match_chord_template_with_confidence(chroma_frame, templates, focus='harmony'):
248
+ """Match with confidence"""
249
+ if chroma_frame.sum() > 0:
250
+ chroma_frame = chroma_frame / chroma_frame.sum()
251
+
252
+ best_chord = 'C'
253
+ best_score = -1
254
+
255
+ for chord_name, template in templates.items():
256
+ if template.sum() > 0:
257
+ template_norm = template / template.sum()
258
+ else:
259
+ continue
260
+
261
+ score = np.dot(chroma_frame, template_norm)
262
+
263
+ if focus == 'bass' and not ('7' in chord_name or 'm' in chord_name):
264
+ score *= 1.1
265
+
266
+ if score > best_score:
267
+ best_score = score
268
+ best_chord = chord_name
269
+
270
+ return best_chord, best_score
271
+
272
+
273
+ def extract_chords(audio_path, min_duration=0.5):
274
+ """Fallback single-file"""
275
+ if not LIBROSA_AVAILABLE:
276
+ return []
277
+
278
+ try:
279
+ y, sr = librosa.load(audio_path, sr=22050, duration=None)
280
+ chroma = librosa.feature.chroma_cqt(y=y, sr=sr, hop_length=512)
281
+ chroma = scipy.ndimage.median_filter(chroma, size=(1, 9))
282
+
283
+ templates = create_chord_templates()
284
+ chords = []
285
+ last_chord = None
286
+
287
+ for i in range(chroma.shape[1]):
288
+ frame = chroma[:, i]
289
+ time = librosa.frames_to_time(i, sr=sr, hop_length=512)
290
+ chord, conf = match_chord_template_with_confidence(frame, templates, 'harmony')
291
+
292
+ if chord != last_chord and conf > 0.12:
293
+ chords.append((float(time), chord))
294
+ last_chord = chord
295
+
296
+ return chords
297
+ except:
298
+ return []
modules/reaper.py ADDED
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ultimate Reaper Project Generator
3
+ - Precise tempo map with fluctuations
4
+ - Chord change markers throughout track
5
+ - Region-based song structure
6
+ - Lyric file integration for better structure detection
7
+ - Color-coded markers and regions
8
+ """
9
+
10
+ from pathlib import Path
11
+ import numpy as np
12
+ import re
13
+
14
+ try:
15
+ import librosa
16
+ LIBROSA_AVAILABLE = True
17
+ except ImportError:
18
+ LIBROSA_AVAILABLE = False
19
+
20
+
21
+ # Color scheme for Reaper (BGR format as integers)
22
+ COLORS = {
23
+ # Regions
24
+ 'intro': 8421504, # Gray
25
+ 'verse': 16744703, # Light blue
26
+ 'pre-chorus': 16777011, # Light yellow
27
+ 'chorus': 8454143, # Green
28
+ 'bridge': 16744576, # Orange
29
+ 'solo': 16711935, # Purple
30
+ 'outro': 8421504, # Gray
31
+ 'fill': 12632256, # Light gray
32
+
33
+ # Chords - by function
34
+ 'tonic': 8454143, # Green (I, i)
35
+ 'subdominant': 16776960, # Yellow (IV, iv)
36
+ 'dominant': 33488896, # Red (V, V7)
37
+ 'default': 16777215, # White
38
+ }
39
+
40
+
41
+ def create_reaper_project(song_name, stems_dir, tempo, key, structure=None,
42
+ chords=None, lyrics_file=None, audio_file=None):
43
+ """
44
+ Generate ultimate Reaper project with full integration
45
+
46
+ Args:
47
+ song_name: Name of the song
48
+ stems_dir: Directory containing stem files
49
+ tempo: Average tempo in BPM
50
+ key: Musical key
51
+ structure: Detected song structure
52
+ chords: List of (timestamp, chord_name) tuples
53
+ lyrics_file: Optional path to lyric file with structure tags
54
+ audio_file: Original audio file for beat detection
55
+
56
+ Returns:
57
+ RPP file content as string
58
+ """
59
+
60
+ stems_dir = Path(stems_dir)
61
+ stem_files = sorted(stems_dir.glob('*.mp3'))
62
+
63
+ if not stem_files:
64
+ return create_empty_project(song_name, tempo)
65
+
66
+ # Parse lyric file if provided
67
+ lyric_structure = None
68
+ if lyrics_file and Path(lyrics_file).exists():
69
+ print(" Parsing lyric file for structure...")
70
+ lyric_structure = parse_lyric_file(lyrics_file)
71
+
72
+ # Merge detected structure with lyric structure
73
+ if lyric_structure:
74
+ structure = merge_structures(structure, lyric_structure)
75
+
76
+ # Detect precise beats and tempo map
77
+ print(" Detecting precise beat grid and tempo map...")
78
+ audio_to_analyze = audio_file if audio_file else stem_files[0]
79
+ beats, downbeats, tempo_map = detect_precise_beats(audio_to_analyze)
80
+
81
+ # Start building RPP
82
+ rpp_lines = []
83
+
84
+ # === HEADER ===
85
+ rpp_lines.append('<REAPER_PROJECT 0.1 "6.0/linux-x86_64" 1234567890')
86
+ rpp_lines.append(' RIPPLE 0')
87
+ rpp_lines.append(' GROUPOVERRIDE 0 0 0')
88
+ rpp_lines.append(' AUTOXFADE 1')
89
+
90
+ # === TEMPO MAP ===
91
+ if tempo_map and len(tempo_map) > 1:
92
+ # Variable tempo - use multiple TEMPO markers (Reaper 6.x format)
93
+ print(f" Creating tempo map with {len(tempo_map)} changes...")
94
+
95
+ # First tempo at project start
96
+ rpp_lines.append(f' TEMPO {tempo_map[0]["bpm"]:.6f} 4 4')
97
+
98
+ # Add tempo changes as additional TEMPO lines with time position
99
+ # Format: TEMPO bpm timesig beat_position
100
+ for i, tm in enumerate(tempo_map[1:], 1):
101
+ # Calculate beat position from time
102
+ beat_pos = tm["time"] * tempo_map[0]["bpm"] / 60.0
103
+ rpp_lines.append(f' TEMPO {tm["bpm"]:.6f} 4 4 {beat_pos:.6f}')
104
+ else:
105
+ # Constant tempo
106
+ rpp_lines.append(f' TEMPO {tempo:.6f} 4 4')
107
+
108
+ rpp_lines.append(' PLAYRATE 1 0 0.25 4')
109
+ rpp_lines.append(' CURSOR 0')
110
+ rpp_lines.append(' ZOOM 100 0 0')
111
+ rpp_lines.append(' VZOOMEX 1 0')
112
+ rpp_lines.append(' USE_REC_CFG 0')
113
+ rpp_lines.append(' RECMODE 1')
114
+ rpp_lines.append(' SMPTESYNC 0 30 100 40 1000 300 0 0 1 0 0')
115
+ rpp_lines.append(' LOOP 0')
116
+ rpp_lines.append(' LOOPGRAN 0 4')
117
+ rpp_lines.append(' RECORD_PATH "" ""')
118
+ rpp_lines.append('')
119
+
120
+ # === DOWNBEAT MARKERS (BAR LINES) ===
121
+ if downbeats is not None and len(downbeats) > 0:
122
+ print(f" Creating {len(downbeats)} bar markers...")
123
+ for i, beat_time in enumerate(downbeats):
124
+ bar_number = i + 1
125
+ adjusted_time = beat_time + 8.0 # Offset by pre-roll
126
+ rpp_lines.append(f' MARKER {bar_number} {adjusted_time:.6f} "Bar {bar_number}" 0 0')
127
+
128
+ # === CHORD MARKERS ===
129
+ marker_id = len(downbeats) + 1 if downbeats is not None else 1
130
+
131
+ if chords and len(chords) > 0:
132
+ print(f" Creating {len(chords)} chord change markers...")
133
+ for time, chord in chords:
134
+ color = get_chord_color(chord, key)
135
+ adjusted_time = time + 8.0 # Offset by pre-roll
136
+ rpp_lines.append(f' MARKER {marker_id} {adjusted_time:.6f} "{chord}" 0 {color}')
137
+ marker_id += 1
138
+
139
+ rpp_lines.append('')
140
+
141
+ # === REGIONS (SONG STRUCTURE) ===
142
+
143
+ # Add count-in region at start
144
+ print(" Creating count-in region (8 seconds)...")
145
+ rpp_lines.append(f' MARKER {marker_id} 0.0 "Count-In" 1 {COLORS["intro"]} {{}} 0')
146
+ marker_id += 1
147
+ rpp_lines.append(f' MARKER {marker_id} 8.0 "" 1 {COLORS["intro"]} {{}} 0')
148
+ marker_id += 1
149
+
150
+ if structure and len(structure) > 0:
151
+ print(f" Creating {len(structure)} structural regions...")
152
+ region_id = 1
153
+ for section in structure:
154
+ section_name = section['name']
155
+ start = section['start'] + 8.0 # Offset by pre-roll
156
+ end = section['end'] + 8.0
157
+ color = get_section_color(section_name)
158
+
159
+ # Regions use different format: MARKER with region flag
160
+ rpp_lines.append(
161
+ f' MARKER {marker_id} {start:.6f} "{section_name}" 1 {color} {{}} {region_id}'
162
+ )
163
+ marker_id += 1
164
+
165
+ # Region end
166
+ rpp_lines.append(
167
+ f' MARKER {marker_id} {end:.6f} "" 1 {color} {{}} {region_id}'
168
+ )
169
+ marker_id += 1
170
+ region_id += 1
171
+
172
+ rpp_lines.append('')
173
+
174
+ # === TRACKS ===
175
+ track_number = 1
176
+
177
+ for stem_file in stem_files:
178
+ stem_name = stem_file.stem.replace('_', ' ').title()
179
+ color = get_track_color(stem_name)
180
+
181
+ rpp_lines.append(' <TRACK')
182
+ rpp_lines.append(f' NAME "{stem_name}"')
183
+ rpp_lines.append(f' PEAKCOL {color}')
184
+ rpp_lines.append(' BEAT -1')
185
+ rpp_lines.append(' AUTOMODE 0')
186
+ rpp_lines.append(' VOLPAN 1 0 -1 -1 1')
187
+ rpp_lines.append(' MUTESOLO 0 0 0')
188
+ rpp_lines.append(' IPHASE 0')
189
+ rpp_lines.append(' PLAYOFFS 0 1')
190
+ rpp_lines.append(' ISBUS 0 0')
191
+ rpp_lines.append(' BUSCOMP 0 0 0 0 0')
192
+ rpp_lines.append(' SHOWINMIX 1 0.6667 0.5 1 0.5 0 0 0')
193
+ rpp_lines.append(' FREEMODE 0')
194
+ rpp_lines.append(' SEL 0')
195
+ rpp_lines.append(' REC 0 0 1 0 0 0 0')
196
+ rpp_lines.append(' VU 2')
197
+ rpp_lines.append(' TRACKHEIGHT 0 0 0 0 0 0')
198
+ rpp_lines.append(' INQ 0 0 0 0.5 100 0 0 100')
199
+ rpp_lines.append(' NCHAN 2')
200
+ rpp_lines.append(' FX 1')
201
+ rpp_lines.append(' TRACKID')
202
+ rpp_lines.append(' PERF 0')
203
+ rpp_lines.append(' MIDIOUT -1')
204
+ rpp_lines.append(' MAINSEND 1 0')
205
+
206
+ # Item (audio)
207
+ rpp_lines.append(' <ITEM')
208
+ rpp_lines.append(' POSITION 8.0') # 8-second pre-roll
209
+ rpp_lines.append(' SNAPOFFS 0')
210
+ rpp_lines.append(' LENGTH 0')
211
+ rpp_lines.append(' LOOP 0')
212
+ rpp_lines.append(' ALLTAKES 0')
213
+ rpp_lines.append(' FADEIN 1 0.01 0 1 0 0 0')
214
+ rpp_lines.append(' FADEOUT 1 0.01 0 1 0 0 0')
215
+ rpp_lines.append(' MUTE 0 0')
216
+ rpp_lines.append(' SEL 1')
217
+ rpp_lines.append(' IGUID')
218
+ rpp_lines.append(f' IID {track_number}')
219
+ rpp_lines.append(f' NAME "{stem_name}"')
220
+ rpp_lines.append(' VOLPAN 1 0 1 -1')
221
+ rpp_lines.append(' SOFFS 0 0')
222
+ rpp_lines.append(' PLAYRATE 1 1 0 -1 0 0.0025')
223
+ rpp_lines.append(' CHANMODE 0')
224
+ rpp_lines.append(' GUID')
225
+ rpp_lines.append(' RESOURCECH 0')
226
+ rpp_lines.append(' <SOURCE WAVE')
227
+ rpp_lines.append(f' FILE "{stem_file.absolute()}"')
228
+ rpp_lines.append(' >')
229
+ rpp_lines.append(' >')
230
+ rpp_lines.append(' >')
231
+ rpp_lines.append('')
232
+
233
+ track_number += 1
234
+
235
+ # Footer
236
+ rpp_lines.append('>')
237
+
238
+ return '\n'.join(rpp_lines)
239
+
240
+
241
+ def parse_lyric_file(lyrics_path):
242
+ """
243
+ Parse lyric file with structure tags
244
+
245
+ Expected format:
246
+ [Intro]
247
+
248
+ [Verse 1]
249
+ Lyrics here...
250
+
251
+ [Chorus]
252
+ Lyrics here...
253
+
254
+ Returns:
255
+ List of {'name': str, 'lyrics': str} dicts
256
+ """
257
+
258
+ with open(lyrics_path, 'r', encoding='utf-8') as f:
259
+ content = f.read()
260
+
261
+ # Pattern: [Section Name]
262
+ sections = []
263
+ current_section = None
264
+ current_lyrics = []
265
+
266
+ for line in content.split('\n'):
267
+ # Check for section tag
268
+ match = re.match(r'\[(.*?)\]', line.strip())
269
+ if match:
270
+ # Save previous section
271
+ if current_section:
272
+ sections.append({
273
+ 'name': current_section,
274
+ 'lyrics': '\n'.join(current_lyrics).strip()
275
+ })
276
+
277
+ # Start new section
278
+ current_section = match.group(1)
279
+ current_lyrics = []
280
+ else:
281
+ # Add to current section
282
+ if current_section:
283
+ current_lyrics.append(line)
284
+
285
+ # Save last section
286
+ if current_section:
287
+ sections.append({
288
+ 'name': current_section,
289
+ 'lyrics': '\n'.join(current_lyrics).strip()
290
+ })
291
+
292
+ return sections
293
+
294
+
295
+ def merge_structures(detected_structure, lyric_structure):
296
+ """
297
+ Merge detected structure with lyric file structure
298
+ Lyric file takes precedence for naming
299
+ """
300
+
301
+ if not detected_structure:
302
+ return lyric_structure
303
+
304
+ if not lyric_structure:
305
+ return detected_structure
306
+
307
+ # Use detected timestamps but lyric names
308
+ merged = []
309
+
310
+ for i, detected in enumerate(detected_structure):
311
+ if i < len(lyric_structure):
312
+ # Use lyric name but detected times
313
+ merged.append({
314
+ 'name': lyric_structure[i]['name'],
315
+ 'start': detected['start'],
316
+ 'end': detected['end'],
317
+ 'lyrics': lyric_structure[i].get('lyrics', '')
318
+ })
319
+ else:
320
+ merged.append(detected)
321
+
322
+ return merged
323
+
324
+
325
+ def detect_precise_beats(audio_file):
326
+ """
327
+ Detect beats, downbeats, and tempo map with high precision
328
+
329
+ Returns:
330
+ (beats, downbeats, tempo_map)
331
+ """
332
+
333
+ if not LIBROSA_AVAILABLE:
334
+ return None, None, None
335
+
336
+ try:
337
+ # Load audio
338
+ y, sr = librosa.load(str(audio_file), sr=22050)
339
+ duration = len(y) / sr
340
+
341
+ # Detect all beats
342
+ tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr, units='frames')
343
+ beat_times = librosa.frames_to_time(beat_frames, sr=sr)
344
+
345
+ # Estimate downbeats (every 4 beats assuming 4/4)
346
+ downbeat_times = beat_times[::4]
347
+
348
+ # Create detailed tempo map (every 2 seconds)
349
+ tempo_map = []
350
+ window_size = 2.0 # seconds
351
+
352
+ for start_time in np.arange(0, duration, window_size):
353
+ end_time = min(start_time + window_size, duration)
354
+ start_sample = int(start_time * sr)
355
+ end_sample = int(end_time * sr)
356
+
357
+ if end_sample > start_sample:
358
+ segment = y[start_sample:end_sample]
359
+ try:
360
+ segment_tempo, _ = librosa.beat.beat_track(y=segment, sr=sr)
361
+ tempo_map.append({
362
+ 'time': float(start_time),
363
+ 'bpm': float(segment_tempo)
364
+ })
365
+ except:
366
+ # Use global tempo if segment fails
367
+ tempo_map.append({
368
+ 'time': float(start_time),
369
+ 'bpm': float(tempo)
370
+ })
371
+
372
+ # Smooth tempo map to reduce noise
373
+ tempo_map = smooth_tempo_map(tempo_map)
374
+
375
+ # Check if tempo is stable enough to treat as constant
376
+ tempos = [tm['bpm'] for tm in tempo_map]
377
+ tempo_std = np.std(tempos)
378
+
379
+ if tempo_std < 3: # Less than 3 BPM variation
380
+ tempo_map = [tempo_map[0]] # Use constant tempo
381
+
382
+ return beat_times, downbeat_times, tempo_map
383
+
384
+ except Exception as e:
385
+ print(f" [WARN] Beat detection failed: {e}")
386
+ return None, None, None
387
+
388
+
389
+ def smooth_tempo_map(tempo_map, window=3):
390
+ """Smooth tempo map with moving average"""
391
+
392
+ if len(tempo_map) <= window:
393
+ return tempo_map
394
+
395
+ smoothed = []
396
+ for i, tm in enumerate(tempo_map):
397
+ start = max(0, i - window // 2)
398
+ end = min(len(tempo_map), i + window // 2 + 1)
399
+
400
+ avg_tempo = np.mean([tempo_map[j]['bpm'] for j in range(start, end)])
401
+
402
+ smoothed.append({
403
+ 'time': tm['time'],
404
+ 'bpm': float(avg_tempo)
405
+ })
406
+
407
+ return smoothed
408
+
409
+
410
+ def get_chord_color(chord, key):
411
+ """Get color for chord based on function in key"""
412
+
413
+ # Simplified: just use default white for now
414
+ # Could be enhanced to detect I, IV, V based on key
415
+ return COLORS['default']
416
+
417
+
418
+ def get_section_color(section_name):
419
+ """Get color for section type"""
420
+
421
+ name_lower = section_name.lower()
422
+
423
+ for section_type, color in COLORS.items():
424
+ if section_type in name_lower:
425
+ return color
426
+
427
+ return COLORS['intro'] # Default
428
+
429
+
430
+ def get_track_color(track_name):
431
+ """Get color for track based on instrument"""
432
+
433
+ name_lower = track_name.lower()
434
+
435
+ if 'vocal' in name_lower or 'voice' in name_lower:
436
+ return 16576 # Blue
437
+ elif 'drum' in name_lower:
438
+ return 33488896 # Red
439
+ elif 'bass' in name_lower:
440
+ return 25387775 # Purple
441
+ elif 'guitar' in name_lower:
442
+ return 8454143 # Green
443
+ elif 'piano' in name_lower or 'key' in name_lower:
444
+ return 16776960 # Yellow
445
+ else:
446
+ return 8421504 # Gray
447
+
448
+
449
+ def create_empty_project(song_name, tempo):
450
+ """Create minimal project if no stems found"""
451
+
452
+ return f"""<REAPER_PROJECT 0.1 "6.0/linux-x86_64" 1234567890
453
+ TEMPO {tempo} 4 4
454
+ >
455
+ """
456
+
457
+
458
+ if __name__ == "__main__":
459
+ print("Reaper Ultimate Integration Module")
460
+ print("Features:")
461
+ print(" • Precise tempo map with fluctuation detection")
462
+ print(" • Chord change markers throughout track")
463
+ print(" • Region-based song structure")
464
+ print(" • Lyric file integration")
465
+ print(" • Color-coded everything")
modules/stems.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stem Separation Module
3
+ Uses Demucs for audio source separation
4
+ """
5
+
6
+ import subprocess
7
+ from pathlib import Path
8
+ import sys
9
+
10
+
11
+ def separate_stems(input_file, output_dir, two_stem=True):
12
+ """
13
+ Separate audio into stems using Demucs (CPU mode)
14
+
15
+ Args:
16
+ input_file: Path to input audio file
17
+ output_dir: Directory to save stems
18
+ two_stem: If True, only separate vocals/instrumental (faster)
19
+ If False, separate into 6 stems (slower)
20
+
21
+ Returns:
22
+ Path to directory containing stems
23
+ """
24
+
25
+ output_path = Path(output_dir)
26
+ output_path.mkdir(parents=True, exist_ok=True)
27
+
28
+ if two_stem:
29
+ print(f" Using 2-stem mode (vocal + instrumental)")
30
+ print(f" Estimated time: 2-4 minutes")
31
+
32
+ cmd = [
33
+ 'demucs',
34
+ '--two-stems=vocals',
35
+ '-o', str(output_path),
36
+ '--mp3',
37
+ '--mp3-bitrate=320',
38
+ '--jobs=0', # Use all CPU cores
39
+ input_file
40
+ ]
41
+ else:
42
+ print(f" Using 6-stem mode (vocal, drums, bass, guitar, keys, other)")
43
+ print(f" Estimated time: 8-12 minutes")
44
+
45
+ cmd = [
46
+ 'demucs',
47
+ '-n', 'htdemucs_6s',
48
+ '-o', str(output_path),
49
+ '--mp3',
50
+ '--mp3-bitrate=320',
51
+ '--jobs=0',
52
+ input_file
53
+ ]
54
+
55
+ try:
56
+ # Run Demucs with live output
57
+ process = subprocess.Popen(
58
+ cmd,
59
+ stdout=subprocess.PIPE,
60
+ stderr=subprocess.STDOUT,
61
+ universal_newlines=True,
62
+ bufsize=1
63
+ )
64
+
65
+ # Print progress
66
+ for line in process.stdout:
67
+ line = line.strip()
68
+ if line:
69
+ # Filter for progress info
70
+ if '%' in line or 'Separated' in line or 'Processing' in line:
71
+ print(f" {line}")
72
+
73
+ process.wait()
74
+
75
+ if process.returncode != 0:
76
+ raise Exception(f"Demucs exited with code {process.returncode}")
77
+
78
+ # Locate output directory
79
+ # Demucs structure: output_dir/model_name/track_name/stems
80
+ song_stem = Path(input_file).stem
81
+
82
+ # Try different possible structures
83
+ possible_paths = [
84
+ output_path / 'htdemucs' / song_stem,
85
+ output_path / 'htdemucs_6s' / song_stem,
86
+ output_path / song_stem
87
+ ]
88
+
89
+ for stems_dir in possible_paths:
90
+ if stems_dir.exists():
91
+ # Verify stems are present
92
+ stem_files = list(stems_dir.glob('*.mp3'))
93
+ if stem_files:
94
+ return stems_dir
95
+
96
+ # If we get here, something went wrong
97
+ raise Exception(f"Could not find output stems in {output_path}")
98
+
99
+ except FileNotFoundError:
100
+ print("\n[FAIL] Error: 'demucs' command not found")
101
+ print(" Make sure Demucs is installed:")
102
+ print(" pip install demucs")
103
+ sys.exit(1)
104
+
105
+ except Exception as e:
106
+ raise Exception(f"Stem separation failed: {str(e)}")
107
+
108
+
109
+ def list_stems(stems_dir):
110
+ """List all stem files in directory"""
111
+ stems_dir = Path(stems_dir)
112
+ return sorted(stems_dir.glob('*.mp3'))
113
+
114
+
115
+ if __name__ == "__main__":
116
+ # Test module
117
+ import sys
118
+
119
+ if len(sys.argv) < 2:
120
+ print("Usage: python stems.py <audio_file>")
121
+ sys.exit(1)
122
+
123
+ test_file = sys.argv[1]
124
+ test_output = "./test_output"
125
+
126
+ print(f"Testing stem separation on: {test_file}")
127
+ result = separate_stems(test_file, test_output, two_stem=True)
128
+
129
+ print(f"\nStems created in: {result}")
130
+ print("\nFiles:")
131
+ for stem in list_stems(result):
132
+ print(f" • {stem.name}")