fluidstudio

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

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])