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.
275 lines
11 KiB
275 lines
11 KiB
import mido
|
|
import sys
|
|
import os
|
|
import argparse
|
|
import json
|
|
|
|
# General MIDI Program Numbers mapped to Instrument Names
|
|
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):
|
|
try:
|
|
midi = mido.MidiFile(file_path)
|
|
except IOError:
|
|
print(f"Error: Could not open MIDI file: {file_path}")
|
|
return False
|
|
except mido.KeySignatureError:
|
|
print(f"Error: Invalid MIDI file: {file_path}")
|
|
return False
|
|
|
|
analysis = {
|
|
"song_title": os.path.splitext(os.path.basename(file_path))[0],
|
|
"tempo": {
|
|
"min_bpm": None,
|
|
"max_bpm": None
|
|
},
|
|
"pitch_bend": {
|
|
"min_semitones": None,
|
|
"max_semitones": None
|
|
},
|
|
"tracks": [],
|
|
"notes": "",
|
|
"song_offset": 0
|
|
}
|
|
|
|
# Collect tempo changes
|
|
tempo_changes = collect_tempo_changes(midi)
|
|
tempos = [bpm for (_, bpm) in tempo_changes]
|
|
analysis["tempo"]["min_bpm"] = min(tempos)
|
|
analysis["tempo"]["max_bpm"] = max(tempos)
|
|
|
|
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
|
|
|
|
global_pitch_bends = []
|
|
|
|
for track in midi.tracks:
|
|
if not has_musical_messages(track):
|
|
continue # Skip non-musical tracks
|
|
|
|
musical_track_count += 1
|
|
track_info = {
|
|
"track_name": f"Track {musical_track_count}",
|
|
"Channel Assignment": [],
|
|
"Pitch Bend Sensitivity": {},
|
|
"Max Note Velocity": "N/A",
|
|
"Min Note Velocity": "N/A",
|
|
"Uses Program Change": False
|
|
}
|
|
|
|
program_changes = []
|
|
pitch_bends_semitones = []
|
|
|
|
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_info["track_name"] = msg.name
|
|
elif hasattr(msg, 'channel'):
|
|
channel = msg.channel # 0-15
|
|
if (channel + 1) not in track_info["Channel Assignment"]:
|
|
track_info["Channel Assignment"].append(channel + 1) # Channels represented as 1-16
|
|
|
|
if msg.type == 'note_on' and msg.velocity > 0:
|
|
velocity = msg.velocity
|
|
if track_info["Max Note Velocity"] == "N/A" or velocity > track_info["Max Note Velocity"]:
|
|
track_info["Max Note Velocity"] = velocity
|
|
if track_info["Min Note Velocity"] == "N/A" or velocity < track_info["Min Note Velocity"]:
|
|
track_info["Min Note Velocity"] = velocity
|
|
elif msg.type == 'program_change':
|
|
track_info["Uses Program Change"] = True
|
|
program_changes.append({
|
|
"program_number": msg.program,
|
|
"instrument_name": get_instrument_name(msg.program),
|
|
"tick": 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)
|
|
global_pitch_bends.append(semitones)
|
|
|
|
# Update Pitch Bend Sensitivity for channels used in this track
|
|
for channel in track_info["Channel Assignment"]:
|
|
sensitivity = channel_pitch_bend_range[channel - 1]
|
|
track_info["Pitch Bend Sensitivity"][f"Channel {channel}"] = round(sensitivity, 2)
|
|
|
|
# Analyze pitch bends
|
|
if pitch_bends_semitones:
|
|
track_info["pitch_bend"] = {
|
|
"min_semitones": round(min(pitch_bends_semitones), 2),
|
|
"max_semitones": round(max(pitch_bends_semitones), 2)
|
|
}
|
|
else:
|
|
track_info["pitch_bend"] = {
|
|
"min_semitones": 0.0,
|
|
"max_semitones": 0.0
|
|
}
|
|
|
|
# Add program changes if any
|
|
if program_changes:
|
|
track_info["Program Changes"] = program_changes
|
|
|
|
analysis["tracks"].append(track_info)
|
|
|
|
# Analyze global pitch bends
|
|
if global_pitch_bends:
|
|
analysis["pitch_bend"]["min_semitones"] = round(min(global_pitch_bends), 2)
|
|
analysis["pitch_bend"]["max_semitones"] = round(max(global_pitch_bends), 2)
|
|
else:
|
|
analysis["pitch_bend"]["min_semitones"] = 0.0
|
|
analysis["pitch_bend"]["max_semitones"] = 0.0
|
|
|
|
if musical_track_count == 0:
|
|
print(f"Warning: No musical tracks found in MIDI file: {file_path}")
|
|
return False
|
|
|
|
# Write the analysis to a JSON file
|
|
output_path = os.path.splitext(file_path)[0] + '.json'
|
|
try:
|
|
with open(output_path, 'w', encoding='utf-8') as f:
|
|
json.dump(analysis, f, indent=4, ensure_ascii=False)
|
|
print(f"Analysis complete. Results saved to '{output_path}'")
|
|
return True
|
|
except IOError:
|
|
print(f"Error: Could not write to output file: {output_path}")
|
|
return False
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Analyze a single MIDI file and generate a JSON report."
|
|
)
|
|
parser.add_argument(
|
|
'input_file',
|
|
help="Path to the MIDI file to analyze."
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
input_file = args.input_file
|
|
|
|
if not os.path.isfile(input_file):
|
|
print(f"Error: The provided path is not a file or does not exist: {input_file}")
|
|
sys.exit(1)
|
|
|
|
if not input_file.lower().endswith(('.mid', '.midi')):
|
|
print(f"Error: The provided file is not a MIDI file: {input_file}")
|
|
sys.exit(1)
|
|
|
|
success = analyze_midi(input_file)
|
|
sys.exit(0 if success else 1)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|