clips.py (6665B)
1 """FluidStudio clip generator — per-frame CSV for video sync. 2 3 Usage: 4 python -m gondry.clips projects/minipops_demo 5 python -m gondry.clips projects/minipops_demo --fps 30 6 7 Output: one CSV per bar in projects/<song>/clip/ 8 Each CSV: 1 row = 1 frame, columns = instruments, ";" delimiter, multiple notes "," separated. 9 """ 10 11 import csv 12 import sys 13 from pathlib import Path 14 15 from .parser import parse_header, parse_gondry, VoiceHeader, GondryEvent 16 from .fps import bpm_to_fps 17 18 19 def events_to_frames(events: list[GondryEvent], fps: int, bpm: float, 20 total_frames: int) -> list[list[int]]: 21 """Convert events to per-frame note lists. 22 23 Returns: list of length total_frames, each entry is list of MIDI notes active at that frame. 24 """ 25 frames = [[] for _ in range(total_frames)] 26 sec_per_frame = 1.0 / fps 27 28 for ev in events: 29 if not ev.midi_notes: 30 continue 31 32 # Event timing in seconds 33 sec_per_beat = 60.0 / bpm 34 start_sec = ev.beat_position * sec_per_beat 35 end_sec = start_sec + (ev.duration_beats * sec_per_beat) 36 37 # Convert to frame indices 38 start_frame = int(start_sec / sec_per_frame) 39 end_frame = int(end_sec / sec_per_frame) 40 41 # Clamp 42 start_frame = max(0, min(start_frame, total_frames - 1)) 43 end_frame = max(0, min(end_frame, total_frames)) 44 45 for frame_idx in range(start_frame, end_frame): 46 frames[frame_idx].extend(ev.midi_notes) 47 48 return frames 49 50 51 def generate_clips(project_dir: str | Path, fps: int | None = None): 52 """Generate per-frame CSVs for video sync. 53 54 Each bar gets its own CSV with all instruments as columns. 55 If fps not specified, auto-calculated from BPM for clean frame sync. 56 """ 57 project = Path(project_dir) 58 partition_dir = project / "partition" 59 clip_dir = project / "clip" 60 61 clip_dir.mkdir(exist_ok=True) 62 63 header_path = partition_dir / "header.json" 64 if not header_path.exists(): 65 raise FileNotFoundError(f"No header.json in {partition_dir}") 66 67 header = parse_header(header_path) 68 bpm = header.bpm 69 sec_per_beat = 60.0 / bpm 70 71 # Auto-calculate fps if not specified 72 if fps is None: 73 fps_info = bpm_to_fps(bpm) 74 fps = int(fps_info["fps"]) 75 print(f"Auto-calculated fps: {fps} (multiplier: {fps_info['multiplier']}x)") 76 else: 77 # Verify clean sync 78 fpm = fps * 60 79 fpb = fpm / bpm 80 if fpb != int(fpb): 81 print(f"WARNING: {fps}fps at {bpm}BPM = {fpb:.2f} frames/beat (not integer)") 82 print(f" Consider: {int(bpm/5)}fps (1x), {int(bpm/5*2)}fps (2x), or {int(bpm/5*3)}fps (3x)") 83 84 # Find all .flsp files 85 gondry_files = sorted(partition_dir.glob("*.flsp")) 86 if not gondry_files: 87 raise FileNotFoundError(f"No .flsp files in {partition_dir}") 88 89 # Parse all voices and collect events 90 voices = {} # name -> list[GondryEvent] 91 for gondry_path in gondry_files: 92 voice_name = gondry_path.stem 93 94 # Match voice to channel config 95 channel_config = None 96 for ch in header.channels: 97 if ch["name"] == voice_name: 98 channel_config = ch 99 break 100 101 if channel_config is None: 102 for ch in header.channels: 103 if voice_name.startswith(ch["name"]) or ch["name"].startswith(voice_name): 104 channel_config = ch 105 break 106 107 if channel_config is None: 108 print(f" SKIP {voice_name} — no matching channel in header") 109 continue 110 111 voice_header = VoiceHeader( 112 project=header.project, 113 bpm=header.bpm, 114 octave=header.octave, 115 mode=header.mode, 116 channels=header.channels, 117 channel_name=voice_name, 118 channel_config=channel_config, 119 ) 120 121 events = parse_gondry(gondry_path, voice_header) 122 voices[voice_name] = events 123 124 if not voices: 125 print("No voices found") 126 return 127 128 # Determine total duration in beats (max across all voices) 129 max_beat = 0.0 130 for events in voices.values(): 131 if events: 132 max_beat = max(max_beat, max(e.beat_position + e.duration_beats for e in events)) 133 134 # Calculate frames 135 total_beats = max_beat 136 frames_per_beat = fps * 60.0 / bpm 137 total_frames = int(total_beats * frames_per_beat) 138 139 print(f"Project: {project}") 140 print(f"BPM: {bpm} | FPS: {fps} | Frames/beat: {frames_per_beat:.2f}") 141 print(f"Total: {total_beats:.1f} beats = {total_frames} frames") 142 print(f"Voices: {list(voices.keys())}") 143 print() 144 145 # Convert each voice to per-frame data 146 voice_frames = {} 147 for name, events in voices.items(): 148 voice_frames[name] = events_to_frames(events, fps, bpm, total_frames) 149 150 # Write one CSV per bar 151 beats_per_bar = 4 # assume 4/4 time 152 frames_per_bar = int(beats_per_bar * frames_per_beat) 153 num_bars = int(total_beats / beats_per_bar) 154 155 for bar_idx in range(num_bars): 156 start_frame = bar_idx * frames_per_bar 157 end_frame = min(start_frame + frames_per_bar, total_frames) 158 159 # Determine which voices have activity in this bar 160 active_voices = [] 161 for name in voices: 162 bar_frames = voice_frames[name][start_frame:end_frame] 163 if any(bar_frames): 164 active_voices.append(name) 165 166 if not active_voices: 167 continue 168 169 clip_path = clip_dir / f"bar_{bar_idx + 1:03d}.csv" 170 171 with open(clip_path, 'w', newline='') as f: 172 writer = csv.writer(f, delimiter=';') 173 174 # Header row 175 writer.writerow(['frame'] + active_voices) 176 177 # Data rows 178 for frame_offset in range(frames_per_bar): 179 frame_idx = start_frame + frame_offset 180 if frame_idx >= total_frames: 181 break 182 183 row = [frame_offset] 184 for name in active_voices: 185 notes = voice_frames[name][frame_idx] 186 row.append(','.join(str(n) for n in notes)) 187 188 writer.writerow(row) 189 190 print(f" {clip_path.name}: {len(active_voices)} voices, {end_frame - start_frame} frames") 191 192 print(f"\n {num_bars} clips written to {clip_dir}") 193 194 195 if __name__ == "__main__": 196 if len(sys.argv) < 2: 197 print("Usage: python -m gondry.clips <project_dir> [--fps N]") 198 sys.exit(1) 199 200 project_dir = sys.argv[1] 201 fps = None 202 203 if '--fps' in sys.argv: 204 idx = sys.argv.index('--fps') 205 fps = int(sys.argv[idx + 1]) 206 207 generate_clips(project_dir, fps)