fluidstudio

Programmable music notation — write music as text, compile to MIDI + WAV stems + mix
Log | Files | Refs | README

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)