Skip to content

Contour

HuronContour

HuronContour(
    pitches: list[int], times: list[float], method: str = "amads"
)

The contour classification scheme proposed by Huron (1996) [1]

The classification scheme is also included in the FANTASTIC toolbox of Müllensiefen (2009) [2] (as Feature 19 Huron Contour: h.contour).

Huron categorises melodies by identifying the start, mean, and end pitches and describing contour in terms of the two directions: start-mean, and mean-end.

Parameters:

  • pitches (list[int]) –

    Pitch values in any numeric format (e.g., MIDI numbers).

  • times (list[float]) –

    Onset times in any consistent, proportional scheme (e.g., seconds, quarter notes, etc.)

Raises:

  • ValueError

    If the times and pitches parameters are not the same length.

Examples:

>>> happy_birthday_pitches = [
...     60, 60, 62, 60, 65, 64, 60, 60, 62, 60, 67, 65,
...     60, 60, 72, 69, 65, 64, 62, 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
... ]
>>> hc = HuronContour(
...     happy_birthday_pitches,
...     happy_birthday_times,
... )
>>> hc.first_pitch
60
>>> hc.mean_pitch
65
>>> hc.last_pitch
65
>>> hc.first_to_mean
5
>>> hc.mean_to_last
0
>>> hc.contour_class
'Ascending-Horizontal'
References
  1. Huron, D (2006). The Melodic Arch in Western Folksongs. Computing in Musicology 10.

  2. Müllensiefen, D. (2009). Fantastic: Feature ANalysis Technology Accessing STatistics (In a Corpus): Technical Report v1.5

Source code in amads/melody/contour/huron_contour.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def __init__(
    self, pitches: list[int], times: list[float], method: str = "amads"
):
    """Initialize with pitch and time values.

    Parameters
    ----------
    pitches : list[int]
        Pitch values in any numeric format (e.g., MIDI numbers).
    times : list[float]
        Onset times in any consistent, proportional scheme
        (e.g., seconds, quarter notes, etc.)

    Raises
    ------
    ValueError
        If the `times` and `pitches` parameters are not the same length.

    Examples
    --------
    >>> happy_birthday_pitches = [
    ...     60, 60, 62, 60, 65, 64, 60, 60, 62, 60, 67, 65,
    ...     60, 60, 72, 69, 65, 64, 62, 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
    ... ]
    >>> hc = HuronContour(
    ...     happy_birthday_pitches,
    ...     happy_birthday_times,
    ... )

    >>> hc.first_pitch
    60
    >>> hc.mean_pitch
    65
    >>> hc.last_pitch
    65
    >>> hc.first_to_mean
    5
    >>> hc.mean_to_last
    0
    >>> hc.contour_class
    'Ascending-Horizontal'

    References
    ----------
      1. Huron, D (2006). The Melodic Arch in Western Folksongs.
         *Computing in Musicology* 10.

      2. Müllensiefen, D. (2009). Fantastic: Feature ANalysis Technology Accessing
         STatistics (In a Corpus): Technical Report v1.5
    """
    if len(times) != len(pitches):
        raise ValueError(
            f"Times and pitches must have the same length, "
            f"got {len(times)} and {len(pitches)}"
        )

    self.times = times
    self.pitches = pitches
    self.first_pitch = pitches[0]
    self.last_pitch = pitches[-1]

    self.mean_pitch = None
    self.first_to_mean = None
    self.mean_to_last = None
    self.calculate_mean_attributes()

    self.contour_class = None
    self.class_label()

Functions

calculate_mean_attributes

calculate_mean_attributes()

Calculate the mean and populate the remaining attributes.

Note that the mean pitch is rounded to the nearest integer, and that this rounding happens before calculating comparisons.

Source code in amads/melody/contour/huron_contour.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def calculate_mean_attributes(self):
    """
    Calculate the mean and populate the remaining attributes.

    Note that the mean pitch is rounded to the nearest integer,
    and that this rounding happens before calculating comparisons.
    """
    self.mean_pitch = int(
        sum(x * y for x, y in zip(self.pitches, self.times))
        / sum(self.times)
    )

    self.first_to_mean = self.mean_pitch - self.first_pitch
    self.mean_to_last = self.last_pitch - self.mean_pitch

class_label

class_label()

Classify a contour into Huron's categories.

This is based simply on the two directions from start to mean and mean to last. Huron proposes shorthands for some of these as follows: "Ascending-Descending" = "Convex", "Ascending-Horizontal" = None, "Ascending-Ascending": None, "Horizontal-Descending": None, "Horizontal-Horizontal": "Horizontal", "Horizontal-Ascending": None, "Descending-Descending": "Descending", "Descending-Ascending": "Concave"

Where no shorthand is provided, this method return the longhand.

Returns:

  • str

    String, exactly as reported in the FANTASTIC library.

Source code in amads/melody/contour/huron_contour.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
def class_label(self):
    """Classify a contour into Huron's categories.

    This is based simply on the two directions from start to mean and mean to last.
    Huron proposes shorthands for some of these as follows:
    "Ascending-Descending" = "Convex",
    "Ascending-Horizontal" = None,
    "Ascending-Ascending": None,
    "Horizontal-Descending": None,
    "Horizontal-Horizontal": "Horizontal",
    "Horizontal-Ascending": None,
    "Descending-Descending": "Descending",
    "Descending-Ascending": "Concave"

    Where no shorthand is provided, this method return the longhand.

    Returns
    -------
    str
        String, exactly as reported in the FANTASTIC library.

    """

    direction_dict = {-1: "Descending", 0: "Horizontal", 1: "Ascending"}

    first_to_mean_sign = sign(self.first_to_mean)
    mean_to_last_sign = sign(self.mean_to_last)

    return_string = f"{direction_dict[first_to_mean_sign]}-{direction_dict[mean_to_last_sign]}"

    shorthand_dict = {
        "Ascending-Descending": "Convex",
        "Horizontal-Horizontal": "Horizontal",
        "Descending-Descending": "Descending",
        "Descending-Ascending": "Concave",
    }

    if return_string in shorthand_dict:
        self.contour_class = shorthand_dict[return_string]
    else:
        self.contour_class = return_string

ParsonsContour

ParsonsContour(
    pitches: list[int],
    character_dict: Optional[dict] = None,
    initial_asterisk: bool = False,
)

Implementation of the basic Parsons contour classification scheme.

Parsons categorises each step by direction only.

Nothing more, nothing less.

Author: Mark Gotham

Parameters:

  • pitches (list[int]) –

    A list of integers representing pitches (assumed to be MIDI numbers or equivalent, not pitch classes)

  • character_dict (Optional[dict], default: None ) –

    A dict specifying which characters to use when mapped to a string. Must include keys for [1, 0, -1] corresponding to up, repeat, and down. The default is Parsons' own values: {1: "u", 0: "r", -1: "d"}. Other options could include <, =, >.

  • initial_asterisk (bool, default: False ) –

    Optionally, include an initial * for the start of the sequence (no previous interval).

Examples:

>>> happy = [60, 60, 62, 60, 65, 64, 60, 60, 62, 60, 67, 65, 60, 60, 72, 69, 65, 64, 62, 70, 69, 65, 67, 65]
>>> pc = ParsonsContour(happy)
>>> pc.interval_sequence
[None, 0, 2, -2, 5, -1, -4, 0, 2, -2, 7, -2, -5, 0, 12, -3, -4, -1, -2, 8, -1, -4, 2, -2]
>>> pc.interval_sequence_sign
[None, 0, 1, -1, 1, -1, -1, 0, 1, -1, 1, -1, -1, 0, 1, -1, -1, -1, -1, 1, -1, -1, 1, -1]
>>> pc.as_string
'rududdrududdrudddduddud'
>>> twinkle_ints = [72, 72, 79, 79, 81, 81, 79, 77, 77, 76, 76, 74, 74, 72]
>>> pc = ParsonsContour(twinkle_ints)
>>> pc.as_string
'rururddrdrdrd'
>>> pc_no_asterisk = ParsonsContour(twinkle_ints, initial_asterisk=True)
>>> pc_no_asterisk.as_string
'*rururddrdrdrd'
>>> pc_symbols = ParsonsContour(twinkle_ints, {1: "<", 0: "=", -1: ">"})
>>> pc_symbols.as_string
'=<=<=>>=>=>=>'
References

[1] Parsons, Denys. 1975. The Directory of Tunes and Musical Themes.

