Source code for melody_features.contour.interpolation_contour

"""Calculates the Interpolation Contour of a melody, along with related features, as
implemented in the FANTASTIC toolbox of Müllensiefen (2009) [1].
Includes a modified version of the FANTASTIC method that is better suited to short melodies
than the original implementation. This 'AMADS' method defines turning points using reversals.
All features are returned for either method.
"""

__author__ = "David Whyatt"

import numpy as np


[docs] class InterpolationContour: """Class for calculating and analyzing the interpolation contours of melodies, according to Müllensiefen (2009) [1]. This representation was first formalised by Steinbeck (1982) [2], and informed a variant of the present implementation in Müllensiefen & Frieler (2006) [3]. An interpolation contour is produced by first identifying turning points in the melody, and then interpolating a linear gradient between each turning point. The resulting list of values represents the gradient of the melody at evenly spaced points in time. """
[docs] def __init__(self, pitches: list[int], times: list[float], method: str = "amads"): """Initialize with pitch and time values. Parameters ---------- pitches : list[int] Array of pitch values times : list[float] Array of onset times in seconds method : str, optional Method to use for contour calculation, either `"fantastic"` or `"amads"`. Defaults to `"amads"`. Raises ------ ValueError If times and pitches are not the same length If method is not "fantastic" or "amads" Examples -------- >>> happy_birthday_pitches = [ ... 60, 60, 62, 60, 65, 64, 60, 60, 62, 60, 67, 65, ... 60, 60, 72, 69, 67, 65, 64, 70, 69, 65, 67, 65 ... ] >>> happy_birthday_times = [ ... 0, 0.75, 1, 2, 3, 4, 6, 6.75, 7, 8, 9, 10, ... 12, 12.75, 13, 14, 15, 16, 17, 18, 18.75, 19, 20, 21 ... ] >>> ic = InterpolationContour( ... happy_birthday_pitches, ... happy_birthday_times, ... method="fantastic", ... ) >>> ic.direction_changes 0.6 >>> ic.class_label 'ccbc' >>> ic.mean_gradient 2.512... >>> ic.gradient_std 5.496... >>> ic.global_direction 1 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] Müllensiefen, D. & Frieler, K. (2006). Cognitive Adequacy in the Measurement of Melodic Similarity: Algorithmic vs. Human Judgments """ if len(times) != len(pitches): raise ValueError( f"Times and pitches must have the same length, got {len(times)} and {len(pitches)}" ) if method not in ["fantastic", "amads"]: raise ValueError( f"Method must be either 'fantastic' or 'amads', got {method}" ) self.times = times self.pitches = pitches self.method = method self.contour = self.calculate_interpolation_contour(pitches, times, method)
@staticmethod def _is_turning_point_fantastic(pitches: list[int], i: int) -> bool: """Helper method to determine if a point is a turning point in FANTASTIC method.""" return any( [ (pitches[i - 1] < pitches[i] and pitches[i] > pitches[i + 1]), (pitches[i - 1] > pitches[i] and pitches[i] < pitches[i + 1]), ( pitches[i - 1] == pitches[i] and pitches[i - 2] < pitches[i] and pitches[i] > pitches[i + 1] ), ( pitches[i - 1] < pitches[i] and pitches[i] == pitches[i + 1] and pitches[i + 2] > pitches[i] ), ( pitches[i - 1] == pitches[i] and pitches[i - 2] > pitches[i] and pitches[i] < pitches[i + 1] ), ( pitches[i - 1] > pitches[i] and pitches[i] == pitches[i + 1] and pitches[i + 2] < pitches[i] ), ] )
[docs] @staticmethod def calculate_interpolation_contour( pitches: list[int], times: list[float], method: str = "amads" ) -> list[float]: """Calculate the interpolation contour representation of a melody [1]. Returns ------- list[float] Array containing the interpolation contour representation """ if method == "fantastic": return InterpolationContour._calculate_fantastic_contour(pitches, times) return InterpolationContour._calculate_amads_contour(pitches, times)
@staticmethod def _calculate_fantastic_contour( pitches: list[int], times: list[float] ) -> list[float]: # Find candidate points candidate_points_pitch = [pitches[0]] # Start with first pitch candidate_points_time = [times[0]] # Start with first time # Special case for very short melodies if len(pitches) in [3, 4]: for i in range(1, len(pitches) - 1): if InterpolationContour._is_turning_point_fantastic(pitches, i): candidate_points_pitch.append(pitches[i]) candidate_points_time.append(times[i]) else: # For longer melodies for i in range(2, len(pitches) - 2): if InterpolationContour._is_turning_point_fantastic(pitches, i): candidate_points_pitch.append(pitches[i]) candidate_points_time.append(times[i]) # Initialize turning points with first note turning_points_pitch = [pitches[0]] turning_points_time = [times[0]] # Find turning points if len(candidate_points_pitch) > 2: for i in range(1, len(pitches) - 1): if times[i] in candidate_points_time: if pitches[i - 1] != pitches[i + 1]: turning_points_pitch.append(pitches[i]) turning_points_time.append(times[i]) # Add last note turning_points_pitch.append(pitches[-1]) turning_points_time.append(times[-1]) # Calculate gradients gradients = np.diff(turning_points_pitch) / np.diff(turning_points_time) # Calculate durations durations = np.diff(turning_points_time) # Create weighted gradients vector samples_per_duration = np.round(durations * 10).astype( int ) # 10 samples per second interpolation_contour = np.repeat(gradients, samples_per_duration) return [float(x) for x in interpolation_contour] @staticmethod def _remove_repeated_notes( pitches: list[int], times: list[float] ) -> tuple[list[int], list[float]]: """Remove repeated notes, keeping only the middle occurrence.""" unique_pitches, unique_times = [], [] i = 0 while i < len(pitches): start_idx = i while i < len(pitches) - 1 and pitches[i + 1] == pitches[i]: i += 1 mid_idx = start_idx + (i - start_idx) // 2 unique_pitches.append(pitches[mid_idx]) unique_times.append(times[mid_idx]) i += 1 return unique_pitches, unique_times @staticmethod def _calculate_amads_contour(pitches: list[int], times: list[float]) -> list[float]: reversals_pitches = [pitches[0]] reversals_time = [times[0]] # Remove repeated notes pitches, times = InterpolationContour._remove_repeated_notes(pitches, times) # Find reversals for i in range(2, len(pitches)): if ( pitches[i] < pitches[i - 1] > pitches[i - 2] or pitches[i] > pitches[i - 1] < pitches[i - 2] ): reversals_pitches.append(pitches[i - 1]) reversals_time.append(times[i - 1]) # Add last note reversals_pitches.append(pitches[-1]) reversals_time.append(times[-1]) # Calculate gradients with np.errstate(divide="ignore", invalid="ignore"): gradients = np.diff(reversals_pitches) / np.diff(reversals_time) # Calculate durations durations = abs(np.diff(reversals_time)) # Create weighted gradients vector samples_per_duration = np.round(durations * 10).astype( int ) # 10 samples per second # Can't have a contour with less than 2 points if len(reversals_pitches) < 2: return [0.0] # If there are only 2 points, just use the gradient between them if len(reversals_pitches) == 2: gradient = reversals_pitches[1] - reversals_pitches[0] return [float(gradient / (reversals_time[1] - reversals_time[0]))] interpolation_contour = np.repeat(gradients, samples_per_duration) return [float(x) for x in interpolation_contour] @property def global_direction(self) -> int: """The overall direction of the interpolation-contour gradients. This feature sums the interpolation-contour gradient samples and returns the sign of that sum. A positive value indicates that upward gradients dominate overall, a negative value indicates that downward gradients dominate overall, and zero indicates that the sampled upward and downward gradients balance out. Returns ------- int `1` if the summed gradients are positive, `0` if the sum is zero, and `-1` if the summed gradients are negative. Note ---- This is a net direction metric. Opposing upward and downward sections can cancel, resulting in `0` even when the contour is not flat. Defaults to the `"amads"` turning-point method (reversals), which is more robust for short melodies than the original FANTASTIC contour-extrema rules. Pass `method="fantastic"` for the original behaviour. Examples -------- Flat overall contour direction (returns the same using FANTASTIC method) >>> ic = InterpolationContour([60, 62, 64, 62, 60], [0, 1, 2, 3, 4]) >>> ic.global_direction 0 Upwards contour direction (returns the same using FANTASTIC method) >>> ic = InterpolationContour([60, 62, 64, 65, 67], [0, 1, 2, 3, 4]) >>> ic.global_direction 1 Downwards contour direction (returns the same using FANTASTIC method) >>> ic = InterpolationContour([67, 65, 67, 62, 60], [0, 1, 2, 3, 4]) >>> ic.global_direction -1 """ return int(np.sign(sum(self.contour))) @property def mean_gradient(self) -> float: """The mean absolute gradient of the interpolation contour. The interpolation contour is represented as a sequence of local gradients between contour turning points. This feature takes the absolute value of each sampled gradient and averages the result, so upward and downward slopes contribute equally. Larger values indicate steeper pitch movement between turning points. Returns ------- float Mean absolute interpolation-contour gradient. Note ---- Defaults to the `"amads"` turning-point method (reversals), which is more robust for short melodies than the original FANTASTIC contour-extrema rules. Pass `method="fantastic"` for the original behaviour. Examples -------- Steps of 2 semitones per second >>> ic = InterpolationContour([60, 62, 64, 62, 60], [0, 1, 2, 3, 4]) >>> ic.mean_gradient 2.0 FANTASTIC method returns 0.0 for this example >>> ic = InterpolationContour([60, 62, 64, 62, 60], [0, 1, 2, 3, 4], method="fantastic") >>> ic.mean_gradient 0.0 """ return float(np.mean(np.abs(self.contour))) @property def gradient_std(self) -> float: """The sample standard deviation of interpolation-contour gradients. This feature measures how much the sampled interpolation-contour gradients vary around their mean. It uses Bessel's correction (`ddof=1`), matching the usual sample-standard-deviation convention. Contours with fewer than two gradient samples have no gradient variability and return `0.0`. Returns ------- float Sample standard deviation of the gradient values. Note ---- Defaults to the `"amads"` turning-point method (reversals), which is more robust for short melodies than the original FANTASTIC contour-extrema rules. Pass `method="fantastic"` for the original behaviour. Examples -------- >>> ic = InterpolationContour([60, 62, 64, 62, 60], [0, 1, 2, 3, 4]) >>> ic.gradient_std 2.0254... FANTASTIC method returns 0.0 for this example >>> ic = InterpolationContour([60, 62, 64, 62, 60], [0, 1, 2, 3, 4], method="fantastic") >>> ic.gradient_std 0.0 Single gradient value returns 0.0 (no variation) >>> ic = InterpolationContour([60, 67], [0, 1]) >>> ic.gradient_std 0.0 """ # Handle edge case where there's only one gradient value if len(self.contour) < 2: return 0.0 return float(np.std(self.contour, ddof=1)) @property def direction_changes(self) -> float: """The proportion of interpolation-contour transitions that reverse direction. The interpolation contour is a sequence of gradient samples. Consecutive samples with the same gradient belong to the same interpolation-gradient run, so they do not increase the denominator. The denominator is the number of transitions between distinct gradient runs; the numerator is the number of those transitions where the gradient changes sign. For example, the sampled contour `[2, 2, 1, 1, -1, -1]` has three gradient runs, `[2, 1, -1]`. It has two transitions between runs, but only `1 -> -1` reverses direction, so the feature value is `1 / 2 = 0.5`. Returns ------- float The number of sign-reversing transitions divided by the number of transitions between distinct interpolation-gradient runs. Returns `0.0` when there are no transitions between distinct gradient runs. Note ---- Defaults to the `"amads"` turning-point method (reversals), which is more robust for short melodies than the original FANTASTIC contour-extrema rules. Pass `method="fantastic"` for the original behaviour. Examples -------- >>> ic = InterpolationContour([60, 62, 64, 62, 60], [0, 1, 2, 3, 4]) >>> ic.direction_changes 1.0 FANTASTIC method returns 0.0 for this example >>> ic = InterpolationContour([60, 62, 64, 62, 60], [0, 1, 2, 3, 4], method="fantastic") >>> ic.direction_changes 0.0 """ # Convert contour to numpy array for element-wise multiplication contour_array = np.array(self.contour) # Calculate products of consecutive gradients consecutive_products = contour_array[:-1] * contour_array[1:] # Get signs of products and count negative ones (direction changes) product_signs = np.sign(consecutive_products) direction_changes = np.sum(np.abs(product_signs[product_signs == -1])) # Count total gradient changes (where consecutive values are different) total_changes = np.sum(contour_array[:-1] != contour_array[1:]) # Avoid division by zero if total_changes == 0: return 0.0 return float(direction_changes / total_changes) @property def class_label(self) -> str: """A four-symbol categorical summary of interpolation-contour gradients. The interpolation contour is sampled at four equally spaced positions. Each sampled gradient is measured in semitones per second, rescaled to semitones per 0.25 seconds, and assigned to one of five ordered categories from strong downward motion to strong upward motion. The returned string preserves temporal order, so `"ddbb"` means that the first two sampled regions are upward and the last two are downward. Categories are defined as follows: - `"a"`: strong downward motion; normalized gradient <= -1.45. - `"b"`: downward motion; -1.45 < normalized gradient <= -0.45. - `"c"`: approximately flat; -0.45 < normalized gradient < 0.45. - `"d"`: upward motion; 0.45 <= normalized gradient < 1.45. - `"e"`: strong upward motion; normalized gradient >= 1.45. Returns ------- str A string of length four whose characters are drawn from `"a"` to `"e"`. Each character gives the gradient category at one sampled position in the interpolation contour. Note ---- Returned labels are letters (`"a"`-`"e"`). Numeric codes (`-2` to `2`) are threshold descriptions only and are not returned by this property. Defaults to the `"amads"` turning-point method (reversals), which is more robust for short melodies than the original FANTASTIC contour-extrema rules. Pass `method="fantastic"` for the original behaviour. Examples -------- Upwards, then downwards contour >>> ic = InterpolationContour([60, 62, 64, 62, 60], [0, 1, 2, 3, 4]) >>> ic.class_label 'ddbb' FANTASTIC method returns 'cccc' for this example, as though the contour is flat >>> ic = InterpolationContour([60, 62, 64, 62, 60], [0, 1, 2, 3, 4], method="fantastic") >>> ic.class_label 'cccc' """ # Sample the contour at 4 equally spaced points # Get 4 equally spaced indices n = len(self.contour) indices = np.linspace(0, n - 1, 4, dtype=int) # Sample the contour at those indices sampled_points = [self.contour[i] for i in indices] # Normalize the gradients to a norm where value of 1 corresponds to a semitone # change in pitch over 0.25 seconds. # Given that base pitch and time units are 1 second and 1 semitone respectively, # just divide by 4 norm_gradients = np.array(sampled_points) * 0.25 classes = "" for grad in norm_gradients: if grad <= -1.45: classes += "a" # strong down elif -1.45 < grad <= -0.45: classes += "b" # down elif -0.45 < grad < 0.45: classes += "c" # flat elif 0.45 <= grad < 1.45: classes += "d" # up else: classes += "e" # strong up return classes