Source code for melody_features.contour.huron_contour

__author__ = "David Whyatt"

# I didn't implement this before, but Mark has a good implementation in
# AMADS, see https://github.com/music-computing/amads

from melody_features.core.representations import Melody


[docs] class HuronContour: """A class for computing the Huron Contour of a melody, as described in the FANTASTIC toolbox. Huron Contour classifies melodies based on the shape of the contour between the first pitch, the mean pitch, and the last pitch. Attributes ---------- melody : Melody The melody object containing the melody to analyze. huron_contour : str The Huron contour classification for the melody. """
[docs] def __init__(self, melody: Melody): """Initialize the Huron Contour using a Melody object and calculate the Huron Contour classification. Parameters ---------- melody : Melody The melody object containing the melody to analyze. """ self.melody = melody contour_points = self.get_contour_points(melody) self._huron_contour = self.get_contour_class(contour_points)
[docs] def get_contour_points(self, melody: Melody) -> list[float]: """Get the contour points of a melody. Calculates the first pitch, weighted mean pitch, and last pitch of the melody. Parameters ---------- melody : Melody The melody object to analyze Returns ------- list[float] The three relevant contour points: [first_pitch, mean_pitch, last_pitch] """ if not melody.pitches or len(melody.pitches) == 0: return [0.0, 0.0, 0.0] if len(melody.pitches) == 1: single_pitch = float(melody.pitches[0]) return [single_pitch, single_pitch, single_pitch] first_pitch = float(melody.pitches[0]) last_pitch = float(melody.pitches[-1]) # Calculate duration-weighted mean pitch by giving more weight to longer notes # to determine the overall "center" of the melody if len(melody.starts) == len(melody.pitches) and len(melody.ends) == len(melody.pitches): durations = [end - start for start, end in zip(melody.starts, melody.ends)] total_duration = sum(durations) if total_duration > 0: weighted_sum = sum(pitch * duration for pitch, duration in zip(melody.pitches, durations)) mean_pitch = weighted_sum / total_duration else: # Fallback to simple mean if durations are problematic mean_pitch = sum(melody.pitches) / len(melody.pitches) else: # Fallback to simple arithmetic mean if timing data is inconsistent mean_pitch = sum(melody.pitches) / len(melody.pitches) # Round mean pitch to nearest integer (MIDI pitch value) mean_pitch = round(mean_pitch) return [first_pitch, float(mean_pitch), last_pitch]
[docs] def get_contour_class(self, contour_points: list[float]) -> str: """The classification of a contour based on the relationship between the first, mean, and last pitch of the melody. Parameters ---------- contour_points : list[float] The three relevant contour points: [first_pitch, mean_pitch, last_pitch] Returns ------- str The contour classification according to Huron's system """ if len(contour_points) != 3: return "horizontal" p1, p_mean, pn = contour_points # Classify based on Huron's 8 categories if p1 < p_mean > pn: return "convex" elif p1 < p_mean == pn: return "ascending-horizontal" elif p1 < p_mean < pn: return "ascending" elif p1 == p_mean == pn: return "horizontal" elif p1 == p_mean > pn: return "horizontal-descending" elif p1 == p_mean < pn: return "horizontal-ascending" elif p1 > p_mean == pn: return "descending-horizontal" elif p1 > p_mean > pn: return "descending" elif p1 > p_mean < pn: return "concave" else: return "unclassified"
@property def class_label(self) -> str: """The Huron three-point contour classification for the melody. The melody is reduced to three pitch points: the first pitch, a rounded duration-weighted mean pitch, and the last pitch. Their relative ordering determines the contour class. For example, first < mean < last is `"ascending"`, first > mean > last is `"descending"`, first < mean > last is `"convex"`, and first > mean < last is `"concave"`. Citation -------- Huron (1996) Returns ------- str One of `"ascending"`, `"descending"`, `"convex"`, `"concave"`, `"horizontal"`, `"ascending-horizontal"`, `"horizontal-ascending"`, `"descending-horizontal"`, `"horizontal-descending"`, or `"unclassified"`. """ return self._huron_contour
[docs] def get_huron_contour(melody: Melody) -> str: """Calculate Huron contour classification for an arbitrary melody. Used here for doctesting and offered for user convenience. Parameters ---------- melody : Melody The melody object to analyze Returns ------- str The Huron contour classification Examples -------- Single note melody: >>> single_note_data = {"MIDI Sequence": "Note(start=0.0, end=1.0, pitch=60, velocity=100)"} >>> single_note = Melody(single_note_data) >>> get_huron_contour(single_note) 'horizontal' The lick - convex contour (62, 64, 65, 67, 64, 60, 62): >>> lick_data = {"MIDI Sequence": "Note(start=0.0, end=1.0, pitch=62, velocity=100)Note(start=1.0, end=2.0, pitch=64, velocity=100)Note(start=2.0, end=3.0, pitch=65, velocity=100)Note(start=3.0, end=4.0, pitch=67, velocity=100)Note(start=4.0, end=6.0, pitch=64, velocity=100)Note(start=6.0, end=7.0, pitch=60, velocity=100)Note(start=7.0, end=8.0, pitch=62, velocity=100)"} >>> lick_melody = Melody(lick_data) >>> get_huron_contour(lick_melody) 'convex' Ascending melody (60, 62, 64, 67): >>> asc_data = {"MIDI Sequence": "Note(start=0.0, end=1.0, pitch=60, velocity=100)Note(start=1.0, end=2.0, pitch=62, velocity=100)Note(start=2.0, end=3.0, pitch=64, velocity=100)Note(start=3.0, end=4.0, pitch=67, velocity=100)"} >>> asc_melody = Melody(asc_data) >>> get_huron_contour(asc_melody) 'ascending' Descending melody (67, 64, 62, 60): >>> desc_data = {"MIDI Sequence": "Note(start=0.0, end=1.0, pitch=67, velocity=100)Note(start=1.0, end=2.0, pitch=64, velocity=100)Note(start=2.0, end=3.0, pitch=62, velocity=100)Note(start=3.0, end=4.0, pitch=60, velocity=100)"} >>> desc_melody = Melody(desc_data) >>> get_huron_contour(desc_melody) 'descending' """ hc = HuronContour(melody) return hc.class_label