fluidstudio

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

midi_gen.py (4261B)


      1 """MIDI generation and FluidSynth rendering for FluidStudio notation."""
      2 
      3 import subprocess
      4 from pathlib import Path
      5 
      6 import mido
      7 
      8 from .parser import GondryEvent, VoiceHeader
      9 
     10 
     11 def gondry_to_midi(events: list[GondryEvent], header: VoiceHeader,
     12                    ticks_per_beat: int = 480) -> mido.MidiFile:
     13     """Convert parsed Gondry events to a MIDI file.
     14 
     15     Args:
     16         events: Parsed events from parse_gondry().
     17         header: Voice header with BPM.
     18         ticks_per_beat: MIDI resolution (480 = standard).
     19     """
     20     mid = mido.MidiFile(ticks_per_beat=ticks_per_beat)
     21     track = mido.MidiTrack()
     22     mid.tracks.append(track)
     23 
     24     # Set tempo
     25     microseconds_per_beat = int(60_000_000 / header.bpm)
     26     track.append(mido.MetaMessage('set_tempo', tempo=microseconds_per_beat, time=0))
     27 
     28     # Track name
     29     track.append(mido.MetaMessage('track_name', name=header.channel_name or header.project, time=0))
     30 
     31     # Convert events to messages with absolute ticks, then delta
     32     messages: list[tuple[int, mido.Message]] = []
     33     legato_on = False
     34 
     35     # Program change for all channels (including drums)
     36     messages.append((0, mido.Message('program_change', program=0, channel=0, time=0)))
     37 
     38     for ev in events:
     39         # Legato state change
     40         if ev.legato is not None:
     41             legato_on = ev.legato
     42             tick = int(ev.beat_position * ticks_per_beat)
     43             cc_val = 127 if legato_on else 0
     44             messages.append((tick, mido.Message('control_change', control=65,
     45                                                 value=cc_val, time=0)))
     46 
     47         if not ev.midi_notes:
     48             continue
     49 
     50         start_tick = int(ev.beat_position * ticks_per_beat)
     51         duration_tick = max(1, int(ev.duration_beats * ticks_per_beat))
     52         end_tick = start_tick + duration_tick
     53 
     54         # When legato is on, delay note_off by 1 tick so the next note_on
     55         # fires before this note_off — creating the overlap FluidSynth needs
     56         if legato_on:
     57             end_tick += 1
     58 
     59         for note in ev.midi_notes:
     60             messages.append((start_tick, mido.Message('note_on', note=note,
     61                                                       velocity=ev.velocity, channel=0, time=0)))
     62             messages.append((end_tick, mido.Message('note_off', note=note,
     63                                                     velocity=0, channel=0, time=0)))
     64 
     65     # Sort by tick, note_off before note_on at same tick (overlap for legato)
     66     messages.sort(key=lambda m: (m[0], m[1].type != 'note_off'))
     67 
     68     # Convert to delta time
     69     last_tick = 0
     70     for tick, msg in messages:
     71         msg.time = tick - last_tick
     72         last_tick = tick
     73         track.append(msg)
     74 
     75     return mid
     76 
     77 
     78 def render_midi(events: list[GondryEvent], header: VoiceHeader,
     79                 output_path: str | Path, ticks_per_beat: int = 480):
     80     """Write events to a .mid file."""
     81     mid = gondry_to_midi(events, header, ticks_per_beat)
     82     mid.save(str(output_path))
     83 
     84 
     85 def render_wav(midi_path: str | Path, sf2_path: str | Path,
     86                output_path: str | Path, sample_rate: int = 44100,
     87                reverb: bool = False, chorus: bool = False,
     88                duration: float | None = None):
     89     """Render MIDI to WAV using FluidSynth CLI.
     90 
     91     Args:
     92         duration: Trim output to exact seconds (beat-accurate stems).
     93     """
     94     cmd = [
     95         "fluidsynth",
     96         "-ni",
     97     ]
     98     if not reverb:
     99         cmd += ["-o", "synth.reverb.active=no"]
    100     if not chorus:
    101         cmd += ["-o", "synth.chorus.active=no"]
    102     cmd += [
    103         str(sf2_path),
    104         str(midi_path),
    105         "-F", str(output_path),
    106         "-r", str(sample_rate),
    107     ]
    108     subprocess.run(cmd, check=True, capture_output=True)
    109 
    110     # Trim to exact duration if requested
    111     if duration is not None:
    112         stem_path = Path(output_path)
    113         trimmed = stem_path.with_suffix('.trimmed.wav')
    114         result = subprocess.run([
    115             "ffmpeg", "-y", "-i", str(output_path),
    116             "-af", f"atrim=end={duration}",
    117             "-c:a", "pcm_s16le", str(trimmed)
    118         ], capture_output=True, text=True)
    119         if result.returncode != 0:
    120             raise RuntimeError(f"ffmpeg trim failed for {output_path}: {result.stderr[-500:]}")
    121         trimmed.rename(stem_path)