Source code for melody_features.feature_definitions.metre

"""Metre feature definitions."""

import numpy as np

from ..feature_decorators import jsymbolic, metre, midi_toolbox, novel, rhythm, simile
from ..algorithms.meter_estimation import (
    duration_accent as _duration_accent,
    melodic_accent as _melodic_accent,
    metric_hierarchy as _metric_hierarchy,
)
from ..core.representations import Melody


__all__ = [
    "metric_hierarchy",
    "meter_accent",
    "meter_numerator",
    "meter_denominator",
    "proportion_of_time_in_first_meter",
    "number_of_unique_time_signatures",
    "syncopation",
    "syncopicity",
]


[docs] @rhythm @metre @midi_toolbox def metric_hierarchy(melody: Melody) -> list[int]: """Metric hierarchy values for each note, indicating the strength of each note position within the known or estimated meter. Higher values indicate stronger metric positions (e.g., downbeat = 5, beat = 4, half-beat = 3, etc.). Implementation based on MIDI toolbox metrichierarchy.m. """ return _metric_hierarchy( melody.starts, melody.ends, tempo=melody.tempo, pitches=melody.pitches, tempo_changes=melody.tempo_changes, )
def _meter_accent_mean(melody: Melody) -> float: """`meteraccent.m` synchrony (float, unrounded).""" hierarchy_values = metric_hierarchy(melody) if not hierarchy_values: return 0.0 melodic_accents = _melodic_accent(melody.pitches) durational_accents = _duration_accent(melody.starts, melody.ends) n = min(len(hierarchy_values), len(melodic_accents), len(durational_accents)) if n == 0: return 0.0 products = [ h * m * d for h, m, d in zip( hierarchy_values[:n], melodic_accents[:n], durational_accents[:n] ) ] return float(-1.0 * np.mean(products))
[docs] @rhythm @metre @midi_toolbox def meter_accent(melody: Melody) -> float: """Phenomenal accent synchrony measure, calculated as the negative mean of the product of metric hierarchy, melodic accent, and durational accent for each note. Higher values indicate stronger accent synchrony. Implementation based on MIDI toolbox meteraccent.m. """ return _meter_accent_mean(melody)
[docs] @jsymbolic @rhythm @metre def meter_numerator(melody: Melody) -> int: """The numerator of the melody's active time signature. For a time signature written as `numerator/denominator`, the numerator is the number of beats in each notated bar. If a melody contains meter changes, this returns the meter stored on the melody object as its primary meter. Returns ------- int The numerator of the notated meter. """ return melody.meter[0]
[docs] @jsymbolic @rhythm @metre def meter_denominator(melody: Melody) -> int: """The denominator of the melody's active time signature. For a time signature written as `numerator/denominator`, the denominator gives the note value that represents one beat: for example, `4` means a quarter-note beat and `8` means an eighth-note beat. Returns ------- int The denominator of the notated meter. """ return melody.meter[1]
[docs] @novel @rhythm @metre def proportion_of_time_in_first_meter(melody: Melody) -> float: """The proportion of the melody's duration spent in its first time signature. The numerator and denominator of the first encountered time signature define the initial meter. This feature reports the fraction of total melody duration before any subsequent meter change. Melodies with no meter change therefore return `1.0`. Parameters ---------- melody : Melody The melody to analyze Returns ------- float The proportion of time spent in the first time signature. """ return melody.proportion_of_time_in_first_meter
[docs] @jsymbolic @rhythm @metre def number_of_unique_time_signatures(melody: Melody) -> int: """ The number of unique time signatures in the melody. Parameters ---------- melody : Melody The melody to analyze Returns ------- int The number of unique time signatures in the melody. Note ----- This feature is named `Metrical Diversity` in jSymbolic. """ return len({(numerator, denominator) for _, numerator, denominator in melody.time_signatures})
[docs] @novel @rhythm @metre def syncopation(melody: Melody) -> float: """ Calculate the mean `syncopation` value based on the Longuet-Higgins and Lee (1984) model. This `syncopation` model assigns metrical weights to each note position based on its position in the metric hierarchy. Syncopation occurs when a rest or tied note is preceded by a sounded note of lower metrical weight. The `syncopation` value is the difference between the rest weight and the preceding note weight. Parameters ---------- melody : Melody The melody to analyze Returns ------- float The mean syncopation value across all syncopation events (0.0 if no syncopation) Citation -------- Longuet-Higgins & Lee (1984) """ if not melody.starts or len(melody.starts) < 2: return 0.0 hierarchy_values = _metric_hierarchy( melody.starts, melody.ends, tempo=melody.tempo, pitches=melody.pitches, tempo_changes=melody.tempo_changes, ) if not hierarchy_values or len(hierarchy_values) != len(melody.starts): return 0.0 # Hierarchy 5 (downbeat/measure start) -> weight 0 (strongest) # Hierarchy 4 (beat) -> weight -1 # Hierarchy 3 (half-beat) -> weight -2 # Hierarchy 2 (quarter-beat) -> weight -3 # Hierarchy 1 (weakest offbeat) -> weight -4 weights = [5 - h for h in hierarchy_values] syncopation_values = [] for i in range(len(melody.starts) - 1): current_note_end = melody.ends[i] next_note_start = melody.starts[i + 1] gap_duration = next_note_start - current_note_end if gap_duration > 0.001: rest_weight = weights[i + 1] preceding_note_weight = weights[i] syncopation_value = rest_weight - preceding_note_weight if syncopation_value > 0: syncopation_values.append(syncopation_value) if not syncopation_values: return 0.0 return float(np.mean(syncopation_values))
[docs] @simile @rhythm @metre def syncopicity(melody: Melody) -> float: """ Calculates the sum `syncopicity` of a melody across metric levels. Syncopicity measures the degree to which notes occur off the main metrical grid but are long enough to span across metric boundaries. This calculates syncopations at four metric levels: 1) Half bar level 2) Beat level 3) First subdivision (half-beat) 4) Second subdivision (quarter-beat) An event is considered syncopated at a given level if: 1) It does not fall on a grid point of this level 2) It falls on a grid point of the next lower level 3) Its IOI extends beyond the lower level time unit (or it's the last note) Parameters ---------- melody : Melody The melody to analyze Returns ------- float Sum of per-level syncopation proportions across tested levels (half-bar, beat, and first subdivision). Each level contributes `syncopation_count / number_of_notes`, so this is not a raw event count. Note ---- Grid durations are derived from the melody's initial meter and tempo and therefore assume constant meter/tempo over the analyzed passage. """ if not melody.starts or len(melody.starts) < 2: return 0.0 numerator, denominator = melody.meter tempo = melody.tempo quarter_note_duration = 60.0 / tempo beat_duration = (4.0 / denominator) * quarter_note_duration measure_duration = numerator * beat_duration levels = [ measure_duration / 2.0, # Half bar beat_duration, # Beat beat_duration / 2.0, # First subdivision beat_duration / 4.0 # Second subdivision ] n_notes = len(melody.starts) total_syncopicity = 0.0 iois = [] for i in range(n_notes - 1): iois.append(melody.starts[i + 1] - melody.starts[i]) iois.append(0) for level_idx in range(len(levels) - 1): level_duration = levels[level_idx] next_lower_duration = levels[level_idx + 1] syncopation_count = 0 tolerance = 0.01 for note_idx, start_time in enumerate(melody.starts): position_in_level = start_time % level_duration on_current_grid = position_in_level < tolerance or position_in_level > (level_duration - tolerance) if on_current_grid: continue position_in_lower = start_time % next_lower_duration on_lower_grid = position_in_lower < tolerance or position_in_lower > (next_lower_duration - tolerance) if not on_lower_grid: continue is_last_note = note_idx == n_notes - 1 ioi_extends = iois[note_idx] > (next_lower_duration + tolerance) if is_last_note or ioi_extends: syncopation_count += 1 level_syncopicity = syncopation_count / n_notes if n_notes > 0 else 0.0 total_syncopicity += level_syncopicity return float(total_syncopicity)