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.
 
 
 
 

306 lines
13 KiB

import mido
import sys
import os
import argparse
# General MIDI Program Numbers mapped to Instrument Names
# Program numbers in MIDI are 0-based (0-127)
GENERAL_MIDI_PROGRAMS = [
"Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", "Honky-tonk Piano",
"Electric Piano 1", "Electric Piano 2", "Harpsichord", "Clavinet",
"Celesta", "Glockenspiel", "Music Box", "Vibraphone",
"Marimba", "Xylophone", "Tubular Bells", "Dulcimer",
"Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ",
"Reed Organ", "Accordion", "Harmonica", "Tango Accordion",
"Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", "Electric Guitar (jazz)", "Electric Guitar (clean)",
"Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", "Guitar harmonics",
"Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", "Fretless Bass",
"Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2",
"Violin", "Viola", "Cello", "Contrabass",
"Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani",
"String Ensemble 1", "String Ensemble 2", "SynthStrings 1", "SynthStrings 2",
"Choir Aahs", "Voice Oohs", "Synth Choir", "Orchestra Hit",
"Trumpet", "Trombone", "Tuba", "Muted Trumpet",
"French Horn", "Brass Section", "SynthBrass 1", "SynthBrass 2",
"Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax",
"Oboe", "English Horn", "Bassoon", "Clarinet",
"Piccolo", "Flute", "Recorder", "Pan Flute",
"Blown Bottle", "Shakuhachi", "Whistle", "Ocarina",
"Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", "Lead 4 (chiff)",
"Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass + lead)",
"Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)",
"Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)",
"FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)",
"FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)",
"Sitar", "Banjo", "Shamisen", "Koto",
"Kalimba", "Bag pipe", "Fiddle", "Shanai",
"Tinkle Bell", "Agogo", "Steel Drums", "Woodblock",
"Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal",
"Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet",
"Telephone Ring", "Helicopter", "Applause", "Gunshot"
]
def get_instrument_name(program_number):
"""
Maps a MIDI program number to its instrument name.
MIDI program numbers are 0-based.
"""
if 0 <= program_number < len(GENERAL_MIDI_PROGRAMS):
return GENERAL_MIDI_PROGRAMS[program_number]
else:
return f"Unknown Program ({program_number})"
def has_musical_messages(track):
"""
Determines if a MIDI track contains any musical messages.
Returns True if the track has at least one musical message, False otherwise.
"""
musical_types = {
'note_on', 'note_off', 'program_change', 'control_change',
'pitchwheel', 'aftertouch', 'polyphonic_key_pressure'
}
for msg in track:
if msg.type in musical_types:
return True
return False
def collect_tempo_changes(midi):
"""
Collects all tempo changes from all tracks in the MIDI file.
Returns a list of tuples: (absolute_tick_time, bpm)
"""
tempo_changes = []
ticks_per_beat = midi.ticks_per_beat
DEFAULT_TEMPO = 500000 # Default tempo (microseconds per beat) is 120 BPM
for track in midi.tracks:
absolute_time = 0
for msg in track:
absolute_time += msg.time
if msg.type == 'set_tempo':
bpm = mido.tempo2bpm(msg.tempo)
tempo_changes.append((absolute_time, bpm))
# If no tempo changes found, assume default tempo
if not tempo_changes:
tempo_changes.append((0, mido.tempo2bpm(DEFAULT_TEMPO)))
# Sort tempo changes by tick time
tempo_changes.sort(key=lambda x: x[0])
return tempo_changes
def analyze_midi(file_path, output_path):
try:
midi = mido.MidiFile(file_path)
except IOError:
print(f" ❌ Could not open MIDI file: {file_path}")
return
except mido.KeySignatureError:
print(f" ❌ Invalid MIDI file: {file_path}")
return
analysis = []
# Collect tempo changes
tempo_changes = collect_tempo_changes(midi)
tempos = [bpm for (_, bpm) in tempo_changes]
min_tempo = min(tempos)
max_tempo = max(tempos)
# Prepare tempo data for output
if len(tempo_changes) == 1:
tempi = tempo_changes[0][1] # Assign tempo if only one
tempo_info = (
f"Tempo Data:\n"
f" Tempo: {tempi:.2f} BPM\n"
)
else:
tempo_info = (
f"Tempo Data:\n"
f" Min Tempo: {min_tempo:.2f} BPM\n"
f" Max Tempo: {max_tempo:.2f} BPM\n"
f" Tempo Changes:\n"
)
for idx, (tick, bpm) in enumerate(tempo_changes, start=1):
tempo_info += f" {idx}. {bpm:.2f} BPM at tick {tick}\n"
analysis.append(tempo_info)
musical_track_count = 0 # To number only musical tracks
# Initialize per channel RPN state and pitch bend range
channel_rpn_state = {channel: {'selected_rpn_msb': None, 'selected_rpn_lsb': None, 'rpn_selected': None} for channel in range(16)}
channel_pitch_bend_range = {channel: 2 for channel in range(16)} # Default to ±2 semitones
for i, track in enumerate(midi.tracks):
if not has_musical_messages(track):
continue # Skip non-musical tracks
musical_track_count += 1
track_name = f"Track {musical_track_count}" # Number musical tracks sequentially
channels_used = set()
max_velocity = None
min_velocity = None
program_changes = [] # List to store program change events
pitch_bends_semitones = [] # List to store pitch bend semitone shifts
absolute_time = 0 # To keep track of the absolute time in ticks
for msg in track:
absolute_time += msg.time # Accumulate delta times to get absolute time
if msg.type == 'track_name':
track_name = msg.name
elif hasattr(msg, 'channel'):
channel = msg.channel # 0-15
channels_used.add(channel + 1) # Channels are 0-15 in mido, represent as 1-16
if msg.type == 'note_on' and msg.velocity > 0:
velocity = msg.velocity
if max_velocity is None or velocity > max_velocity:
max_velocity = velocity
if min_velocity is None or velocity < min_velocity:
min_velocity = velocity
elif msg.type == 'program_change':
# Store program number and the absolute time it occurs
program_changes.append((msg.program, absolute_time))
elif msg.type == 'control_change':
# Handle RPN messages for Pitch Bend Sensitivity
if msg.control == 101:
# Set RPN MSB
channel_rpn_state[channel]['selected_rpn_msb'] = msg.value
if msg.value != 0:
channel_rpn_state[channel]['rpn_selected'] = None
elif msg.control == 100:
# Set RPN LSB
channel_rpn_state[channel]['selected_rpn_lsb'] = msg.value
# Check if RPN 0 (Pitch Bend Sensitivity) is selected
if (channel_rpn_state[channel]['selected_rpn_msb'] == 0 and
channel_rpn_state[channel]['selected_rpn_lsb'] == 0):
channel_rpn_state[channel]['rpn_selected'] = 'pitch_bend_range'
else:
channel_rpn_state[channel]['rpn_selected'] = None
elif msg.control == 6:
# Data Entry MSB
if channel_rpn_state[channel].get('rpn_selected') == 'pitch_bend_range':
# Set pitch bend range in semitones (integer part)
channel_pitch_bend_range[channel] = msg.value
elif msg.control == 38:
# Data Entry LSB
# Currently not handling fractional semitones
pass
elif msg.type == 'pitchwheel':
# Calculate semitone shift based on current pitch bend range
current_range = channel_pitch_bend_range[channel]
semitones = (msg.pitch / 8192) * current_range
pitch_bends_semitones.append(semitones)
channels_used = sorted(channels_used)
channels_str = ', '.join(map(str, channels_used)) if channels_used else 'None'
has_program_change = 'Yes' if program_changes else 'No'
max_velocity_str = str(max_velocity) if max_velocity is not None else 'N/A'
min_velocity_str = str(min_velocity) if min_velocity is not None else 'N/A'
# Analyze pitch bends
if pitch_bends_semitones:
min_pitch_bend = min(pitch_bends_semitones)
max_pitch_bend = max(pitch_bends_semitones)
pitch_shift_info = (
f" Min Pitch Bend: {min_pitch_bend:.2f} semitones\n"
f" Max Pitch Bend: {max_pitch_bend:.2f} semitones\n"
)
else:
pitch_shift_info = (
f" Min Pitch Bend: N/A\n"
f" Max Pitch Bend: N/A\n"
)
# Gather Pitch Bend Sensitivity per channel used in this track
if channels_used:
pitch_bend_sensitivity_info = " Pitch Bend Sensitivity:\n"
for channel in channels_used:
sensitivity = channel_pitch_bend_range[channel - 1]
pitch_bend_sensitivity_info += f" Channel {channel}: {sensitivity:.2f} semitones\n"
else:
pitch_bend_sensitivity_info = " Pitch Bend Sensitivity: N/A\n"
track_info = (
f"{track_name}:\n"
f" Channels Used: {channels_str}\n"
f"{pitch_bend_sensitivity_info}"
f" Max Note Velocity: {max_velocity_str}\n"
f" Min Note Velocity: {min_velocity_str}\n"
f" Uses Program Change: {has_program_change}\n"
f"{pitch_shift_info}"
)
# If there are program changes, add their details
if program_changes:
track_info += f" Program Changes:\n"
for idx, (program, time) in enumerate(program_changes, start=1):
instrument_name = get_instrument_name(program)
track_info += f" {idx}. Program {program} ({instrument_name}) at tick {time}\n"
analysis.append(track_info)
if musical_track_count == 0:
print(f" ⚠️ No musical tracks found in MIDI file: {file_path}")
return
try:
with open(output_path, 'w', encoding='utf-8') as f:
f.write(f"MIDI File Analysis: {os.path.basename(file_path)}\n\n")
for section in analysis:
f.write(section + "\n")
print(f" ✅ Analysis complete. Results saved to '{output_path}'")
except IOError:
print(f" ❌ Could not write to output file: {output_path}")
def process_directory(input_dir, recursive=False):
if recursive:
midi_files = [
os.path.join(dp, f) for dp, dn, filenames in os.walk(input_dir)
for f in filenames if f.lower().endswith(('.mid', '.midi'))
]
else:
midi_files = [
os.path.join(input_dir, f) for f in os.listdir(input_dir)
if os.path.isfile(os.path.join(input_dir, f)) and f.lower().endswith(('.mid', '.midi'))
]
if not midi_files:
print(f"No MIDI files found in directory: {input_dir}")
return
print(f"Found {len(midi_files)} MIDI file(s) in '{input_dir}'{' and its subdirectories' if recursive else ''}.\n")
for midi_file in midi_files:
base, _ = os.path.splitext(midi_file)
output_text = base + '.txt' # Replace extension with .txt
print(f"Analyzing '{midi_file}'...")
analyze_midi(midi_file, output_text)
def main():
parser = argparse.ArgumentParser(
description="Analyze all MIDI files in a directory and generate corresponding text reports."
)
parser.add_argument(
'input_directory',
help="Path to the directory containing MIDI files to analyze."
)
parser.add_argument(
'-r', '--recursive',
action='store_true',
help="Recursively analyze MIDI files in all subdirectories."
)
args = parser.parse_args()
input_dir = args.input_directory
if not os.path.isdir(input_dir):
print(f"Error: The provided path is not a directory or does not exist: {input_dir}")
sys.exit(1)
process_directory(input_dir, recursive=args.recursive)
if __name__ == "__main__":
main()