""" Ultimate Reaper Project Generator - Precise tempo map with fluctuations - Chord change markers throughout track - Region-based song structure - Lyric file integration for better structure detection - Color-coded markers and regions """ from pathlib import Path import numpy as np import re try: import librosa LIBROSA_AVAILABLE = True except ImportError: LIBROSA_AVAILABLE = False # Color scheme for Reaper (BGR format as integers) COLORS = { # Regions 'intro': 8421504, # Gray 'verse': 16744703, # Light blue 'pre-chorus': 16777011, # Light yellow 'chorus': 8454143, # Green 'bridge': 16744576, # Orange 'solo': 16711935, # Purple 'outro': 8421504, # Gray 'fill': 12632256, # Light gray # Chords - by function 'tonic': 8454143, # Green (I, i) 'subdominant': 16776960, # Yellow (IV, iv) 'dominant': 33488896, # Red (V, V7) 'default': 16777215, # White } def create_reaper_project(song_name, stems_dir, tempo, key, structure=None, chords=None, lyrics_file=None, audio_file=None): """ Generate ultimate Reaper project with full integration Args: song_name: Name of the song stems_dir: Directory containing stem files tempo: Average tempo in BPM key: Musical key structure: Detected song structure chords: List of (timestamp, chord_name) tuples lyrics_file: Optional path to lyric file with structure tags audio_file: Original audio file for beat detection Returns: RPP file content as string """ stems_dir = Path(stems_dir) stem_files = sorted(stems_dir.glob('*.mp3')) if not stem_files: return create_empty_project(song_name, tempo) # Parse lyric file if provided lyric_structure = None if lyrics_file and Path(lyrics_file).exists(): print(" Parsing lyric file for structure...") lyric_structure = parse_lyric_file(lyrics_file) # Merge detected structure with lyric structure if lyric_structure: structure = merge_structures(structure, lyric_structure) # Detect precise beats and tempo map print(" Detecting precise beat grid and tempo map...") audio_to_analyze = audio_file if audio_file else stem_files[0] beats, downbeats, tempo_map = detect_precise_beats(audio_to_analyze) # Start building RPP rpp_lines = [] # === HEADER === rpp_lines.append(' 1: # Variable tempo - use multiple TEMPO markers (Reaper 6.x format) print(f" Creating tempo map with {len(tempo_map)} changes...") # First tempo at project start rpp_lines.append(f' TEMPO {tempo_map[0]["bpm"]:.6f} 4 4') # Add tempo changes as additional TEMPO lines with time position # Format: TEMPO bpm timesig beat_position for i, tm in enumerate(tempo_map[1:], 1): # Calculate beat position from time beat_pos = tm["time"] * tempo_map[0]["bpm"] / 60.0 rpp_lines.append(f' TEMPO {tm["bpm"]:.6f} 4 4 {beat_pos:.6f}') else: # Constant tempo rpp_lines.append(f' TEMPO {tempo:.6f} 4 4') rpp_lines.append(' PLAYRATE 1 0 0.25 4') rpp_lines.append(' CURSOR 0') rpp_lines.append(' ZOOM 100 0 0') rpp_lines.append(' VZOOMEX 1 0') rpp_lines.append(' USE_REC_CFG 0') rpp_lines.append(' RECMODE 1') rpp_lines.append(' SMPTESYNC 0 30 100 40 1000 300 0 0 1 0 0') rpp_lines.append(' LOOP 0') rpp_lines.append(' LOOPGRAN 0 4') rpp_lines.append(' RECORD_PATH "" ""') rpp_lines.append('') # === DOWNBEAT MARKERS (BAR LINES) === if downbeats is not None and len(downbeats) > 0: print(f" Creating {len(downbeats)} bar markers...") for i, beat_time in enumerate(downbeats): bar_number = i + 1 adjusted_time = beat_time + 8.0 # Offset by pre-roll rpp_lines.append(f' MARKER {bar_number} {adjusted_time:.6f} "Bar {bar_number}" 0 0') # === CHORD MARKERS === marker_id = len(downbeats) + 1 if downbeats is not None else 1 if chords and len(chords) > 0: print(f" Creating {len(chords)} chord change markers...") for time, chord in chords: color = get_chord_color(chord, key) adjusted_time = time + 8.0 # Offset by pre-roll rpp_lines.append(f' MARKER {marker_id} {adjusted_time:.6f} "{chord}" 0 {color}') marker_id += 1 rpp_lines.append('') # === REGIONS (SONG STRUCTURE) === # Add count-in region at start print(" Creating count-in region (8 seconds)...") rpp_lines.append(f' MARKER {marker_id} 0.0 "Count-In" 1 {COLORS["intro"]} {{}} 0') marker_id += 1 rpp_lines.append(f' MARKER {marker_id} 8.0 "" 1 {COLORS["intro"]} {{}} 0') marker_id += 1 if structure and len(structure) > 0: print(f" Creating {len(structure)} structural regions...") region_id = 1 for section in structure: section_name = section['name'] start = section['start'] + 8.0 # Offset by pre-roll end = section['end'] + 8.0 color = get_section_color(section_name) # Regions use different format: MARKER with region flag rpp_lines.append( f' MARKER {marker_id} {start:.6f} "{section_name}" 1 {color} {{}} {region_id}' ) marker_id += 1 # Region end rpp_lines.append( f' MARKER {marker_id} {end:.6f} "" 1 {color} {{}} {region_id}' ) marker_id += 1 region_id += 1 rpp_lines.append('') # === TRACKS === track_number = 1 for stem_file in stem_files: stem_name = stem_file.stem.replace('_', ' ').title() color = get_track_color(stem_name) rpp_lines.append(' ') rpp_lines.append(' >') rpp_lines.append(' >') rpp_lines.append('') track_number += 1 # Footer rpp_lines.append('>') return '\n'.join(rpp_lines) def parse_lyric_file(lyrics_path): """ Parse lyric file with structure tags Expected format: [Intro] [Verse 1] Lyrics here... [Chorus] Lyrics here... Returns: List of {'name': str, 'lyrics': str} dicts """ with open(lyrics_path, 'r', encoding='utf-8') as f: content = f.read() # Pattern: [Section Name] sections = [] current_section = None current_lyrics = [] for line in content.split('\n'): # Check for section tag match = re.match(r'\[(.*?)\]', line.strip()) if match: # Save previous section if current_section: sections.append({ 'name': current_section, 'lyrics': '\n'.join(current_lyrics).strip() }) # Start new section current_section = match.group(1) current_lyrics = [] else: # Add to current section if current_section: current_lyrics.append(line) # Save last section if current_section: sections.append({ 'name': current_section, 'lyrics': '\n'.join(current_lyrics).strip() }) return sections def merge_structures(detected_structure, lyric_structure): """ Merge detected structure with lyric file structure Lyric file takes precedence for naming """ if not detected_structure: return lyric_structure if not lyric_structure: return detected_structure # Use detected timestamps but lyric names merged = [] for i, detected in enumerate(detected_structure): if i < len(lyric_structure): # Use lyric name but detected times merged.append({ 'name': lyric_structure[i]['name'], 'start': detected['start'], 'end': detected['end'], 'lyrics': lyric_structure[i].get('lyrics', '') }) else: merged.append(detected) return merged def detect_precise_beats(audio_file): """ Detect beats, downbeats, and tempo map with high precision Returns: (beats, downbeats, tempo_map) """ if not LIBROSA_AVAILABLE: return None, None, None try: # Load audio y, sr = librosa.load(str(audio_file), sr=22050) duration = len(y) / sr # Detect all beats tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr, units='frames') beat_times = librosa.frames_to_time(beat_frames, sr=sr) # Estimate downbeats (every 4 beats assuming 4/4) downbeat_times = beat_times[::4] # Create detailed tempo map (every 2 seconds) tempo_map = [] window_size = 2.0 # seconds for start_time in np.arange(0, duration, window_size): end_time = min(start_time + window_size, duration) start_sample = int(start_time * sr) end_sample = int(end_time * sr) if end_sample > start_sample: segment = y[start_sample:end_sample] try: segment_tempo, _ = librosa.beat.beat_track(y=segment, sr=sr) tempo_map.append({ 'time': float(start_time), 'bpm': float(segment_tempo) }) except: # Use global tempo if segment fails tempo_map.append({ 'time': float(start_time), 'bpm': float(tempo) }) # Smooth tempo map to reduce noise tempo_map = smooth_tempo_map(tempo_map) # Check if tempo is stable enough to treat as constant tempos = [tm['bpm'] for tm in tempo_map] tempo_std = np.std(tempos) if tempo_std < 3: # Less than 3 BPM variation tempo_map = [tempo_map[0]] # Use constant tempo return beat_times, downbeat_times, tempo_map except Exception as e: print(f" [WARN] Beat detection failed: {e}") return None, None, None def smooth_tempo_map(tempo_map, window=3): """Smooth tempo map with moving average""" if len(tempo_map) <= window: return tempo_map smoothed = [] for i, tm in enumerate(tempo_map): start = max(0, i - window // 2) end = min(len(tempo_map), i + window // 2 + 1) avg_tempo = np.mean([tempo_map[j]['bpm'] for j in range(start, end)]) smoothed.append({ 'time': tm['time'], 'bpm': float(avg_tempo) }) return smoothed def get_chord_color(chord, key): """Get color for chord based on function in key""" # Simplified: just use default white for now # Could be enhanced to detect I, IV, V based on key return COLORS['default'] def get_section_color(section_name): """Get color for section type""" name_lower = section_name.lower() for section_type, color in COLORS.items(): if section_type in name_lower: return color return COLORS['intro'] # Default def get_track_color(track_name): """Get color for track based on instrument""" name_lower = track_name.lower() if 'vocal' in name_lower or 'voice' in name_lower: return 16576 # Blue elif 'drum' in name_lower: return 33488896 # Red elif 'bass' in name_lower: return 25387775 # Purple elif 'guitar' in name_lower: return 8454143 # Green elif 'piano' in name_lower or 'key' in name_lower: return 16776960 # Yellow else: return 8421504 # Gray def create_empty_project(song_name, tempo): """Create minimal project if no stems found""" return f""" """ if __name__ == "__main__": print("Reaper Ultimate Integration Module") print("Features:") print(" • Precise tempo map with fluctuation detection") print(" • Chord change markers throughout track") print(" • Region-based song structure") print(" • Lyric file integration") print(" • Color-coded everything")