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)