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
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()
|