Source code in amads/melody/contour/parsons_contour.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def __init__(
    self,
    pitches: list[int],
    character_dict: Optional[dict] = None,
    initial_asterisk: bool = False,
):
    """
    The 'Parsons code' returns simply the direction of each successive melodic interval.
    It's been used in lookup, and can serve as a useful first entry point to the topic of contour.

    Parameters
    ----------
    pitches:
        A list of integers representing pitches
        (assumed to be MIDI numbers or equivalent, not pitch classes)
    character_dict:
        A dict specifying which characters to use when mapped to a string.
        Must include keys for [1, 0, -1] corresponding to up, repeat, and down.
        The default is Parsons' own values: {1: "u", 0: "r", -1: "d"}.
        Other options could include `<`, `=`, `>`.
    initial_asterisk:
        Optionally, include an initial `*` for the start of the sequence (no previous interval).

    Examples
    --------
    >>> happy = [60, 60, 62, 60, 65, 64, 60, 60, 62, 60, 67, 65, 60, 60, 72, 69, 65, 64, 62, 70, 69, 65, 67, 65]
    >>> pc = ParsonsContour(happy)
    >>> pc.interval_sequence
    [None, 0, 2, -2, 5, -1, -4, 0, 2, -2, 7, -2, -5, 0, 12, -3, -4, -1, -2, 8, -1, -4, 2, -2]

    >>> pc.interval_sequence_sign
    [None, 0, 1, -1, 1, -1, -1, 0, 1, -1, 1, -1, -1, 0, 1, -1, -1, -1, -1, 1, -1, -1, 1, -1]

    >>> pc.as_string
    'rududdrududdrudddduddud'

    >>> twinkle_ints = [72, 72, 79, 79, 81, 81, 79, 77, 77, 76, 76, 74, 74, 72]
    >>> pc = ParsonsContour(twinkle_ints)
    >>> pc.as_string
    'rururddrdrdrd'

    >>> pc_no_asterisk = ParsonsContour(twinkle_ints, initial_asterisk=True)
    >>> pc_no_asterisk.as_string
    '*rururddrdrdrd'

    >>> pc_symbols = ParsonsContour(twinkle_ints, {1: "<", 0: "=", -1: ">"})
    >>> pc_symbols.as_string
    '=<=<=>>=>=>=>'

    References
    ----------
    [1] Parsons, Denys. 1975. *The Directory of Tunes and Musical Themes*.
    """

    self.pitches = pitches
    self.character_dict = (
        character_dict if character_dict else {1: "u", 0: "r", -1: "d"}
    )
    self.initial_asterisk = initial_asterisk

    self.interval_sequence = None
    self.interval_sequence_sign = None
    self.as_string = None

    self.get_intervals()
    self.make_string()

Functions

make_string

make_string()

Create a flat, string representation of the contour directions.

Source code in amads/melody/contour/parsons_contour.py
100
101
102
103
104
105
106
107
108
def make_string(self):
    """Create a flat, string representation of the contour directions."""
    self.as_string = ""
    if self.initial_asterisk:
        self.as_string += "*"
    for i in range(1, len(self.interval_sequence_sign)):
        self.as_string += self.character_dict[
            self.interval_sequence_sign[i]
        ]

StepContour

StepContour(
    pitches: list[int],
    durations: list[float],
    step_contour_length: int = _step_contour_length,
)

Class for calculating and analyzing the step contour of a melody.

Also related features, as implemented in the FANTASTIC toolbox of Müllensiefen (2009) [1] (as features 20–22). Exemplified in Steinbeck (1982) [2], Juhász (2000) [3], Eerola and Toiviainen (2004) [4].

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.

Author: David Whyatt

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
2.3974
>>> sc.global_direction  # Correlation with ascending line
0.9746
>>> sc.local_variation  # Average absolute difference between adjacent values
0.1111

