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