MIDI Tools - Tesla Coil MIDI Processing Suite
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

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)