Parameters:

  • pitches (list[int]) –

    List of pitch values in any numeric format (e.g., MIDI numbers).

  • durations (list[float]) –

    List of note durations measured in tatums

  • step_contour_length (int, default: _step_contour_length ) –

    Length of the output step contour vector (default is 64)

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]
Source code in amads/melody/contour/step_contour.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def __init__(
    self,
    pitches: list[int],
    durations: list[float],
    step_contour_length: int = _step_contour_length,
):
    """Initialize StepContour with melody data.

    Parameters
    ----------
    pitches : list[int]
        List of pitch values in any numeric format (e.g., MIDI numbers).
    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)

    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)})"
        )

    self._step_contour_length = step_contour_length
    self.contour = self._calculate_contour(pitches, durations)

Attributes

global_variation property

global_variation: float

Calculate the global variation of the step contour by taking the standard deviation of the step contour vector.

Returns:

  • float

    Float value representing the global variation of the step contour

Examples:

>>> sc = StepContour([60, 62, 64], [1.0, 1.0, 1.0])
>>> sc.global_variation
1.64

global_direction property

global_direction: float

Calculate the global direction of the step contour by taking the correlation between the step contour vector and an ascending linear function y = x.

Returns:

  • float

    Float value representing the global direction of the step contour Returns 0.0 if the contour is flat

Examples:

>>> sc = StepContour([60, 62, 64], [1.0, 1.0, 1.0])
>>> sc.global_direction
0.943
>>> 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
-0.943

local_variation property

local_variation: float

Calculate the local variation of the step contour, by taking the mean absolute difference between adjacent values.

Returns:

  • float

    Float value representing the local variation of the step contour

Examples:

>>> sc = StepContour([60, 62, 64], [1.0, 1.0, 1.0])
>>> sc.local_variation
0.0634

Functions

_normalize_durations

_normalize_durations(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]
Source code in amads/melody/contour/step_contour.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
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

_expand_to_vector classmethod

_expand_to_vector(
    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]
Source code in amads/melody/contour/step_contour.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
@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

_calculate_contour

_calculate_contour(
    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]
Source code in amads/melody/contour/step_contour.py
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
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
    )

InterpolationContour

InterpolationContour(
    score: Optional[Score] = None,
    onsets: Optional[Sequence[float]] = None,
    pitches: Optional[Sequence[int]] = None,
    method: str = "amads",
)

Class for calculating and analyzing interpolated contours of melodies.

As implemented in the FANTASTIC toolbox of Müllensiefen (2009) [1] (as features 23–27). This representation was first formalised by Steinbeck (1982) [2], and informed a variant of the present implementation in Müllensiefen & Frieler (2004) [3].

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, and is the default method. All features are returned for either method.

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.

Author: David Whyatt

Parameters:

  • score (Score, default: None ) –

    If pitches and onsets are provided, use them. If not and a score is use that.

  • pitches (list[int], default: None ) –

    Pitch values in any numeric format (e.g., MIDI numbers).

  • onsets (list[float], default: None ) –

    Onset onsets in any consistent, proportional scheme (e.g., seconds, quarter notes, etc.)

  • method (str, default: 'amads' ) –

    Method to use for contour calculation, either "fantastic" or "amads". Defaults to "amads". The FANTASTIC method is the original implementation, and identifies turning points using contour extrema via a series of rules. The AMADS method instead identifies reversals for all melody lengths, and is the default method.

Raises:

  • ValueError

    If neither onsets and pitches or a score parameter are provided. If the onsets and pitches parameters 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, 65, 64, 62, 70, 69, 65, 67, 65
... ]
>>> happy_birthday_onsets = [
...     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(
...     pitches=happy_birthday_pitches,
...     onsets=happy_birthday_onsets,
...     method="fantastic",
... )
>>> ic.direction_changes
0.6
>>> ic.class_label
'ccbc'
>>> round(ic.mean_gradient, 6)
2.702857
>>> round(ic.gradient_std, 6)
5.65564
>>> 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. (2004). Cognitive Adequacy in the Measurement of Melodic Similarity: Algorithmic vs. Human Judgments

Source code in amads/melody/contour/interpolation_contour.py
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def __init__(
    self,
    score: Optional[Score] = None,
    onsets: Optional[Sequence[float]] = None,
    pitches: Optional[Sequence[int]] = None,
    method: str = "amads",
):
    """Initialize with pitch and time values.

    Parameters
    ----------
    score : Score, optional, default=None
        If `pitches` and `onsets` are provided, use them. If not and a `score` is use that.
    pitches : list[int]
        Pitch values in any numeric format (e.g., MIDI numbers).
    onsets : list[float]
        Onset onsets in any consistent, proportional scheme (e.g., seconds,
        quarter notes, etc.)
    method : str, optional
        Method to use for contour calculation, either "fantastic" or "amads".
        Defaults to "amads".
        The FANTASTIC method is the original implementation, and identifies
        turning points using contour extrema via a series of rules. The
        AMADS method instead identifies reversals for all melody lengths,
        and is the default method.

    Raises
    ------
    ValueError
        If neither `onsets` and `pitches` or a score parameter are provided.
        If the `onsets` and `pitches` parameters 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, 65, 64, 62, 70, 69, 65, 67, 65
    ... ]
    >>> happy_birthday_onsets = [
    ...     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(
    ...     pitches=happy_birthday_pitches,
    ...     onsets=happy_birthday_onsets,
    ...     method="fantastic",
    ... )
    >>> ic.direction_changes
    0.6
    >>> ic.class_label
    'ccbc'
    >>> round(ic.mean_gradient, 6)
    2.702857
    >>> round(ic.gradient_std, 6)
    5.65564
    >>> 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. (2004). Cognitive Adequacy in the
        Measurement of Melodic Similarity: Algorithmic vs. Human Judgments
    """
    none_checks = (onsets is not None, pitches is not None)
    if any(none_checks) and not all(none_checks):
        raise ValueError(
            "onsets and pitches must be provided together, not one without the other."
        )

    if all(none_checks):
        if len(onsets) != len(pitches):
            raise ValueError(
                f"onsets and pitches must have the same length, "
                f"got {len(onsets)} and {len(pitches)}."
            )
        self.onsets = list(onsets)
        self.pitches = list(pitches)
    else:
        if score is None:
            raise ValueError(
                "Provide either a Score or both onsets and pitches."
            )
        if not isinstance(score, Score):
            raise TypeError("Score should be a Score object.")

        self.onsets, self.pitches = self.get_onsets_and_pitches(score)

    if method not in ["fantastic", "amads"]:
        raise ValueError(
            f"Method must be either 'fantastic' or 'amads', got {method}"
        )
    self.method = method

    self.contour = self.calculate_interpolation_contour(
        pitches, onsets, method
    )

Attributes

global_direction property

global_direction: int

Calculate the global direction of the interpolation contour.

Takes the sign of the sum of all contour values. Can be invoked for either FANTASTIC or AMADS method.

Returns:

  • int

    1 if sum is positive, 0 if sum is zero, -1 if sum is negative

Examples:

Flat overall contour direction (returns the same using FANTASTIC method)

>>> ic = InterpolationContour(pitches=[60, 62, 64, 62, 60], onsets=[0, 1, 2, 3, 4])
>>> ic.global_direction
0

Upwards contour direction (returns the same using FANTASTIC method)

>>> ic = InterpolationContour(pitches=[60, 62, 64, 65, 67], onsets=[0, 1, 2, 3, 4])
>>> ic.global_direction
1

Downwards contour direction (returns the same using FANTASTIC method)

>>> ic = InterpolationContour(pitches=[67, 65, 67, 62, 60], onsets=[0, 1, 2, 3, 4])
>>> ic.global_direction
-1

mean_gradient property

mean_gradient: float

Calculate the absolute mean gradient of the interpolation contour. Can be invoked for either FANTASTIC or AMADS method.

Returns:

  • float

    Mean of the absolute gradient values

Examples:

Steps of 2 semitones per second

>>> ic = InterpolationContour(pitches=[60, 62, 64, 62, 60], onsets=[0, 1, 2, 3, 4])
>>> ic.mean_gradient
2.0

FANTASTIC method returns 0.0 for this example

>>> ic = InterpolationContour(pitches=[60, 62, 64, 62, 60], onsets=[0, 1, 2, 3, 4], method="fantastic")
>>> ic.mean_gradient
0.0

gradient_std property

gradient_std: float

Calculate the standard deviation of the interpolation contour gradients.

Can be invoked for either FANTASTIC or AMADS method.

Returns:

  • float

    Standard deviation of the gradient values (by default, using Bessel's correction)

Examples:

>>> ic = InterpolationContour(pitches=[60, 62, 64, 62, 60], onsets=[0, 1, 2, 3, 4])
>>> round(ic.gradient_std, 7)
2.0254787

FANTASTIC method returns 0.0 for this example

>>> ic = InterpolationContour(pitches=[60, 62, 64, 62, 60], onsets=[0, 1, 2, 3, 4], method="fantastic")
>>> ic.gradient_std
0.0

direction_changes property

direction_changes: float

Calculate the proportion of interpolated gradient values that consistute a change in direction. For instance, a gradient value of -0.5 to 0.25 is a change in direction. Can be invoked for either FANTASTIC or AMADS method.

Returns:

  • float

    Ratio of the number of changes in contour direction relative to the number of different interpolated gradient values

Examples:

>>> ic = InterpolationContour(pitches=[60, 62, 64, 62, 60], onsets=[0, 1, 2, 3, 4])
>>> ic.direction_changes
1.0

FANTASTIC method returns 0.0 for this example

>>> ic = InterpolationContour(pitches=[60, 62, 64, 62, 60], onsets=[0, 1, 2, 3, 4], method="fantastic")
>>> ic.direction_changes
0.0

class_label property

class_label: str

Classify an interpolation contour into gradient categories.

Can be invoked for either FANTASTIC or AMADS method.

The contour is sampled at 4 equally spaced points and each gradient is normalized to units of pitch change per second (expressed in units of semitones per 0.25 seconds.) The result is then classified into one of 5 categories:

  • 'a': Strong downward (-2) - normalized gradient <= -1.45
  • 'b': Downward (-1) - normalized gradient between -1.45 and -0.45
  • 'c': Flat (0) - normalized gradient between -0.45 and 0.45
  • 'd': Upward (1) - normalized gradient between 0.45 and 1.45
  • 'e': Strong upward (2) - normalized gradient >= 1.45

Returns:

  • str

    String of length 4 containing letters a-e representing the gradient categories at 4 equally spaced points in the contour

Examples:

Upwards, then downwards contour

>>> ic = InterpolationContour(pitches=[60, 62, 64, 62, 60], onsets=[0, 1, 2, 3, 4])
>>> ic.class_label
'ddbb'

FANTASTIC method returns 'cccc' for this example, as though the contour is flat

>>> ic = InterpolationContour(pitches=[60, 62, 64, 62, 60], onsets=[0, 1, 2, 3, 4], method="fantastic")
>>> ic.class_label
'cccc'

Functions

get_onsets_and_pitches

get_onsets_and_pitches(score: Score) -> tuple[list[float], list[int]]

Extract onset times and pitches from a Score object.

Parameters:

  • score (Score) –

    The Score object to extract data from

Returns:

  • tuple[list[float], list[int]]

    A tuple containing (onset_times, pitch_values)

Source code in amads/melody/contour/interpolation_contour.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def get_onsets_and_pitches(
    self, score: Score
) -> tuple[list[float], list[int]]:
    """Extract onset times and pitches from a Score object.

    Parameters
    ----------
    score : Score
        The Score object to extract data from

    Returns
    -------
    tuple[list[float], list[int]]
        A tuple containing (onset_times, pitch_values)
    """
    notes = score.get_sorted_notes()
    return [note.onset for note in notes], [note.key_num for note in notes]

_is_turning_point_fantastic staticmethod

_is_turning_point_fantastic(pitches: list[int], i: int) -> bool

Helper method to determine if a point is a turning point in FANTASTIC method.

Source code in amads/melody/contour/interpolation_contour.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
@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]
            ),
        ]
    )

calculate_interpolation_contour staticmethod

calculate_interpolation_contour(
    pitches: list[int], onsets: 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

Source code in amads/melody/contour/interpolation_contour.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
@staticmethod
def calculate_interpolation_contour(
    pitches: list[int], onsets: 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, onsets
        )

    return InterpolationContour._calculate_amads_contour(pitches, onsets)

_calculate_fantastic_contour staticmethod

_calculate_fantastic_contour(
    pitches: list[int], onsets: list[float]
) -> list[float]

Calculate the interpolation contour using the FANTASTIC method.

Utilises the helper function _is_turning_point_fantastic to identify turning points.

Source code in amads/melody/contour/interpolation_contour.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
@staticmethod
def _calculate_fantastic_contour(
    pitches: list[int], onsets: list[float]
) -> list[float]:
    """
    Calculate the interpolation contour using the FANTASTIC method.

    Utilises the helper function _is_turning_point_fantastic to identify
    turning points.
    """
    # Find candidate points
    candidate_points_pitch = [pitches[0]]  # Start with first pitch
    candidate_points_time = [onsets[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(onsets[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(onsets[i])

    # Initialize turning points with first note
    turning_points_pitch = [pitches[0]]
    turning_points_time = [onsets[0]]

    # Find turning points
    if len(candidate_points_pitch) > 2:
        for i in range(1, len(pitches) - 1):
            if onsets[i] in candidate_points_time:
                if pitches[i - 1] != pitches[i + 1]:
                    turning_points_pitch.append(pitches[i])
                    turning_points_time.append(onsets[i])

    # Add last note
    turning_points_pitch.append(pitches[-1])
    turning_points_time.append(onsets[-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
    sample_rate = 10  # 10 samples per second
    samples_per_duration = abs(
        np.round(durations * sample_rate).astype(int)
    )
    interpolation_contour = np.repeat(gradients, samples_per_duration)

    return [float(x) for x in interpolation_contour]

_remove_repeated_notes staticmethod

_remove_repeated_notes(
    pitches: list[int], onsets: list[float]
) -> tuple[list[int], list[float]]

Helper function to remove repeated notes, keeping only the middle occurrence.

This is used for the AMADS method to produce the interpolated gradient values at the middle of a sequence of repeated notes, should there be a reversal between the repeated notes.

Source code in amads/melody/contour/interpolation_contour.py
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
@staticmethod
def _remove_repeated_notes(
    pitches: list[int], onsets: list[float]
) -> tuple[list[int], list[float]]:
    """Helper function to remove repeated notes, keeping only the middle occurrence.

    This is used for the AMADS method to produce the interpolated gradient values
    at the middle of a sequence of repeated notes, should there be a reversal
    between the repeated notes.
    """
    unique_pitches, unique_onsets = [], []
    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_onsets.append(onsets[mid_idx])
        i += 1
    return unique_pitches, unique_onsets

_calculate_amads_contour staticmethod

_calculate_amads_contour(
    pitches: list[int], onsets: list[float]
) -> list[float]

Calculate the interpolation contour using the AMADS method.

Utilises the helper function _remove_repeated_notes.

Source code in amads/melody/contour/interpolation_contour.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
@staticmethod
def _calculate_amads_contour(
    pitches: list[int], onsets: list[float]
) -> list[float]:
    """
    Calculate the interpolation contour using the AMADS method.

    Utilises the helper function _remove_repeated_notes.
    """
    reversals_pitches = [pitches[0]]
    reversals_time = [onsets[0]]

    # Remove repeated notes
    pitches, onsets = InterpolationContour._remove_repeated_notes(
        pitches, onsets
    )

    # 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(onsets[i - 1])

    # Add last note
    reversals_pitches.append(pitches[-1])
    reversals_time.append(onsets[-1])

    # Calculate gradients
    gradients = np.diff(reversals_pitches) / np.diff(reversals_time)

    # Calculate durations
    durations = np.diff(reversals_time)

    # Create weighted gradients vector
    samples_per_duration = abs(np.round(durations * 10).astype(int))

    # 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]

plot

plot(ax=None)

Plot the melody notes and the interpolation contour gradients.

Displays two subplots (if ax is None):

  • Top: pitch values at their onset times as a scatter/step plot, with all original melody notes connected by lines.
  • Bottom: the interpolation contour — the piecewise-constant gradient values produced by calculate_interpolation_contour, plotted as a step function over normalised time (0–1).

Parameters:

  • ax (array-like of matplotlib.axes.Axes, default: None ) –

    A pair of Axes [ax_melody, ax_contour] to draw on. If None, a new figure with two vertically stacked subplots is created with figsize=(8, 5).

Returns:

  • tuple[Axes, Axes]

    (ax_melody, ax_contour) — the two axes, suitable for further customisation or embedding in a larger figure.

Raises:

  • ImportError

    If matplotlib is not installed.

Examples:

>>> ic = InterpolationContour(
...     pitches=[60, 62, 64, 62, 60],
...     onsets=[0, 1, 2, 3, 4],
... )
>>> ax_melody, ax_contour = ic.plot()
Source code in amads/melody/contour/interpolation_contour.py
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
def plot(self, ax=None):
    """
    Plot the melody notes and the interpolation contour gradients.

    Displays two subplots (if ``ax`` is None):

    * **Top**: pitch values at their onset times as a scatter/step plot,
      with all original melody notes connected by lines.
    * **Bottom**: the interpolation contour — the piecewise-constant
      gradient values produced by `calculate_interpolation_contour`,
      plotted as a step function over normalised time (0–1).

    Parameters
    ----------
    ax : array-like of matplotlib.axes.Axes, optional
        A pair of Axes ``[ax_melody, ax_contour]`` to draw on.  If
        ``None``, a new figure with two vertically stacked subplots is
        created with ``figsize=(8, 5)``.

    Returns
    -------
    tuple[matplotlib.axes.Axes, matplotlib.axes.Axes]
        ``(ax_melody, ax_contour)`` — the two axes, suitable for further
        customisation or embedding in a larger figure.

    Raises
    ------
    ImportError
        If matplotlib is not installed.

    Examples
    --------
    >>> ic = InterpolationContour(
    ...     pitches=[60, 62, 64, 62, 60],
    ...     onsets=[0, 1, 2, 3, 4],
    ... )
    >>> ax_melody, ax_contour = ic.plot()
    """
    try:
        import matplotlib.pyplot as plt
    except ImportError as e:
        raise ImportError(
            "matplotlib is required for plotting. "
            "Install it with: pip install matplotlib"
        ) from e

    if ax is None:
        _, (ax_melody, ax_contour) = plt.subplots(
            2, 1, figsize=(8, 5), constrained_layout=True
        )
    else:
        ax_melody, ax_contour = ax

    # Top panel: all melody notes with connecting lines
    ax_melody.plot(
        self.onsets,
        self.pitches,
        color="steelblue",
        linewidth=1.2,
        zorder=2,
    )
    ax_melody.scatter(
        self.onsets,
        self.pitches,
        color="steelblue",
        s=50,
        linewidths=0.8,
        edgecolors="white",
        zorder=3,
        label="Notes",
    )
    ax_melody.set_xlabel("Onset time", fontsize=10)
    ax_melody.set_ylabel("MIDI pitch", fontsize=10)
    ax_melody.set_title("Melody", fontsize=11)
    ax_melody.grid(True, linestyle="--", alpha=0.4)
    ax_melody.spines[["top", "right"]].set_visible(False)

    # Bottom panel: interpolation contour
    contour = self.contour
    # The contour is sampled at a rate of 10 samples per unit time.
    # Build a normalised time axis over [0, 1] for display.
    n = len(contour)
    norm_time = np.linspace(0, 1, n, endpoint=False)

    ax_contour.step(
        norm_time,
        contour,
        where="post",
        color="tomato",
        linewidth=1.8,
        label=f"Interpolation contour ({self.method})",
    )
    ax_contour.axhline(0, color="black", linewidth=0.8, linestyle="--")
    ax_contour.set_xlabel("Normalised time", fontsize=10)
    ax_contour.set_ylabel("Gradient (semitones / s)", fontsize=10)
    ax_contour.set_title(
        f"Interpolation contour  "
        f"[direction={self.global_direction:+d}, "
        f"mean={self.mean_gradient:.2f}, "
        f"class={self.class_label}]",
        fontsize=11,
    )
    ax_contour.legend(fontsize=9)
    ax_contour.grid(True, linestyle="--", alpha=0.4)
    ax_contour.spines[["top", "right"]].set_visible(False)

    return ax_melody, ax_contour

PolynomialContour

PolynomialContour(
    score: Optional[Score] = None,
    onsets: Optional[Sequence[float]] = None,
    pitches: Optional[Sequence[int]] = None,
)

A class for computing polynomial contour.

As described in the FANTASTIC toolbox [1]. This approach is discussed in detail in Müllensiefen and Wiggins (2011) [2].

Polynomial Contour is constructed in 3 simple steps:

  • First, the onsets are first centred around the origin of the time axis, making a symmetry between the first onset and the last.

  • Then, a polynomial model is fit, seeking to predict the pitch values from a least squares regression of the centred onset times.

  • Finally, the best model is selected using Bayes' Information Criterion, stepwise and in a backwards direction.

The final output is the coefficients of the first three non-constant terms, i.e. [c1, c2, c3] from p = c0 + c1t + c2t^2 + c3t^3.

Author: David Whyatt

Attributes:

  • coefficients (list[float]) –

    The polynomial contour coefficients. Returns the first 3 non-constant coefficients [c1, c2, c3] of the final selected polynomial contour model. The constant term is not included as per the FANTASTIC toolbox specification.

References
  1. Müllensiefen, D. (2009). Fantastic: Feature ANalysis Technology Accessing STatistics (In a Corpus): Technical Report v1.5
  2. Müllensiefen, D., & Wiggins, G.A. (2011). Polynomial functions as a representation of melodic phrase contour.

Examples:

Single note melodies return [0.0, 0.0, 0.0] since there is no contour:

>>> pc = PolynomialContour(onsets=[1.0], pitches=[60])
>>> pc.coefficients
[0.0, 0.0, 0.0]

Real melody examples:

>>> test_pitches = [62, 64, 65, 67, 64, 60, 62]
>>> test_case = Score.from_melody(pitches=test_pitches, durations=[1.0, 1.0, 1.0, 1.0, 2.0, 1.0, 1.0]) # duration
>>> test_case_pc = PolynomialContour(test_case)
>>> test_case_pc.onsets
[0.0, 1.0, 2.0, 3.0, 4.0, 6.0, 7.0]
>>> [round(x, 7) for x in test_case_pc.coefficients]  # Verified against FANTASTIC toolbox
[-1.5014826, -0.2661533, 0.122057]

The same result if this data comes from a score or directly.

>>> test_onsets = [0.0, 1.0, 2.0, 3.0, 4.0, 6.0, 7.0]
>>> test_case_pc.onsets == test_onsets
True
>>> test_pitches = [62, 64, 65, 67, 64, 60, 62]
>>> test_2 = PolynomialContour(onsets=test_onsets, pitches=test_pitches)
>>> test_2.onsets == test_onsets
True
>>> [round(x, 7) for x in test_2.coefficients]  # Verified against FANTASTIC toolbox
[-1.5014826, -0.2661533, 0.122057]
>>> twinkle = Score.from_melody([60, 60, 67, 67, 69, 69, 67, 65, 65, 64, 64, 62, 62, 60],
... [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0])
>>> pc3 = PolynomialContour(twinkle)
>>> [round(x, 7) for x in pc3.coefficients]  # Verified against FANTASTIC toolbox
[-0.9535562, 0.2120971, 0.0]
Source code in amads/melody/contour/polynomial_contour.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def __init__(
    self,
    score: Optional[Score] = None,
    onsets: Optional[Sequence[float]] = None,
    pitches: Optional[Sequence[int]] = None,
):
    none_checks = (onsets is not None, pitches is not None)
    if any(none_checks) and not all(none_checks):
        raise ValueError(
            "onsets and pitches must be provided together, not one without the other."
        )

    if all(none_checks):
        if len(onsets) != len(pitches):
            raise ValueError(
                f"onsets and pitches must have the same length, "
                f"got {len(onsets)} and {len(pitches)}."
            )
        self.onsets = list(onsets)
        self.pitches = list(pitches)
    else:
        if score is None:
            raise ValueError(
                "Provide either a Score or both onsets and pitches."
            )
        if not isinstance(score, Score):
            raise TypeError("Score should be a Score object.")

        self.onsets, self.pitches = self.get_onsets_and_pitches(score)

    self.coefficients = self.calculate_coefficients(
        self.onsets, self.pitches
    )

Functions

calculate_coefficients

calculate_coefficients(
    onsets: list[float], pitches: list[int]
) -> list[float]

Calculate polynomial contour coefficients for the melody. Main method for the PolynomialContour class.

Parameters:

  • onsets (list[float]) –

    List of onset times from the score

  • pitches (list[int]) –

    List of pitch values from the score

Returns:

  • list[float]

    First 3 coefficients [c1, c2, c3] of the polynomial contour, with zeros padded if needed. For melodies with fewer than 2 notes, returns [0.0, 0.0, 0.0] since there is no meaningful contour to analyze.

Source code in amads/melody/contour/polynomial_contour.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
def calculate_coefficients(
    self, onsets: list[float], pitches: list[int]
) -> list[float]:
    """Calculate polynomial contour coefficients for the melody.
    Main method for the PolynomialContour class.

    Parameters
    ----------
    onsets : list[float]
        List of onset times from the score
    pitches : list[int]
        List of pitch values from the score

    Returns
    -------
    list[float]
        First 3 coefficients [c1, c2, c3] of the polynomial contour, with zeros
        padded if needed. For melodies with fewer than 2 notes, returns [0.0, 0.0, 0.0]
        since there is no meaningful contour to analyze.
    """
    if len(onsets) <= 1:
        return [0.0, 0.0, 0.0]

    # Center onset times
    centered_onsets = self.center_onset_times(onsets)

    # Calculate polynomial degree
    m = len(onsets) // 2

    # Select best model using BIC
    return self.select_model(centered_onsets, pitches, m)

get_onsets_and_pitches

get_onsets_and_pitches(score: Score) -> tuple[list[float], list[int]]

Extract onset times and pitches from a Score object.

Parameters:

  • score (Score) –

    The Score object to extract data from

Returns:

  • tuple[list[float], list[int]]

    A tuple containing (onset_times, pitch_values)

Source code in amads/melody/contour/polynomial_contour.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def get_onsets_and_pitches(
    self, score: Score
) -> tuple[list[float], list[int]]:
    """Extract onset times and pitches from a Score object.

    Parameters
    ----------
    score : Score
        The Score object to extract data from

    Returns
    -------
    tuple[list[float], list[int]]
        A tuple containing (onset_times, pitch_values)
    """
    notes = score.get_sorted_notes()
    return [note.onset for note in notes], [note.key_num for note in notes]

center_onset_times

center_onset_times(onsets: list[float]) -> list[float]

Center onset times around their midpoint. This produces a symmetric axis of onset times, which is used later to fit the polynomial.

For single-note melodies, returns [0.0] since there is no meaningful contour to analyze.

Parameters:

  • onsets (list[float]) –

    List of onset times to center

Returns:

  • list[float]

    List of centered onset times. Returns [0.0] for single-note melodies.

Source code in amads/melody/contour/polynomial_contour.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def center_onset_times(self, onsets: list[float]) -> list[float]:
    """Center onset times around their midpoint. This produces a symmetric axis
    of onset times, which is used later to fit the polynomial.

    For single-note melodies, returns [0.0] since there is no meaningful contour
    to analyze.

    Parameters
    ----------
    onsets : list[float]
        List of onset times to center

    Returns
    -------
    list[float]
        List of centered onset times. Returns [0.0] for single-note melodies.
    """
    if len(onsets) <= 1:
        return [0.0] * len(onsets)

    # Calculate midpoint using first and last onset times
    midpoint = (onsets[0] + onsets[-1]) / 2
    return [time - midpoint for time in onsets]

fit_polynomial

fit_polynomial(
    centered_onsets: list[float], pitches: list[int], m: int
) -> list[float]

Fit a polynomial model to the melody contour using least squares regression.

The polynomial has the form: p = c0 + c1t + c2t^2 + ... + cm*t^m

where m = n // 2 (n = number of notes) and t are centered onset times.

Parameters:

  • centered_onsets (list[float]) –

    List of centered onset times

  • pitches (list[int]) –

    List of pitch values

  • m (int) –

    Maximum polynomial degree to use

Returns:

  • list[float]

    The coefficients [c0, c1, ..., cm] of the fitted polynomial

Source code in amads/melody/contour/polynomial_contour.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
def fit_polynomial(
    self, centered_onsets: list[float], pitches: list[int], m: int
) -> list[float]:
    """
    Fit a polynomial model to the melody contour using least squares regression.

    The polynomial has the form:
    p = c0 + c1*t + c2*t^2 + ... + cm*t^m

    where m = n // 2 (n = number of notes) and t are centered onset times.

    Parameters
    ----------
    centered_onsets : list[float]
        List of centered onset times
    pitches : list[int]
        List of pitch values
    m : int
        Maximum polynomial degree to use

    Returns
    -------
    list[float]
        The coefficients [c0, c1, ..., cm] of the fitted polynomial
    """

    n = len(pitches)
    if n <= 1:
        return [float(pitches[0]) if n == 1 else 0.0]

    # Create predictor matrix X where each column is t^i
    x = np.array(
        [[t**i for i in range(m + 1)] for t in centered_onsets], dtype=float
    )
    y = np.array(pitches, dtype=float)

    # Use numpy's least squares solver
    coeffs = np.linalg.lstsq(x, y, rcond=None)[0]

    return coeffs.tolist()

select_model

select_model(
    centered_onsets: list[float], pitches: list[int], m: int
) -> list[float]

Select the best polynomial model using BIC in an exhaustive search over all subsets of polynomial terms.

Tests all 2^(m+1) - 1 combinations of polynomial terms and selects the one with the best (lowest) BIC. The max degree is m = n // 2.

Note: the search space grows as O(2^m). This is fine for shot melodies (up to c.30 notes, m <= 15). Longer melodies will be slow and need a review of this method for performance.

Parameters:

  • centered_onsets (list[float]) –

    List of centered onset times

  • pitches (list[int]) –

    List of pitch values

  • m (int) –

    Maximum polynomial degree to consider

Returns:

  • list[float]

    The coefficients [c1, c2, c3] of the selected polynomial model, padded with zeros if the selected degree is less than 3.

Source code in amads/melody/contour/polynomial_contour.py
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
def select_model(
    self, centered_onsets: list[float], pitches: list[int], m: int
) -> list[float]:
    """Select the best polynomial model using BIC in an exhaustive search
    over all subsets of polynomial terms.

    Tests all 2^(m+1) - 1 combinations of polynomial terms and selects
    the one with the best (lowest) BIC. The max degree is m = n // 2.

    Note: the search space grows as O(2^m).
    This is fine for shot melodies (up to c.30 notes, m <= 15).
    Longer melodies will be slow and need a review of this method for performance.

    Parameters
    ----------
    centered_onsets : list[float]
        List of centered onset times
    pitches : list[int]
        List of pitch values
    m : int
        Maximum polynomial degree to consider

    Returns
    -------
    list[float]
        The coefficients [c1, c2, c3] of the selected polynomial model,
        padded with zeros if the selected degree is less than 3.
    """
    max_degree = m
    pitches_array = np.array(pitches, dtype=float)
    x_full = np.array(
        [[t**i for i in range(max_degree + 1)] for t in centered_onsets]
    )

    # Start with maximum degree model
    best_fit = self.fit_polynomial(centered_onsets, pitches, m)
    # Pad to at least degree-3 so indexing [1],[2],[3] is always safe
    best_coeffs = np.zeros(max(max_degree + 1, 4))
    best_coeffs[: len(best_fit)] = best_fit
    best_bic = self._calculate_bic(
        best_coeffs[: max_degree + 1], x_full, pitches_array
    )

    for i in range(1, 2 ** (max_degree + 1)):
        binary = format(i, f"0{max_degree + 1}b")
        degrees = [j for j in range(1, max_degree + 1) if binary[j] == "1"]

        if not degrees:
            continue

        x = np.ones((len(centered_onsets), len(degrees) + 1))
        for j, degree in enumerate(degrees):
            x[:, j + 1] = [t**degree for t in centered_onsets]

        coeffs = np.linalg.lstsq(x, pitches_array, rcond=None)[0]

        # Build a full coefficient array (padded to at least degree 3)
        test_coeffs = np.zeros(max(max_degree + 1, 4))
        test_coeffs[0] = coeffs[0]
        for j, degree in enumerate(degrees):
            test_coeffs[degree] = coeffs[j + 1]

        bic = self._calculate_bic(
            test_coeffs[: max_degree + 1], x_full, pitches_array
        )

        if bic < best_bic:
            best_coeffs = test_coeffs
            best_bic = bic

    return [
        best_coeffs[1].item(),  # convert to native float
        best_coeffs[2].item(),
        best_coeffs[3].item(),
    ]

_calculate_bic

_calculate_bic(coeffs: ndarray, x: ndarray, y: ndarray) -> float

Calculate BIC for a set of coefficients.

Emulates the FANTASTIC toolbox implementation, which uses stepAIC from the MASS package in R. Only non-zero coefficients are counted as parameters.

If the max value is 0, then a small epsilon is added to RSS. We do this before taking the log to guard against the case of a perfect fit (RSS = 0 → log(0) = -inf).

Parameters:

  • coeffs (ndarray) –

    Coefficient array (length must match x.shape[1])

  • x (ndarray) –

    Predictor matrix

  • y (ndarray) –

    Response vector

Returns:

  • float

    BIC value

Source code in amads/melody/contour/polynomial_contour.py
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
def _calculate_bic(
    self, coeffs: np.ndarray, x: np.ndarray, y: np.ndarray
) -> float:
    """Calculate BIC for a set of coefficients.

    Emulates the FANTASTIC toolbox implementation, which uses stepAIC from
    the MASS package in R. Only non-zero coefficients are counted as
    parameters.

    If the max value is 0, then a small epsilon is added to RSS.
    We do this before taking the log to guard against
    the case of a perfect fit (RSS = 0 → log(0) = -inf).

    Parameters
    ----------
    coeffs : np.ndarray
        Coefficient array (length must match x.shape[1])
    x : np.ndarray
        Predictor matrix
    y : np.ndarray
        Response vector

    Returns
    -------
    float
        BIC value
    """
    predictions = np.dot(x, coeffs)
    residuals = predictions - y
    rss = np.sum(residuals**2)
    rss = max(rss, 1e-10)  # guard against log(0) on perfect fits
    n = len(y)

    # Count only non-zero coefficients as parameters
    n_params = np.sum(np.abs(coeffs) > 1e-10)

    return n * np.log(rss / n) + n_params * np.log(n)

plot

plot(ax=None)

Plot the melody contour and the fitted polynomial curve.

Displays pitch values at their centered onset times (scatter) with the selected polynomial fit overlaid (line). The y-axis is labelled with note names derived from the MIDI pitch numbers.

Parameters:

  • ax (Axes, default: None ) –

    Axes to draw on. If None, a new figure and axes are created with figsize=(8, 4).

Returns:

  • Axes

    The axes containing the plot, suitable for further customisation or embedding in a larger figure.

Raises:

  • ImportError

    If matplotlib is not installed.

Source code in amads/melody/contour/polynomial_contour.py
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
def plot(self, ax=None):
    """Plot the melody contour and the fitted polynomial curve.

    Displays pitch values at their centered onset times (scatter) with the
    selected polynomial fit overlaid (line). The y-axis is labelled with
    note names derived from the MIDI pitch numbers.

    Parameters
    ----------
    ax : matplotlib.axes.Axes, optional
        Axes to draw on. If None, a new figure and axes are created
        with ``figsize=(8, 4)``.

    Returns
    -------
    matplotlib.axes.Axes
        The axes containing the plot, suitable for further customisation
        or embedding in a larger figure.

    Raises
    ------
    ImportError
        If matplotlib is not installed.

    """
    try:
        import matplotlib.pyplot as plt
        import matplotlib.ticker as ticker
    except ImportError as e:
        raise ImportError(
            "matplotlib is required for plotting. "
            "Install it with: pip install matplotlib"
        ) from e

    onsets = self.onsets
    pitches = self.pitches
    centered_onsets = self.center_onset_times(onsets)

    t = np.array(centered_onsets)
    c1, c2, c3 = self.coefficients

    # Recover the constant term c0 (not stored per FANTASTIC spec) as the
    # mean of the residuals after subtracting the known polynomial terms.
    poly_terms = c1 * t + c2 * t**2 + c3 * t**3
    c0 = float(np.mean(np.array(pitches, dtype=float) - poly_terms))

    t_smooth = np.linspace(t[0], t[-1], 300) if len(t) > 1 else t.copy()
    fit_curve = c0 + c1 * t_smooth + c2 * t_smooth**2 + c3 * t_smooth**3

    if ax is None:
        _, ax = plt.subplots(figsize=(8, 4))

    ax.scatter(
        centered_onsets,
        pitches,
        zorder=3,
        label="Notes",
        color="steelblue",
        s=60,
        linewidths=0.8,
        edgecolors="white",
    )
    ax.plot(
        t_smooth,
        fit_curve,
        color="tomato",
        linewidth=2,
        label=f"Contour [$c_{1}$={c1:.3f}, $c_{2}$={c2:.3f}, $c_{3}$={c3:.3f}]",
        zorder=2,
    )

    unique_pitches = sorted(set(pitches))
    ax.set_yticks(unique_pitches)
    ax.set_yticklabels(
        [key_num_to_name(p) for p in unique_pitches], fontsize=9
    )

    ax.xaxis.set_major_formatter(
        ticker.FuncFormatter(lambda x, _: f"{x:+.1f}")
    )
    ax.set_xlabel("Centered onset time", fontsize=10)
    ax.set_ylabel("Pitch", fontsize=10)
    ax.set_title("Polynomial contour", fontsize=11)
    ax.legend(fontsize=9)
    ax.grid(True, linestyle="--", alpha=0.4)
    ax.spines[["top", "right"]].set_visible(False)

    return ax

fantastic_pitch_features

fantastic_pitch_features(score: Score) -> Dict

Extract pitch features from a melody.

Author: David Whyatt

Parameters:

  • score (Score) –

    The score to extract pitch features from.

Returns:

  • Dict

    A dictionary of pitch features. Dictionary keys: - pitch_range: The range of pitches in the melody. - pitch_std: The standard deviation of the pitches in the melody. - pitch_entropy: A variant of the Shannon entropy of the pitches in the melody.

Source code in amads/melody/fantastic.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def fantastic_pitch_features(score: Score) -> Dict:
    """Extract pitch features from a melody.

    <small>**Author**: David Whyatt</small>

    Parameters
    ----------
    score : Score
        The score to extract pitch features from.

    Returns
    -------
    Dict
        A dictionary of pitch features.
        Dictionary keys:
            - pitch_range: The range of pitches in the melody.
            - pitch_std: The standard deviation of the pitches in the melody.
            - pitch_entropy: A variant of the Shannon entropy of the pitches in the melody.
    """
    notes = score.get_sorted_notes()

    pitches = [note.pitch.key_num for note in notes]

    pitch_range = max(pitches) - min(pitches)
    pitch_std = np.std(pitches)

    # Calculate pitch entropy using Shannon's formula
    # First get frequency distribution of pitches
    pitch_counts = Counter(pitches)
    total_pitches = len(pitches)

    # Calculate relative frequencies
    pitch_freqs = {
        p: count / total_pitches for p, count in pitch_counts.items()
    }

    # Calculate entropy using the formula from the FANTASTIC toolbox
    pitch_entropy = -sum(
        f * np.log2(f) for f in pitch_freqs.values()
    ) / np.log2(24)

    return {
        "pitch_range": pitch_range,
        "pitch_std": pitch_std,
        "pitch_entropy": pitch_entropy,
    }

fantastic_pitch_interval_features

fantastic_pitch_interval_features(score: Score) -> Dict

Extract pitch interval features from a melody.

Author: David Whyatt

Parameters:

  • score (Score) –

    The score to extract pitch interval features from.

Returns:

  • Dict

    A dictionary of pitch interval features. Dictionary keys: - absolute_interval_range: The range of absolute pitch intervals in the melody. - mean_absolute_interval: The mean of the absolute pitch intervals in the melody. - std_absolute_interval: The standard deviation of the absolute pitch intervals in the melody. - modal_interval: The modal absolute pitch interval in the melody. - interval_entropy: A variant of the Shannon entropy of the absolute pitch intervals in the melody.

Source code in amads/melody/fantastic.py
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def fantastic_pitch_interval_features(score: Score) -> Dict:
    """Extract pitch interval features from a melody.

    <small>**Author**: David Whyatt</small>

    Parameters
    ----------
    score : Score
        The score to extract pitch interval features from.

    Returns
    -------
    Dict
        A dictionary of pitch interval features.
        Dictionary keys:
            - absolute_interval_range: The range of absolute pitch intervals in the melody.
            - mean_absolute_interval: The mean of the absolute pitch intervals in the melody.
            - std_absolute_interval: The standard deviation of the absolute pitch intervals in the melody.
            - modal_interval: The modal absolute pitch interval in the melody.
            - interval_entropy: A variant of the Shannon entropy of the absolute pitch intervals in the melody.
    """
    notes = score.get_sorted_notes()

    pitches = [note.pitch.key_num for note in notes]
    # Fantastic defines intervals by looking forwards
    intervals = [pitches[i + 1] - pitches[i] for i in range(len(pitches) - 1)]
    # and then always uses the absolute value
    abs_intervals = [abs(interval) for interval in intervals]

    absolute_interval_range = max(abs_intervals) - min(abs_intervals)
    mean_absolute_interval = np.mean(abs_intervals)
    std_absolute_interval = np.std(abs_intervals)
    modal_interval = max(set(abs_intervals), key=abs_intervals.count)

    # Calculate interval entropy using Shannon's formula
    # First get frequency distribution of intervals
    interval_counts = Counter(abs_intervals)
    total_intervals = len(abs_intervals)

    # Calculate relative frequencies
    interval_freqs = {
        i: count / total_intervals for i, count in interval_counts.items()
    }

    # Calculate entropy using the formula from the FANTASTIC toolbox
    # Note that the maximum number of different intervals is instead 23 here
    interval_entropy = -sum(
        f * np.log2(f) for f in interval_freqs.values()
    ) / np.log2(23)

    return {
        "absolute_interval_range": absolute_interval_range,
        "mean_absolute_interval": mean_absolute_interval,
        "std_absolute_interval": std_absolute_interval,
        "modal_interval": modal_interval,
        "interval_entropy": interval_entropy,
    }

fantastic_duration_features

fantastic_duration_features(score: Score) -> Dict

Extract duration features from a melody.

Author: David Whyatt

Parameters:

  • score (Score) –

    The score to extract duration features from.

Returns:

  • Dict

    A dictionary of duration features. Dictionary keys:

Source code in amads/melody/fantastic.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def fantastic_duration_features(score: Score) -> Dict:
    """Extract duration features from a melody.

    <small>**Author**: David Whyatt</small>

    Parameters
    ----------
    score : Score
        The score to extract duration features from.

    Returns
    -------
    Dict
        A dictionary of duration features.
        Dictionary keys:
    """

    raise NotImplementedError("Not implemented yet")

fantastic_global_features

fantastic_global_features(score: Score) -> Dict

Extract global extension features from a melody.

Author: David Whyatt

Parameters:

  • score (Score) –

    The score to extract global extension features from.

Returns:

  • Dict

    A dictionary of global extension features. Dictionary keys:

Source code in amads/melody/fantastic.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
def fantastic_global_features(score: Score) -> Dict:
    """Extract global extension features from a melody.

    <small>**Author**: David Whyatt</small>

    Parameters
    ----------
    score : Score
        The score to extract global extension features from.

    Returns
    -------
    Dict
        A dictionary of global extension features.
        Dictionary keys:
    """

    raise NotImplementedError("Not implemented yet")

fantastic_step_contour_features

fantastic_step_contour_features(score: Score) -> Dict

Extract step contour features from a melody.

Author: David Whyatt

Parameters:

  • score (Score) –

    The score to extract step contour features from.

Returns:

  • Dict

    A dictionary of step contour features. Dictionary keys: - global_variation: The global variation of the step contour. - global_direction: The global direction of the step contour. - local_variation: The local variation of the step contour.

Source code in amads/melody/fantastic.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def fantastic_step_contour_features(score: Score) -> Dict:
    """Extract step contour features from a melody.

    <small>**Author**: David Whyatt</small>

    Parameters
    ----------
    score : Score
        The score to extract step contour features from.

    Returns
    -------
    Dict
        A dictionary of step contour features.
        Dictionary keys:
            - global_variation: The global variation of the step contour.
            - global_direction: The global direction of the step contour.
            - local_variation: The local variation of the step contour.
    """
    notes = score.get_sorted_notes()

    # Extract pitches and times for contour calculation
    pitches = [note.pitch.key_num for note in notes]
    durations = [note.duration for note in notes]

    sc = StepContour(pitches, durations)

    return {
        "global_variation": sc.global_variation,
        "global_direction": sc.global_direction,
        "local_variation": sc.local_variation,
    }

fantastic_interpolation_contour_features

fantastic_interpolation_contour_features(score: Score) -> Dict

Extract interpolation contour features from a melody.

Author: David Whyatt

Parameters:

  • score (Score) –

    The score to extract interpolation contour features from.

Returns:

  • Dict

    A dictionary of interpolation contour features. Dictionary keys: - global_direction: The global direction of the interpolation contour. - mean_gradient: The mean gradient of the interpolation contour. - gradient_std: The standard deviation of the gradient of the interpolation contour. - direction_changes: The number of direction changes in the interpolation contour. - class_label: The class label of the interpolation contour.

Source code in amads/melody/fantastic.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def fantastic_interpolation_contour_features(score: Score) -> Dict:
    """Extract interpolation contour features from a melody.

    <small>**Author**: David Whyatt</small>

    Parameters
    ----------
    score : Score
        The score to extract interpolation contour features from.

    Returns
    -------
    Dict
        A dictionary of interpolation contour features.
        Dictionary keys:
            - global_direction: The global direction of the interpolation contour.
            - mean_gradient: The mean gradient of the interpolation contour.
            - gradient_std: The standard deviation of the gradient of the interpolation contour.
            - direction_changes: The number of direction changes in the interpolation contour.
            - class_label: The class label of the interpolation contour.
    """
    notes = score.get_sorted_notes()

    # Calculate contour
    ic = InterpolationContour(
        pitches=[note.pitch.key_num for note in notes],
        onsets=[note.onset for note in notes],
        method="fantastic",
    )

    return {
        # Interpolation contour features
        "global_direction": ic.global_direction,
        "mean_gradient": ic.mean_gradient,
        "gradient_std": ic.gradient_std,
        "direction_changes": ic.direction_changes,
        "class_label": ic.class_label,
    }

fantastic_parsons_contour_features

fantastic_parsons_contour_features(
    score: Score,
    character_dict: Dict = None,
    initial_asterisk: bool = False,
) -> Dict

Extract Parsons contour features from a melody.

Author: David Whyatt

Parameters:

  • score (Score) –

    The score to extract Parsons contour features from.

Returns:

  • Dict

    A dictionary of Parsons contour features. Dictionary keys: - interval_sequence: The interval sequence of the melody. - interval_sequence_sign: A representation of the direction of the interval sequence using -1, 0, and 1 to represent down, repeat, and up intervals respectively. - as_string: The Parsons contour as a string, using the characters u, r, and d to represent up, repeat, and down intervals respectively.

Source code in amads/melody/fantastic.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
def fantastic_parsons_contour_features(
    score: Score, character_dict: Dict = None, initial_asterisk: bool = False
) -> Dict:
    """Extract Parsons contour features from a melody.

    <small>**Author**: David Whyatt</small>

    Parameters
    ----------
    score : Score
        The score to extract Parsons contour features from.

    Returns
    -------
    Dict
        A dictionary of Parsons contour features.
        Dictionary keys:
            - interval_sequence: The interval sequence of the melody.
            - interval_sequence_sign: A representation of the direction of the interval sequence
                using -1, 0, and 1 to represent down, repeat, and up intervals respectively.
            - as_string: The Parsons contour as a string, using the characters u, r, and d
                to represent up, repeat, and down intervals respectively.
    """

    notes = score.get_sorted_notes()

    pitches = [note.pitch.key_num for note in notes]
    pc = ParsonsContour(
        pitches,
        character_dict=character_dict,
        initial_asterisk=initial_asterisk,
    )

    return {
        "interval_sequence": pc.interval_sequence,
        "interval_sequence_sign": pc.interval_sequence_sign,
        "as_string": pc.as_string,
    }

fantastic_polynomial_contour_features

fantastic_polynomial_contour_features(score: Score) -> Dict

Extract polynomial contour features from a melody.

Author: David Whyatt

Parameters:

  • score (Score) –

    The score to extract polynomial contour features from.

Returns:

  • Dict

    A dictionary of polynomial contour coefficients. Dictionary keys: - coefficients: The coefficients of the polynomial contour.

Source code in amads/melody/fantastic.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
def fantastic_polynomial_contour_features(score: Score) -> Dict:
    """Extract polynomial contour features from a melody.

    <small>**Author**: David Whyatt</small>

    Parameters
    ----------
    score : Score
        The score to extract polynomial contour features from.

    Returns
    -------
    Dict
        A dictionary of polynomial contour coefficients.
        Dictionary keys:
            - coefficients: The coefficients of the polynomial contour.
    """

    pc = PolynomialContour(score)

    return {
        "coefficients": pc.coefficients,
    }

fantastic_huron_contour_features

fantastic_huron_contour_features(score: Score) -> Dict

Extract Huron contour features from a melody.

Author: David Whyatt

Parameters:

  • score (Score) –

    The score to extract Huron contour features from.

Returns:

  • Dict

    A dictionary of Huron contour features. Dictionary keys: - first_pitch: The first pitch of the melody. - mean_pitch: The mean pitch of the melody. - last_pitch: The last pitch of the melody. - first_to_mean: The difference between the first and mean pitch. - mean_to_last: The difference between the mean and last pitch. - contour_class: The class of the Huron contour.

Source code in amads/melody/fantastic.py
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
def fantastic_huron_contour_features(score: Score) -> Dict:
    """Extract Huron contour features from a melody.

    <small>**Author**: David Whyatt</small>

    Parameters
    ----------
    score : Score
        The score to extract Huron contour features from.

    Returns
    -------
    Dict
        A dictionary of Huron contour features.
        Dictionary keys:
            - first_pitch: The first pitch of the melody.
            - mean_pitch: The mean pitch of the melody.
            - last_pitch: The last pitch of the melody.
            - first_to_mean: The difference between the first and mean pitch.
            - mean_to_last: The difference between the mean and last pitch.
            - contour_class: The class of the Huron contour.
    """
    notes = score.get_sorted_notes()

    pitches = [note.pitch.key_num for note in notes]
    times = [note.onset for note in notes]

    hc = HuronContour(pitches, times)

    return {
        "first_pitch": hc.first_pitch,
        "mean_pitch": hc.mean_pitch,
        "last_pitch": hc.last_pitch,
        "first_to_mean": hc.first_to_mean,
        "mean_to_last": hc.mean_to_last,
        "contour_class": hc.contour_class,
    }

fantastic_count_mtypes

fantastic_count_mtypes(
    score: Score, segment: bool, phrase_gap: float, units: str
) -> NGramCounter

Count M-Types in a melody.

Author: David Whyatt

Parameters:

  • score (Score) –

    The score to count M-Types in.

  • segment (bool) –

    Whether to segment the melody into phrases.

  • phrase_gap (float) –

    The minimum IOI gap to consider a new phrase.

  • units (str) –

    The units of the phrase gap, either "seconds" or "quarters".

Returns:

  • NGramCounter

    An NGramCounter object containing the counts of M-Types. This allows for the computation of the complexity measures, either by accessing the properties of the NGramCounter object or by using the fantastic_mtype_summary_features function.

Source code in amads/melody/fantastic.py
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
def fantastic_count_mtypes(
    score: Score, segment: bool, phrase_gap: float, units: str
) -> NGramCounter:
    """Count M-Types in a melody.

    <small>**Author**: David Whyatt</small>

    Parameters
    ----------
    score : Score
        The score to count M-Types in.
    segment : bool
        Whether to segment the melody into phrases.
    phrase_gap : float
        The minimum IOI gap to consider a new phrase.
    units : str
        The units of the phrase gap, either "seconds" or "quarters".

    Returns
    -------
    NGramCounter
        An NGramCounter object containing the counts of M-Types.
        This allows for the computation of the complexity measures, either
        by accessing the properties of the NGramCounter object or by using
        the `fantastic_mtype_summary_features` function.
    """
    if segment:
        segments = fantastic_segmenter(score, phrase_gap, units)
    else:
        segments = [score]

    counter = NGramCounter()
    tokenizer = FantasticTokenizer()

    all_tokens = []
    for phrase in segments:
        tokens = tokenizer.tokenize(phrase)
        all_tokens.extend(tokens)

    counter.count_ngrams(all_tokens, n=[1, 2, 3, 4, 5])

    return counter

fantastic_mtype_summary_features

fantastic_mtype_summary_features(
    score: Score, segment: bool, phrase_gap: float, units: str
) -> Dict

Count M-Types in a melody and compute summary features.

This function provides an easy way to get all the complexity measures at once.

Author: David Whyatt

Parameters:

  • score (Score) –

    The score to count M-Types in.

  • segment (bool) –

    Whether to segment the melody into phrases.

  • phrase_gap (float) –

    The minimum IOI gap to consider a new phrase.

  • units (str) –

    The units of the phrase gap, either "seconds" or "quarters".

Returns:

  • Dict

    A dictionary of summary features. Dictionary keys:

    - mean_entropy: The mean entropy of the M-Types.
    - mean_productivity: The mean productivity of the M-Types.
    - yules_k: The mean Yules K statistic.
    - simpsons_d: The mean Simpson's D statistic.
    - sichels_s: The mean Sichels S statistic.
    - honores_h: The mean Honores H statistic.
    
Source code in amads/melody/fantastic.py
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
def fantastic_mtype_summary_features(
    score: Score, segment: bool, phrase_gap: float, units: str
) -> Dict:
    """Count M-Types in a melody and compute summary features.

    This function provides an easy way to get all the complexity measures
    at once.

    <small>**Author**: David Whyatt</small>

    Parameters
    ----------
    score : Score
        The score to count M-Types in.
    segment : bool
        Whether to segment the melody into phrases.
    phrase_gap : float
        The minimum IOI gap to consider a new phrase.
    units : str
        The units of the phrase gap, either "seconds" or "quarters".

    Returns
    -------
    Dict
        A dictionary of summary features.
        Dictionary keys:

            - mean_entropy: The mean entropy of the M-Types.
            - mean_productivity: The mean productivity of the M-Types.
            - yules_k: The mean Yules K statistic.
            - simpsons_d: The mean Simpson's D statistic.
            - sichels_s: The mean Sichels S statistic.
            - honores_h: The mean Honores H statistic.
    """
    mtype_counts = fantastic_count_mtypes(score, segment, phrase_gap, units)

    return {
        "mean_entropy": mtype_counts.mean_entropy,
        "mean_productivity": mtype_counts.mean_productivity,
        "yules_k": mtype_counts.yules_k,
        "simpsons_d": mtype_counts.simpsons_d,
        "sichels_s": mtype_counts.sichels_s,
        "honores_h": mtype_counts.honores_h,
    }