Source code for melody_features.core.representations

"""
This module contains classes and functions to represent melodies and extract information
from MIDI sequence data.
"""

__author__ = "David Whyatt"
import json
from typing import Optional


[docs] def build_midi_sequence_string( pitches: list[int], starts: list[float], ends: list[float], ) -> str: """Build the legacy `MIDI Sequence` string from parallel note lists.""" return "Note(" + "Note(".join( f"pitch={p}, start={s}, end={e})" for p, s, e in zip(pitches, starts, ends) )
[docs] def build_midi_data( pitches: list[int], starts: list[float], ends: list[float], *, melody_id: str = "", tempo: float = 100.0, time_signature: str = "4/4", tempo_changes: Optional[list[tuple[float, float]]] = None, ) -> dict: """Build a `midi_data` dict from parallel note lists. This is the structured input expected by :class:`Melody` and matches the shape returned by :func:`melody_features.io.midi.import_midi`. """ pitch_list = [int(p) for p in pitches] start_list = [float(s) for s in starts] end_list = [float(e) for e in ends] note_count = len(pitch_list) if len(start_list) != note_count or len(end_list) != note_count: raise ValueError( "pitches, starts, and ends must have the same length " f"(got {note_count}, {len(start_list)}, {len(end_list)})" ) if tempo_changes is None: tempo_changes = [(0.0, tempo)] return { "ID": melody_id, "pitches": pitch_list, "starts": start_list, "ends": end_list, "MIDI Sequence": build_midi_sequence_string( pitch_list, start_list, end_list ), "tempo": tempo, "tempo_changes": tempo_changes, "time_signature": time_signature, "total_duration": max(end_list) if end_list else 0.0, }
[docs] class Melody: """Melody representation backed by parallel note lists. Construct directly with :meth:`from_notes`, or pass a `midi_data` dict with `pitches`, `starts`, and `ends` (as produced by :func:`melody_features.io.midi.import_midi`). Legacy dicts that only provide `MIDI Sequence` are still supported via string parsing. """
[docs] @classmethod def from_notes( cls, pitches: list[int], starts: list[float], ends: list[float], *, tempo: float = 100.0, melody_id: str = "", time_signature: str = "4/4", tempo_changes: Optional[list[tuple[float, float]]] = None, ) -> "Melody": """Construct a melody from parallel note lists without reading MIDI.""" return cls( build_midi_data( pitches, starts, ends, melody_id=melody_id, tempo=tempo, time_signature=time_signature, tempo_changes=tempo_changes, ) )
[docs] def __init__(self, midi_data: dict, tempo: float = None): """Initialize a Melody object from MIDI sequence data. Args: midi_data (dict): Dictionary containing MIDI sequence data tempo (float, optional): Tempo in BPM. If None, uses tempo from midi_data if available. """ self._midi_data = midi_data midi_sequence = midi_data.get("MIDI Sequence", "") self._midi_sequence = ( midi_sequence.split("Note(")[1:] if midi_sequence else [] ) if tempo is not None: self._tempo = tempo elif "tempo" in midi_data: self._tempo = midi_data["tempo"] else: self._tempo = 100.00 self._tempo_changes = midi_data.get("tempo_changes", [(0.0, self._tempo)]) self._pitches, self._starts, self._ends = self._load_note_lists(midi_data) channels = midi_data.get("channels") if isinstance(channels, list) and len(channels) == len(self._pitches): self._channels = [int(channel) for channel in channels] else: self._channels = [1] * len(self._pitches)
@staticmethod def _has_structured_note_lists(midi_data: dict) -> bool: """Return whether `midi_data` has aligned pitches/starts/ends lists.""" for key in ("pitches", "starts", "ends"): values = midi_data.get(key) if not isinstance(values, list): return False note_count = len(midi_data["pitches"]) return ( len(midi_data["starts"]) == note_count and len(midi_data["ends"]) == note_count ) @classmethod def _load_note_lists( cls, midi_data: dict ) -> tuple[list[int], list[float], list[float]]: """Load note lists from structured fields or legacy MIDI Sequence text.""" if cls._has_structured_note_lists(midi_data): return ( [int(p) for p in midi_data["pitches"]], [float(s) for s in midi_data["starts"]], [float(e) for e in midi_data["ends"]], ) midi_sequence = midi_data.get("MIDI Sequence", "") if not midi_sequence: return [], [], [] fragments = midi_sequence.split("Note(")[1:] return cls._parse_midi_sequence(fragments) @staticmethod def _parse_midi_sequence( midi_sequence: list[str], ) -> tuple[list[int], list[float], list[float]]: """Parse pitch, start, and end times from note string fragments.""" pitches: list[int] = [] starts: list[float] = [] ends: list[float] = [] for note_fragment in midi_sequence: pitch_text = Melody._read_note_field(note_fragment, "pitch") if pitch_text is not None: pitches.append(int(pitch_text)) start_text = Melody._read_note_field(note_fragment, "start") if start_text is not None: starts.append(float(start_text)) end_text = Melody._read_note_field(note_fragment, "end") if end_text is not None: ends.append(float(end_text)) return pitches, starts, ends @staticmethod def _read_note_field(note_fragment: str, field_name: str) -> Optional[str]: """Return the raw text of one `Note(...)` field (e.g. pitch, start, end).""" marker = f"{field_name}=" marker_at = note_fragment.find(marker) if marker_at == -1: return None value_begin = marker_at + len(marker) comma_at = note_fragment.find(",", value_begin) paren_at = note_fragment.find(")", value_begin) if field_name == "start": if comma_at == -1: return None value_stop = comma_at elif field_name == "pitch": if comma_at == -1: value_stop = paren_at elif ( comma_at + 1 < len(note_fragment) and note_fragment[comma_at + 1] == ")" ): value_stop = paren_at else: value_stop = comma_at else: value_stop = comma_at if comma_at != -1 else paren_at if value_stop == -1: return None return note_fragment[value_begin:value_stop].rstrip(",)") @property def midi_data(self) -> dict: """Raw import dictionary backing this melody (e.g. from :func:`import_midi`).""" return self._midi_data @property def id(self) -> str: """Get the ID (file path) of the MIDI file. Returns: str: File path or ID of the MIDI file """ return self._midi_data.get("ID", "") @property def pitches(self) -> list[int]: """List of MIDI note pitches in order of appearance.""" return self._pitches @property def starts(self) -> list[float]: """List of MIDI note start times in order of appearance.""" return self._starts @property def ends(self) -> list[float]: """List of MIDI note end times in order of appearance.""" return self._ends @property def channels(self) -> list[int]: """MIDI channel numbers (MIDI Toolbox uses channels 1-16).""" return self._channels @property def tempo(self) -> float: """Extract tempo from Class input. Returns: float: Tempo of the melody in beats per minute """ return self._tempo @property def tempo_changes(self) -> list[tuple[float, float]]: """Get tempo changes from the melody. Returns: list[tuple[float, float]]: List of (time_in_seconds, tempo_in_bpm) tuples """ return self._tempo_changes @property def meter(self) -> tuple[int, int]: """Extract the first time signature from the melody. Returns: tuple[int, int]: First time signature as (numerator, denominator) Defaults to (4, 4) if no time signature information available """ time_sig_info = self._midi_data.get("time_signature_info") if time_sig_info and "first_time_signature" in time_sig_info: return time_sig_info["first_time_signature"] return (4, 4) # Default to 4/4 if no information available @property def time_signatures(self) -> list[tuple[float, int, int]]: """Get all time signatures present in the melody. Returns: list[tuple[float, int, int]]: List of tuples (time_in_seconds, numerator, denominator). If none are available, falls back to a single entry using the first meter at time 0.0. """ time_sig_info = self._midi_data.get("time_signature_info") if time_sig_info and "all_time_signatures" in time_sig_info: return time_sig_info["all_time_signatures"] num, den = self.meter return [(0.0, num, den)] @property def proportion_of_time_in_first_meter(self) -> float: """Calculate the proportion of time spent in the first time signature. Returns: float: Proportion (0.0 to 1.0) that the first time signature comprises of the total melody duration. 1.0 means the melody uses only one time signature throughout. """ time_sig_info = self._midi_data.get("time_signature_info") if time_sig_info and "proportion_of_time_in_first_meter" in time_sig_info: return time_sig_info["proportion_of_time_in_first_meter"] return 0.0 @property def total_duration(self) -> float: """Get the total duration of the MIDI sequence in seconds. Returns: float: Total duration of the MIDI sequence in seconds, including any leading or trailing silence. This matches jSymbolic's DurationInSecondsFeature implementation. """ return self._midi_data.get("total_duration", 0.0) @property def key_signature(self) -> tuple: """Get the first key signature in the melody. Returns: tuple or None: (key_name, mode) where key_name is a string like 'C', 'Am', 'F#' and mode is either 'major' or 'minor'. Returns None if no key signature found. """ key_sig_info = self._midi_data.get("key_signature_info") if key_sig_info: return key_sig_info.get("first_key_signature") return None @property def key_signatures(self) -> list: """Get all key signatures present in the melody. Returns: list[tuple]: List of tuples (key_name, mode) for all key signatures. Empty list if no key signatures are found. """ key_sig_info = self._midi_data.get("key_signature_info") if key_sig_info: return key_sig_info.get("all_key_signatures", []) return [] @property def has_key_signature(self) -> bool: """Check if the MIDI file contains any key signature information. Returns: bool: True if at least one key signature was found, False otherwise. """ key_sig_info = self._midi_data.get("key_signature_info") if key_sig_info: return key_sig_info.get("has_key_signature", False) return False @property def key_fifths(self) -> int: """Get the circle of fifths position of the first key signature. Returns: int or None: Position on circle of fifths (-7 to 7) where: 0 = C major / A minor Positive = sharps (G=1, D=2, A=3, E=4, B=5, F#=6, C#=7) Negative = flats (F=-1, Bb=-2, Eb=-3, Ab=-4, Db=-5, Gb=-6, Cb=-7) Returns None if no key signature found. """ key_sig_info = self._midi_data.get("key_signature_info") if key_sig_info: return key_sig_info.get("fifths") return None @property def key_mode(self) -> int: """Get the mode of the first key signature as an integer. Returns: int or None: 1 for major, -1 for minor. Returns None if no key signature found. """ key_sig_info = self._midi_data.get("key_signature_info") if key_sig_info: return key_sig_info.get("mode") return None
[docs] def read_midijson(file_path: str) -> dict: with open(file_path, "r", encoding="utf-8") as file: return json.load(file)