Source code for jdxi_editor.midi.soundfont

# sheep

import os
import time

import fluidsynth
import mido
from mido import MidiFile

from jdxi_editor.midi.synth_select import list_and_select_instrument

# Configuration
[docs] HW_PORT_HINT = "Roland JDXi" # adjust if your port name differs
[docs] SF2_PATH = os.path.expanduser("~/SoundFonts/FluidR3_GM.sf2")
# SF2_PATH = os.path.expanduser("~/SoundFonts/Guitar/Guitar.sf2")
[docs] MIDI_FILE_PATH = "tests/sheep.mid" # Test file with tempo changes
[docs] def find_hw_output_name(prefer_hw=True): outs = mido.get_output_names() for name in outs: if HW_PORT_HINT in name: return name return None
[docs] def open_hw_output(): port_name = find_hw_output_name(True) if port_name: print(f"[INFO] Opening hardware MIDI output: {port_name}") return mido.open_output(port_name) print("[INFO] Hardware MIDI output not found. Will use FluidSynth.") return None
[docs] def setup_fluidsynth(sf2_path): if not os.path.exists(sf2_path): raise FileNotFoundError(f"SoundFont not found at {sf2_path}") fs = fluidsynth.Synth() # On macOS, default audio driver is typically CoreAudio; pyFluidSynth uses PortAudio by default on some builds. # The "driver" parameter is optional; omit if you encounter issues. try: fs.start(driver=None) # let Fluidsynth pick a sensible default except TypeError: fs.start() # compatibility fallback # Load the SoundFont first fs.sfload(sf2_path) print(f"[INFO] FluidSynth started with SF2: {sf2_path}") # Now let user select instrument try: list_and_select_instrument(fs) except Exception as e: print(f"[WARN] Could not select instrument: {e}") # Fallback to default program fs.program_select(0, 0, 0, 0) # channel 0, bank 0, preset 0 return fs
[docs] def midi_to_events(in_port, sink_send, use_sw, fs=None): # Forward messages from the input port to the sink print("[INFO] Starting MIDI routing. Press Ctrl+C to exit.") try: for msg in in_port: if use_sw: # Translate to FluidSynth if msg.type == "note_on" and msg.velocity > 0: fs.noteon(0, msg.NOTE, msg.velocity) elif (msg.type == "note_off") or ( msg.type == "note_on" and msg.velocity == 0 ): fs.noteoff(0, msg.NOTE) elif msg.type == "control_change": fs.CC(0, msg.control, msg.STATUS) elif msg.type == "program_change": fs.program_change(0, msg.program) # You can extend with aftertouch, pitchwheel, etc. else: sink_send.send(msg) except KeyboardInterrupt: print("[INFO] Stopped by user.") finally: in_port.close()
[docs] def ticks_to_seconds(ticks: int, tempo: int, ticks_per_beat: int) -> float: """ Convert MIDI ticks to seconds using the same formula as the main player. :param ticks: int :param tempo: int (μs per quarter note) :param ticks_per_beat: int :return: float """ return (tempo / 1_000_000.0) * (ticks / ticks_per_beat)
[docs] def get_total_duration_in_seconds(midi_file): """ Calculate the correct duration accounting for tempo changes. Uses the same approach as the main player. """ ticks_per_beat = midi_file.ticks_per_beat current_tempo = 500_000 # default: 120 BPM time_seconds = 0 last_tick = 0 # Collect all events with absolute ticks events = [] for track in midi_file.tracks: abs_tick = 0 for msg in track: abs_tick += msg.time events.append((abs_tick, msg)) # Sort all events by tick events.sort(key=lambda x: x[0]) for abs_tick, msg in events: delta_ticks = abs_tick - last_tick time_seconds += (current_tempo / 1_000_000) * (delta_ticks / ticks_per_beat) last_tick = abs_tick if msg.type == "set_tempo": current_tempo = msg.tempo return time_seconds
[docs] def play_midi_with_tempo_handling(mid, fs, use_sw): """Play MIDI file with proper tempo change handling using the main player approach""" # Collect all events with absolute ticks and tempo events = [] current_tempo = 500_000 # Default tempo (120 BPM) ticks_per_beat = mid.ticks_per_beat for track in mid.tracks: abs_tick = 0 for msg in track: abs_tick += msg.time events.append((abs_tick, msg, current_tempo)) # Update tempo when we encounter a tempo change if msg.type == "set_tempo": current_tempo = msg.tempo # Sort events by tick events.sort(key=lambda x: x[0]) # Play messages with proper timing start_time = time.time() print(f"[INFO] Starting playback with {mido.tempo2bpm(500_000):.1f} BPM") for abs_tick, msg, msg_tempo in events: # Calculate when this message should be played using its tempo msg_time_sec = ticks_to_seconds(abs_tick, msg_tempo, ticks_per_beat) target_time = start_time + msg_time_sec # Wait until it's time to play this message while time.time() < target_time: time.sleep(0.001) # Small sleep to avoid busy waiting # Handle different message types if use_sw and fs: if msg.type == "set_tempo": bpm = mido.tempo2bpm(msg.tempo) print(f"[INFO] Tempo change to {bpm:.1f} BPM at {msg_time_sec:.2f}s") elif msg.type == "note_on" and msg.velocity > 0: fs.noteon(0, msg.NOTE, msg.velocity) elif (msg.type == "note_off") or ( msg.type == "note_on" and msg.velocity == 0 ): fs.noteoff(0, msg.NOTE) elif msg.type == "control_change": fs.CC(0, msg.control, msg.STATUS) elif msg.type == "program_change": fs.program_change(0, msg.program) elif msg.type == "time_signature": print( f"[INFO] Time signature: {msg.numerator}/{msg.denominator} at {msg_time_sec:.2f}s" )
[docs] def main(): # 1) Try hardware first hw_out = open_hw_output() use_sw = False fs = None if hw_out is None: # 2) Fallback to FluidSynth use_sw = True fs = setup_fluidsynth(SF2_PATH) # 3) Prepare an input source # If a MIDI file is provided, use it; else, you can switch to live input. if os.path.exists(MIDI_FILE_PATH): print(f"[INFO] Playing MIDI file: {MIDI_FILE_PATH}") mid = MidiFile(MIDI_FILE_PATH) # Calculate correct duration using the main player approach correct_duration = get_total_duration_in_seconds(mid) print(f"[INFO] Correct file duration: {correct_duration:.2f} seconds") print(f"[INFO] Mido calculated duration: {mid.length:.2f} seconds") print(f"[INFO] Difference: {abs(correct_duration - mid.length):.2f} seconds") # Play with proper tempo handling play_midi_with_tempo_handling(mid, fs, use_sw) print("[INFO] MIDI file playback finished.") else: # 4) Live MIDI input (demo: waits for input) input_ports = mido.get_input_names() if not input_ports: print("[WARN] No MIDI input sources found. Exiting.") return in_port_name = input_ports[0] print(f"[INFO] Opening MIDI input: {in_port_name}") in_port = mido.open_input(in_port_name) midi_to_events(in_port, hw_out, use_sw, fs) # Cleanup if hw_out: hw_out.close() if fs: fs.delete() print("[INFO] Exited cleanly.")
if __name__ == "__main__": main()