You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
178 lines
6.0 KiB
178 lines
6.0 KiB
import mido
|
|
from mido import MidiFile, MidiTrack, Message, MetaMessage
|
|
import sys
|
|
import os
|
|
from collections import defaultdict
|
|
|
|
class Note:
|
|
def __init__(self, note, start, end, channel, velocity_on, velocity_off):
|
|
self.note = note
|
|
self.start = start
|
|
self.end = end
|
|
self.channel = channel
|
|
self.velocity_on = velocity_on
|
|
self.velocity_off = velocity_off
|
|
self.voice = None
|
|
|
|
def get_notes_and_events(track):
|
|
"""
|
|
Extract notes with start and end times and collect non-note events from a MIDI track.
|
|
Returns a list of Note objects and a list of non-note events.
|
|
Each non-note event is a tuple (absolute_time, message).
|
|
"""
|
|
notes = []
|
|
ongoing_notes = {}
|
|
non_note_events = []
|
|
absolute_time = 0
|
|
for msg in track:
|
|
absolute_time += msg.time
|
|
if msg.type == 'note_on' and msg.velocity > 0:
|
|
key = (msg.note, msg.channel)
|
|
if key in ongoing_notes:
|
|
print(f"Warning: Note {msg.note} on channel {msg.channel} started without previous note_off.")
|
|
ongoing_notes[key] = (absolute_time, msg.velocity)
|
|
elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0):
|
|
key = (msg.note, msg.channel)
|
|
if key in ongoing_notes:
|
|
start_time, velocity_on = ongoing_notes.pop(key)
|
|
notes.append(Note(msg.note, start_time, absolute_time, msg.channel, velocity_on, msg.velocity))
|
|
else:
|
|
print(f"Warning: Note {msg.note} on channel {msg.channel} ended without a start.")
|
|
else:
|
|
# Collect non-note events
|
|
non_note_events.append((absolute_time, msg.copy(time=0))) # Use time=0 temporarily
|
|
return notes, non_note_events
|
|
|
|
def assign_voices(notes):
|
|
"""
|
|
Assign voices to notes based on overlapping.
|
|
Lower pitch notes get lower voice numbers.
|
|
Returns notes with assigned voice and total number of voices.
|
|
"""
|
|
# Sort notes by start time, then by pitch (ascending)
|
|
notes.sort(key=lambda x: (x.start, x.note))
|
|
|
|
voices = [] # List of end times for each voice
|
|
for note in notes:
|
|
# Find available voices where the current voice's last note ends before the new note starts
|
|
available = [i for i, end in enumerate(voices) if end <= note.start]
|
|
if available:
|
|
# Assign to the lowest available voice
|
|
voice = min(available)
|
|
voices[voice] = note.end
|
|
else:
|
|
# No available voice, create a new one
|
|
voice = len(voices)
|
|
voices.append(note.end)
|
|
note.voice = voice
|
|
return notes, len(voices)
|
|
|
|
def create_track_name(original_name, voice):
|
|
if voice == 0:
|
|
return original_name
|
|
else:
|
|
return f"{original_name}_{voice}"
|
|
|
|
def merge_events(note_events, non_note_events, voice):
|
|
"""
|
|
Merge note events and non-note events for a specific voice.
|
|
Returns a list of messages sorted by absolute time.
|
|
"""
|
|
events = []
|
|
|
|
# Add non-note events
|
|
for abs_time, msg in non_note_events:
|
|
events.append((abs_time, msg))
|
|
|
|
# Add note_on and note_off events for this voice
|
|
for note in note_events:
|
|
if note.voice != voice:
|
|
continue
|
|
# Note on
|
|
events.append((note.start, Message('note_on', note=note.note, velocity=note.velocity_on, channel=note.channel, time=0)))
|
|
# Note off
|
|
events.append((note.end, Message('note_off', note=note.note, velocity=note.velocity_off, channel=note.channel, time=0)))
|
|
|
|
# Sort all events by absolute time
|
|
events.sort(key=lambda x: x[0])
|
|
|
|
# Convert absolute times to delta times
|
|
merged_msgs = []
|
|
prev_time = 0
|
|
for abs_time, msg in events:
|
|
delta = abs_time - prev_time
|
|
msg.time = delta
|
|
merged_msgs.append(msg)
|
|
prev_time = abs_time
|
|
|
|
return merged_msgs
|
|
|
|
def process_track(track, original_track_index):
|
|
"""
|
|
Process a single MIDI track and split overlapping notes into new tracks.
|
|
Preserves non-note messages by copying them to each suffixed track.
|
|
Returns a list of new tracks.
|
|
"""
|
|
# Extract track name
|
|
track_name = f"Track{original_track_index}"
|
|
for msg in track:
|
|
if msg.type == 'track_name':
|
|
track_name = msg.name
|
|
break
|
|
|
|
# Extract notes and non-note events
|
|
notes, non_note_events = get_notes_and_events(track)
|
|
if not notes:
|
|
return [track] # No notes to process
|
|
|
|
# Assign voices
|
|
assigned_notes, num_voices = assign_voices(notes)
|
|
|
|
# Collect notes per voice
|
|
voice_to_notes = defaultdict(list)
|
|
for note in assigned_notes:
|
|
voice_to_notes[note.voice].append(note)
|
|
|
|
# Create new tracks
|
|
new_tracks = []
|
|
for voice in range(num_voices):
|
|
new_track = MidiTrack()
|
|
# Add track name
|
|
new_track.append(MetaMessage('track_name', name=create_track_name(track_name, voice), time=0))
|
|
# Merge and sort events
|
|
merged_msgs = merge_events(voice_to_notes[voice], non_note_events, voice)
|
|
new_track.extend(merged_msgs)
|
|
new_tracks.append(new_track)
|
|
return new_tracks
|
|
|
|
def generate_output_filename(input_file):
|
|
"""
|
|
Generate output filename by inserting '_monofy' before the file extension.
|
|
For example: 'song.mid' -> 'song_monofy.mid'
|
|
"""
|
|
base, ext = os.path.splitext(input_file)
|
|
return f"{base}_monofy{ext}"
|
|
|
|
def main(input_file):
|
|
output_file = generate_output_filename(input_file)
|
|
|
|
mid = MidiFile(input_file)
|
|
new_mid = MidiFile()
|
|
new_mid.ticks_per_beat = mid.ticks_per_beat
|
|
|
|
for i, track in enumerate(mid.tracks):
|
|
new_tracks = process_track(track, i)
|
|
new_mid.tracks.extend(new_tracks)
|
|
|
|
new_mid.save(output_file)
|
|
print(f"Processed MIDI saved to '{output_file}'")
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) != 2:
|
|
print("Usage: python split_midi_tracks.py input.mid")
|
|
else:
|
|
input_file = sys.argv[1]
|
|
if not os.path.isfile(input_file):
|
|
print(f"Input file '{input_file}' does not exist.")
|
|
sys.exit(1)
|
|
main(input_file)
|