Source code for melody_features.io.midi
import logging
import os
from pathlib import Path
from typing import List, Optional, Union
import pretty_midi
import mido
from mido.midifiles.meta import KeySignatureError
from natsort import natsorted
from ..algorithms.meter_estimation import estimate_meter, meter_to_time_signature
from ..core.representations import Melody, build_midi_sequence_string
from ..utils.warnings import suppress_common_melody_warnings
suppress_common_melody_warnings()
[docs]
def import_midi(midi_file: str) -> dict:
"""Import a MIDI file and return a dictionary with melody data.
Parameters
----------
midi_file : str
Path to the MIDI file
Returns
-------
dict or None
Dictionary containing:
- ID: Filename of the MIDI file
- MIDI Sequence: String representation of the melody
- pitches: List of MIDI pitch values
- starts: List of note start times
- ends: List of note end times
Returns None if the file cannot be imported
"""
logger = logging.getLogger("melody_features")
try:
# Parse the MIDI file
midi_data = pretty_midi.PrettyMIDI(midi_file)
# Get the first instrument with notes
melody_track = None
for instrument in midi_data.instruments:
if len(instrument.notes) > 0:
melody_track = instrument
break
if melody_track is None:
logger.warning(f"No melody track found in {midi_file}")
return None
# Extract note data
pitches = [note.pitch for note in melody_track.notes]
starts = [note.start for note in melody_track.notes]
ends = [note.end for note in melody_track.notes]
midi_sequence = build_midi_sequence_string(pitches, starts, ends)
tempo = extract_tempo_from_midi(midi_data)
tempo_changes = extract_tempo_changes_from_midi(midi_data)
time_sig_info = extract_time_signatures_from_midi(midi_data, starts, ends, pitches)
# Extract key signature information
key_sig_info = extract_key_signatures_from_midi(midi_file)
mid = mido.MidiFile(midi_file)
total_duration = mid.length
return {
"ID": midi_file,
"MIDI Sequence": midi_sequence,
"pitches": pitches,
"starts": starts,
"ends": ends,
"channels": [1] * len(pitches),
"tempo": tempo,
"tempo_changes": tempo_changes,
"time_signature_info": time_sig_info,
"key_signature_info": key_sig_info,
"total_duration": total_duration,
}
except (KeySignatureError, ValueError, IOError) as e:
logger.warning(f"Could not import {midi_file}: {str(e)}")
return None
except Exception as e:
logger.warning(f"Unexpected error importing {midi_file}: {str(e)}")
return None
[docs]
def load_midi(midi_file: Union[str, os.PathLike]) -> Optional[Melody]:
"""Load a single MIDI file as a :class:`~melody_features.core.representations.Melody`.
Parameters
----------
midi_file : str or os.PathLike
Path to the MIDI file
Returns
-------
Melody or None
Loaded melody, or None if import failed
"""
melody_data = import_midi(str(midi_file))
if melody_data is None:
return None
try:
return Melody(melody_data)
except Exception as e:
logging.getLogger("melody_features").warning(
f"Error creating Melody from {midi_file}: {e}"
)
return None
[docs]
def list_midi_files(
directory: Union[str, os.PathLike],
*,
recursive: bool = False,
) -> List[str]:
"""Return sorted paths to `.mid` / `.midi` files in a directory.
Parameters
----------
directory : str or os.PathLike
Folder to scan for MIDI files
recursive : bool, optional
If True, search subdirectories as well (default False)
Returns
-------
list[str]
Naturally sorted absolute paths to MIDI files
Raises
------
FileNotFoundError
If `directory` does not exist or contains no MIDI files
"""
path = Path(directory)
if not path.is_dir():
raise FileNotFoundError(f"Directory not found: {directory}")
if recursive:
midi_paths = list(path.rglob("*.mid")) + list(path.rglob("*.midi"))
else:
midi_paths = list(path.glob("*.mid")) + list(path.glob("*.midi"))
if not midi_paths:
raise FileNotFoundError(f"No MIDI files found in {directory}")
return natsorted(str(p.resolve()) for p in midi_paths)
[docs]
def import_midi_from_directory(
directory: Union[str, os.PathLike],
*,
recursive: bool = False,
) -> List[Melody]:
"""Load all MIDI files in a directory as :class:`~melody_features.core.representations.Melody` objects.
Parameters
----------
directory : str or os.PathLike
Folder containing MIDI files
recursive : bool, optional
If True, include MIDI files in subdirectories (default False)
Returns
-------
list[Melody]
Successfully loaded melodies, in natural sort order.
Files that fail to import are skipped (warnings are logged per file).
"""
logger = logging.getLogger("melody_features")
midi_paths = list_midi_files(directory, recursive=recursive)
melodies: List[Melody] = []
for midi_path in midi_paths:
melody = load_midi(midi_path)
if melody is not None:
melodies.append(melody)
else:
logger.debug(f"Skipped failed import: {midi_path}")
logger.info(f"Loaded {len(melodies)} of {len(midi_paths)} MIDI files from {directory}")
return melodies
[docs]
def extract_time_signatures_from_midi(midi_data: pretty_midi.PrettyMIDI, starts: list[float] = None,
ends: list[float] = None, pitches: list[int] = None) -> dict:
"""Extract time signature information from MIDI data with meter estimation fallback.
Parameters
----------
midi_data : pretty_midi.PrettyMIDI
The MIDI data object
starts : list[float], optional
Note start times for meter estimation fallback
ends : list[float], optional
Note end times for meter estimation fallback
pitches : list[int], optional
MIDI pitch values for optimal meter estimation
Returns
-------
dict
Dictionary containing:
- 'first_time_signature': tuple of (numerator, denominator) for first time sig
- 'all_time_signatures': list of (time, numerator, denominator) for all time sigs
- 'metric_stability': proportion (0.0-1.0) that first time sig comprises of total
- 'is_estimated': bool indicating if meter was estimated vs read from file
"""
time_signatures = midi_data.time_signature_changes
if not time_signatures:
# use meter estimation as fallback
if starts and ends:
estimated_meter = estimate_meter(starts, ends, pitches, use_optimal=True)
estimated_time_sig = meter_to_time_signature(estimated_meter)
else:
raise ValueError("Cannot estimate meter: no note timing data (starts/ends) provided and no time signature found in MIDI file")
return {
'first_time_signature': estimated_time_sig,
'all_time_signatures': [(0.0, estimated_time_sig[0], estimated_time_sig[1])],
'metric_stability': 0.0, # No stability when meter is estimated
'is_estimated': True
}
# Time signatures found in MIDI file
first_ts = time_signatures[0]
first_time_sig = (first_ts.numerator, first_ts.denominator)
# Get all time signatures with their times
all_time_sigs = []
for ts in time_signatures:
all_time_sigs.append((ts.time, ts.numerator, ts.denominator))
# Calculate metric stability
if len(time_signatures) == 1:
# Only one time signature = perfectly stable
metric_stability = 1.0
else:
# Calculate proportion of time spent in first time signature
total_duration = midi_data.get_end_time()
if total_duration == 0:
metric_stability = 1.0
else:
# Find when first time signature ends (when next one begins)
if len(time_signatures) > 1:
first_ts_duration = time_signatures[1].time - time_signatures[0].time
else:
first_ts_duration = total_duration
# Calculate what proportion of total time is in first time signature
metric_stability = min(1.0, first_ts_duration / total_duration)
return {
'first_time_signature': first_time_sig,
'all_time_signatures': all_time_sigs,
'metric_stability': metric_stability,
'is_estimated': False
}
[docs]
def extract_tempo_from_midi(midi_data: pretty_midi.PrettyMIDI) -> float:
"""Extract tempo information from a MIDI file.
Parameters
----------
midi_data : pretty_midi.PrettyMIDI
Parsed MIDI data object
Returns
-------
float
Tempo in beats per minute (BPM). Returns 100.0 as fallback if no tempo found.
"""
logger = logging.getLogger("melody_features")
try:
# Try to get tempo changes from the MIDI file
tempo_changes = midi_data.get_tempo_changes()
if len(tempo_changes[0]) > 0:
# Use the first tempo change as the main tempo
tempo = tempo_changes[1][0]
logger.debug(f"Extracted tempo from MIDI: {tempo} BPM")
return tempo
else:
# If no tempo changes found, try using the estimate_tempo method
try:
estimated_tempo = midi_data.estimate_tempo()
if estimated_tempo > 0:
logger.debug(f"Estimated tempo from MIDI: {estimated_tempo} BPM")
return estimated_tempo
except:
pass
# If all else fails, check for a single tempo in the first track
for instrument in midi_data.instruments:
if hasattr(instrument, 'control_changes'):
for control in instrument.control_changes:
if control.number == 0x51: # Tempo change control
# Convert from microseconds per quarter note to BPM
# MIDI tempo is stored in microseconds per quarter note
if hasattr(control, 'value') and control.value > 0:
tempo_bpm = 60000000 / control.value
logger.debug(f"Found tempo in control changes: {tempo_bpm} BPM")
return tempo_bpm
except Exception as e:
logger.warning(f"Could not extract tempo from MIDI, using default: {str(e)}")
# Default fallback tempo
logger.debug("Using default tempo: 100.0 BPM")
return 100.0
[docs]
def extract_tempo_changes_from_midi(midi_data: pretty_midi.PrettyMIDI) -> list[tuple[float, float]]:
"""Extract all tempo changes from a MIDI file.
Parameters
----------
midi_data : pretty_midi.PrettyMIDI
Parsed MIDI data object
Returns
-------
list[tuple[float, float]]
List of (time_in_seconds, tempo_in_bpm) tuples representing tempo changes
"""
logger = logging.getLogger("melody_features")
try:
tempo_changes = midi_data.get_tempo_changes()
if len(tempo_changes[0]) > 0:
# Convert from ticks to seconds
tempo_times_ticks = tempo_changes[0]
tempo_values_bpm = tempo_changes[1]
# Convert tick times to seconds
tempo_times_seconds = []
for tick_time in tempo_times_ticks:
seconds = midi_data.tick_to_time(tick_time)
tempo_times_seconds.append(seconds)
tempo_changes_list = list(zip(tempo_times_seconds, tempo_values_bpm))
logger.debug(f"Extracted {len(tempo_changes_list)} tempo changes from MIDI")
return tempo_changes_list
else:
single_tempo = extract_tempo_from_midi(midi_data)
return [(0.0, single_tempo)]
except Exception as e:
logger.warning(f"Could not extract tempo changes from MIDI: {str(e)}")
# Fallback to single tempo
single_tempo = extract_tempo_from_midi(midi_data)
return [(0.0, single_tempo)]
[docs]
def extract_key_signatures_from_midi(midi_path: str) -> dict:
"""Extract key signature information from MIDI file using mido.
Parameters
----------
midi_path : str
Path to the MIDI file
Returns
-------
dict
Dictionary with these keys:
* ``first_key_signature``: tuple of ``(key_name, mode)`` for the first
key signature, where mode is ``'major'`` or ``'minor'``
* ``all_key_signatures``: list of ``(key_name, mode)`` for all key signatures
* ``has_key_signature``: bool indicating if any key signature was found
* ``fifths``: int representing position on circle of fifths (-7 to 7)
* ``mode``: int (1 for major, -1 for minor)
Notes
-----
Uses mido library to read key signature meta messages.
"""
logger = logging.getLogger("melody_features")
if not os.path.exists(midi_path):
logger.warning(f"MIDI file not found: {midi_path}")
return {
'first_key_signature': None,
'all_key_signatures': [],
'has_key_signature': False,
'fifths': None,
'mode': None
}
try:
mid = mido.MidiFile(midi_path)
fifths_map = {
'C': 0, 'G': 1, 'D': 2, 'A': 3, 'E': 4, 'B': 5,
'F#': 6, 'C#': 7, 'F': -1, 'Bb': -2, 'Eb': -3,
'Ab': -4, 'Db': -5, 'Gb': -6, 'Cb': -7,
# Enharmonic equivalents
'G#': -4,
'D#': -3,
'A#': -2,
'E#': -1,
'B#': 0,
'Fb': 4,
}
key_signatures = []
first_key_name = None
first_mode_str = None
first_fifths = None
first_mode = None
for track in mid.tracks:
for msg in track:
if msg.type == 'key_signature':
key_str = msg.key
# Validate that we have a non-empty key string
if not key_str:
logger.warning(f"Empty key signature found in {midi_path}, skipping")
continue
is_minor = key_str.endswith('m')
mode_str = 'minor' if is_minor else 'major'
mode = -1 if is_minor else 1
root = key_str[:-1] if is_minor else key_str
# Validate that the root note is in our fifths map
if root not in fifths_map:
logger.warning(f"Unknown key root '{root}' in {midi_path}, skipping")
continue
fifths = fifths_map[root]
if first_key_name is None:
first_key_name = key_str
first_mode_str = mode_str
first_fifths = fifths
first_mode = mode
key_signatures.append((key_str, mode_str))
if not key_signatures:
logger.debug(f"No key signature found in MIDI file: {midi_path}")
return {
'first_key_signature': None,
'all_key_signatures': [],
'has_key_signature': False,
'fifths': None,
'mode': None
}
return {
'first_key_signature': (first_key_name, first_mode_str),
'all_key_signatures': key_signatures,
'has_key_signature': True,
'fifths': first_fifths,
'mode': first_mode
}
except Exception as e:
logger.warning(f"Error extracting key signatures from {midi_path}: {str(e)}")
return {
'first_key_signature': None,
'all_key_signatures': [],
'has_key_signature': False,
'fifths': None,
'mode': None
}