Source code for melody_features.contour.step_contour

"""Calculates the Step Contour of a melody, along with related features.

Exemplified in Steinbeck (1982) [2], Juhász (2000) [3], Eerola and Toiviainen (2004) [4].
"""

__author__ = "David Whyatt"

import numpy as np


[docs] class StepContour: """Class for calculating and analyzing the step contour of a melody. A step contour is a list of MIDI pitch values, repeated proportionally to the duration (measured in tatums) of each note relative to the total melody length. This list is normalized to a user defined length, defaulting to 64 steps as used in FANTASTIC. Rests are considered as extending the duration of the previous note. Examples -------- >>> pitches = [60, 64, 67] # C4, E4, G4 >>> durations = [2.0, 1.0, 1.0] # First note is 2 beats, others are 1 beat >>> sc = StepContour(pitches, durations) >>> len(sc.contour) # Default length is 64 64 >>> pitches = [60, 62, 64, 65, 67] # C4, D4, E4, F4, G4 >>> durations = [1.0, 1.0, 1.0, 1.0, 1.0] # Notes have equal durations >>> sc = StepContour(pitches, durations) >>> sc.contour[:8] # First 8 values of 64-length contour [60, 60, 60, 60, 60, 60, 60, 60] >>> sc.global_variation # Standard deviation of contour # doctest: +ELLIPSIS 2.3974... >>> sc.global_direction # Correlation with ascending line # doctest: +ELLIPSIS 0.9746... >>> sc.local_variation # Average absolute difference between adjacent values # doctest: +ELLIPSIS 0.1111... """ _step_contour_length = 64
[docs] def __init__( self, pitches: list[int], durations: list[float], step_contour_length: int = _step_contour_length, method: str = "amads", ): """Initialize StepContour with melody data. Parameters ---------- pitches : list[int] List of pitch values durations : list[float] List of note durations measured in tatums step_contour_length : int, optional Length of the output step contour vector (default is 64) method : str, optional Method for contour statistics, either `"amads"` or `"fantastic"`. Defaults to `"amads"`. References ---------- [1] Müllensiefen, D. (2009). Fantastic: Feature ANalysis Technology Accessing STatistics (In a Corpus): Technical Report v1.5 [2] W. Steinbeck, Struktur und Ähnlichkeit: Methoden automatisierter Melodieanalyse. Bärenreiter, 1982. [3] Juhász, Z. 2000. A model of variation in the music of a Hungarian ethnic group. Journal of New Music Research 29(2):159-172. [4] Eerola, T. & Toiviainen, P. (2004). MIDI Toolbox: MATLAB Tools for Music Research. University of Jyväskylä: Kopijyvä, Jyväskylä, Finland. Examples -------- >>> sc = StepContour([60, 62], [2.0, 2.0], step_contour_length=4) >>> sc.contour [60, 60, 62, 62] """ if len(pitches) != len(durations): raise ValueError( f"The length of pitches (currently {len(pitches)}) must be equal to " f"the length of durations (currently {len(durations)})" ) if method not in ("amads", "fantastic"): raise ValueError( f"Method must be either 'amads' or 'fantastic', got {method}" ) self._step_contour_length = step_contour_length self.method = method self.contour = self._calculate_contour(pitches, durations)
def _normalize_durations(self, durations: list[float]) -> list[float]: """Helper function to normalize note durations to fit within 4 bars of 4/4 time (64 tatums total by default). Parameters ---------- durations : list[float] List of duration values measured in tatums Returns ------- list[float] List of normalized duration values Examples -------- >>> sc = StepContour([60], [1.0]) >>> sc._normalize_durations([2.0, 2.0]) [32.0, 32.0] """ total_duration = sum(durations) if total_duration == 0: raise ValueError("Total duration is 0, cannot normalize") normalized = [ self._step_contour_length * (duration / total_duration) for duration in durations ] return normalized @classmethod def _expand_to_vector( cls, pitches: list[int], normalized_durations: list[float], step_contour_length: int, ) -> list[int]: """Helper function that resamples the melody to a vector of length step_contour_length. Parameters ---------- pitches : list[int] List of pitch values normalized_durations : list[float] List of normalized duration values (should sum to step_contour_length) step_contour_length : int Length of the output step contour vector Returns ------- list[int] List of length step_contour_length containing repeated pitch values Examples -------- >>> StepContour._expand_to_vector([60, 62], [2.0, 2.0], step_contour_length=4) [60, 60, 62, 62] """ if abs(sum(normalized_durations) - step_contour_length) > 1e-6: raise ValueError( f"The sum of normalized_durations ({sum(normalized_durations)}) must " f"be equal to the step contour length ({step_contour_length})" ) # We interpret the output list as a vector of pitch samples taken # at times 0, 1, 2, ..., 63 where 63 = step_contour_length - 1 # and the length of the normalized melody is 64. output_length = step_contour_length output_pitches = [None for _ in range(output_length)] output_times = list(range(output_length)) output_index = 0 offset = 0.0 # Iterate over the input pitches and durations for sounding_pitch, duration in zip(pitches, normalized_durations): offset += duration while True: # Step through the output list, and populate any time slots that # are occupied by the current note. if output_index >= output_length: break output_time = output_times[output_index] if output_time >= offset: break output_pitches[output_index] = sounding_pitch output_index += 1 return output_pitches def _calculate_contour( self, pitches: list[int], durations: list[float] ) -> list[int]: """Calculate the step contour from input pitches and durations. Examples -------- >>> sc = StepContour([60, 62], [2.0, 2.0], step_contour_length=4) >>> sc._calculate_contour([60, 62], [2.0, 2.0]) [60, 60, 62, 62] """ normalized_durations = self._normalize_durations(durations) return self._expand_to_vector( pitches, normalized_durations, self._step_contour_length ) @property def global_variation(self) -> float: """The overall pitch variability of the step contour. The step contour is a fixed-length pitch vector in which each note's MIDI pitch is repeated in proportion to its duration. This feature is the standard deviation of that vector, so long notes influence the result more than short notes. Larger values indicate a wider duration-weighted pitch spread. Returns ------- float Standard deviation of the duration-weighted step-contour pitch vector. Note ---- The `"amads"` method uses population standard deviation (`ddof=0`), while `"fantastic"` uses sample standard deviation (`ddof=1`) for compatibility with the original FANTASTIC convention. Examples -------- >>> sc = StepContour([60, 62, 64], [1.0, 1.0, 1.0]) >>> sc.global_variation # doctest: +ELLIPSIS 1.639... """ if len(self.contour) < 2: return 0.0 ddof = 1 if self.method == "fantastic" else 0 return float(np.std(self.contour, ddof=ddof)) @property def global_direction(self) -> float: """The overall ascending or descending tendency of the step contour. This is the Pearson correlation between the fixed-length step-contour pitch vector and an ascending linear ramp. Positive values indicate an overall upward trajectory, negative values indicate an overall downward trajectory, and values near zero indicate little linear pitch direction. A flat contour returns `0.0`. Returns ------- float Correlation with an ascending linear ramp. Examples -------- >>> sc = StepContour([60, 62, 64], [1.0, 1.0, 1.0]) >>> sc.global_direction # doctest: +ELLIPSIS 0.942... >>> sc = StepContour([60, 60, 60], [1.0, 1.0, 1.0]) >>> sc.global_direction 0.0 >>> sc = StepContour([64, 62, 60], [1.0, 1.0, 1.0]) # Descending melody >>> sc.global_direction # doctest: +ELLIPSIS -0.942... """ corr = np.corrcoef(self.contour, np.arange(self._step_contour_length))[0, 1] if np.isnan(corr) and len(self.contour) > 1: return 0.0 return float(corr) @property def local_variation(self) -> float: """The average adjacent-sample change in the step contour. This feature compares each neighboring pair of samples in the fixed-length step-contour pitch vector and averages the absolute pitch differences. Because adjacent samples inside the same sustained note have difference zero, the measure emphasizes local pitch changes after duration-weighted resampling rather than the raw sequence of note-to-note intervals. Returns ------- float Mean absolute difference between adjacent step-contour samples. Examples -------- >>> sc = StepContour([60, 62, 64], [1.0, 1.0, 1.0]) >>> sc.local_variation # doctest: +ELLIPSIS 0.0634... """ pairs = list(zip(self.contour, self.contour[1:])) local_variation = sum(abs(c2 - c1) for c1, c2 in pairs) / len(pairs) return local_variation