compiler.py (5769B)
1 """FluidStudio project compiler. 2 3 Usage: 4 from gondry.compiler import compile_project 5 compile_project("projects/marimba_demo") 6 7 Or CLI: 8 python -m gondry.compiler projects/marimba_demo 9 """ 10 11 import sys 12 import subprocess 13 import time 14 from datetime import datetime 15 from pathlib import Path 16 17 from .parser import parse_header, parse_gondry, VoiceHeader 18 from .midi_gen import render_midi, render_wav 19 20 21 def compile_project(project_dir: str | Path, sf2_root: str | Path = "asset/soundfonts"): 22 """Compile a gondry project: partition -> stems -> mix.""" 23 project = Path(project_dir) 24 partition_dir = project / "partition" 25 stems_dir = project / "stems" 26 mix_dir = project / "mix" 27 midi_dir = project / "midi" 28 29 stems_dir.mkdir(exist_ok=True) 30 mix_dir.mkdir(exist_ok=True) 31 midi_dir.mkdir(exist_ok=True) 32 33 log_lines = [] 34 35 def log(msg): 36 print(msg) 37 log_lines.append(msg) 38 39 header_path = partition_dir / "header.json" 40 if not header_path.exists(): 41 raise FileNotFoundError(f"No header.json in {partition_dir}") 42 43 header = parse_header(header_path) 44 bpm = header.bpm 45 sec_per_beat = 60.0 / bpm 46 sf2_root = Path(sf2_root) 47 48 log(f"=== FluidStudio Compile === {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") 49 log(f"Project: {project}") 50 log(f"BPM: {bpm} ({sec_per_beat:.4f}s/beat)") 51 log(f"Octave: {header.octave} | Mode: {header.mode}") 52 log(f"Channels: {[ch['name'] for ch in header.channels]}") 53 log("") 54 55 # Find all .flsp files 56 gondry_files = sorted(partition_dir.glob("*.flsp")) 57 if not gondry_files: 58 raise FileNotFoundError(f"No .flsp files in {partition_dir}") 59 60 stem_paths = [] 61 max_beat_total = 0.0 62 63 for gondry_path in gondry_files: 64 voice_name = gondry_path.stem # e.g. "melody" 65 66 # Match voice to channel config 67 channel_config = None 68 for ch in header.channels: 69 if ch["name"] == voice_name: 70 channel_config = ch 71 break 72 73 if channel_config is None: 74 # Try partial match 75 for ch in header.channels: 76 if voice_name.startswith(ch["name"]) or ch["name"].startswith(voice_name): 77 channel_config = ch 78 break 79 80 if channel_config is None: 81 log(f" SKIP {voice_name} — no matching channel in header") 82 continue 83 84 # Parse 85 voice_header = VoiceHeader( 86 project=header.project, 87 bpm=header.bpm, 88 octave=header.octave, 89 mode=header.mode, 90 channels=header.channels, 91 channel_name=voice_name, 92 channel_config=channel_config, 93 ) 94 95 events = parse_gondry(gondry_path, voice_header) 96 if not events: 97 log(f" SKIP {voice_name} — no events") 98 continue 99 100 # Log all events 101 log(f" --- {voice_name} ---") 102 log(f" sf2: {channel_config['sf2']}") 103 log(f" octave: {channel_config.get('octave', header.octave)}") 104 log(f" events ({len(events)}):") 105 for ev in events: 106 if ev.midi_notes: 107 log(f" beat={ev.beat_position:.3f} dur={ev.duration_beats:.3f} notes={ev.midi_notes} vel={ev.velocity}") 108 else: 109 log(f" beat={ev.beat_position:.3f} dur={ev.duration_beats:.3f} rest") 110 111 # Calculate exact duration from last note-off 112 max_beat = max(e.beat_position + e.duration_beats for e in events) 113 max_beat_total = max(max_beat_total, max_beat) 114 duration = max_beat * sec_per_beat 115 116 # Render MIDI 117 midi_path = midi_dir / f"{voice_name}.mid" 118 render_midi(events, voice_header, midi_path) 119 log(f" -> {midi_path} ({duration:.4f}s)") 120 121 # Render WAV (trimmed to exact beat duration) 122 sf2_path = sf2_root / channel_config["sf2"] 123 stem_path = stems_dir / f"{voice_name}.wav" 124 if not sf2_path.exists(): 125 log(f" WARN: SF2 not found at {sf2_path}") 126 127 render_wav(midi_path, sf2_path, stem_path, duration=duration) 128 129 # Verify stem has audio 130 result = subprocess.run( 131 ["ffmpeg", "-i", str(stem_path), "-af", "volumedetect", "-f", "null", "-"], 132 capture_output=True, text=True 133 ) 134 max_vol = "?" 135 for line in result.stderr.splitlines(): 136 if "max_volume" in line: 137 max_vol = line.split(":")[-1].strip() 138 log(f" -> {stem_path} ({duration:.4f}s, peak={max_vol})") 139 log("") 140 141 stem_paths.append(stem_path) 142 143 # Mix all stems 144 if stem_paths: 145 total_duration = max_beat_total * sec_per_beat 146 inputs = [] 147 filter_parts = [] 148 for i, sp in enumerate(stem_paths): 149 inputs += ["-i", str(sp)] 150 filter_parts.append(f"[{i}:a]") 151 152 mix_path = mix_dir / "mix.wav" 153 filter_str = "".join(filter_parts) + f"amix=inputs={len(stem_paths)}:duration=first[mix]" 154 cmd = ["ffmpeg", "-y"] + inputs + [ 155 "-filter_complex", filter_str, 156 "-map", "[mix]", 157 str(mix_path), 158 ] 159 r = subprocess.run(cmd, capture_output=True) 160 if r.returncode != 0: 161 log(f" MIX ERROR: ffmpeg failed") 162 log(f" stderr: {r.stderr.decode()[-500:]}") 163 else: 164 log(f" mix: {total_duration:.4f}s -> {mix_path}") 165 166 # Write log file 167 log_path = project / "compile.log" 168 log_path.write_text("\n".join(log_lines) + "\n") 169 log(f" log -> {log_path}") 170 171 return project 172 173 174 if __name__ == "__main__": 175 if len(sys.argv) < 2: 176 print("Usage: python -m gondry.compiler <project_dir>") 177 sys.exit(1) 178 compile_project(sys.argv[1])