Source code for melody_features.feature_definitions.tonality

"""Tonality feature definitions."""

from typing import Dict, Literal, Optional

import numpy as np

from ..algorithms import compute_tonality_vector
from ..utils.distributional import histogram_bins
from .expectation import _get_key_distances
from ..feature_decorators import fantastic, idyom, midi_toolbox, novel, partitura, pitch, tonality
from ..core.representations import Melody
from ..algorithms.tonal_tension import ALPHA, BETA, DEFAULT_WEIGHTS, SCALE_FACTOR, estimate_tonaltension


__all__ = [
    "infer_key_from_pitches",
    "key",
    "keyname",
    "tonalness",
    "tonal_clarity",
    "tonal_spike",
    "referent",
    "tonal_tension",
    "mean_cloud_diameter",
    "std_cloud_diameter",
    "mean_cloud_momentum",
    "std_cloud_momentum",
    "mean_tensile_strain",
    "std_tensile_strain",
    "tonalness_histogram",
    "inscale",
    "proportion_inscale",
    "longest_monotonic_conjunct_scalar_passage",
    "longest_conjunct_scalar_passage",
    "proportion_conjunct_scalar",
    "proportion_scalar",
    "mode",
    "get_tonality_features",
]


[docs] def infer_key_from_pitches( pitches: list[int], algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler" ) -> tuple[Optional[str], Optional[str]]: """ Infer the key of a melody using the specified algorithm. Parameters ---------- pitches : list[int] List of MIDI pitch values algorithm : Literal["krumhansl_schmuckler"], optional Key-finding algorithm to use. Default is "krumhansl_schmuckler". Returns ------- tuple[Optional[str], Optional[str]] (key_name, mode) e.g., ("C", "major") or (None, None) if cannot determine Raises ------ NotImplementedError If algorithm is not supported Citations -------- Krumhansl (1990) """ if algorithm != "krumhansl_schmuckler": raise NotImplementedError( f"Key-finding algorithm '{algorithm}' is not implemented. " f"Currently only 'krumhansl_schmuckler' is supported." ) pitch_classes = [p % 12 for p in pitches] correlations = compute_tonality_vector(pitch_classes) if not correlations: return None, None key_string = correlations[0][0] # e.g., "C major" parts = key_string.split() key_name = parts[0] mode = parts[1] if len(parts) > 1 else "major" return key_name, mode
def _normalize_key_root(root: str) -> str: """Normalize key root names to a canonical display form.""" if not root: return root root = root.strip() if not root: return root return root[0].upper() + root[1:] def _canonical_key_string(key_name: Optional[str], mode: Optional[str]) -> Optional[str]: """Return canonical key strings like 'C major' or 'F# minor'.""" if not key_name or not mode: return None mode_normalized = mode.strip().lower() if mode_normalized not in {"major", "minor"}: return None root = _normalize_key_root(key_name) return f"{root} {mode_normalized}" def _resolve_key_for_melody( melody: Melody, key_estimation: Literal["always_read_from_file", "infer_if_necessary", "always_infer"] = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler", ) -> Optional[str]: """Resolve a melody key using the requested key-estimation policy.""" inferred_key = None inferred_key_name, inferred_mode = infer_key_from_pitches( melody.pitches, algorithm=key_finding_algorithm ) inferred_key = _canonical_key_string(inferred_key_name, inferred_mode) key_from_melody = None if melody.has_key_signature: key_sig = melody.key_signature if key_sig and len(key_sig) >= 2: key_root = key_sig[0] mode = key_sig[1] if isinstance(key_root, str) and key_root.endswith("m") and mode == "minor": key_root = key_root[:-1] key_from_melody = _canonical_key_string(key_root, mode) if key_estimation == "always_infer": return inferred_key if key_estimation == "always_read_from_file": if key_from_melody is None: raise ValueError(f"No key signature found in MIDI file: {melody.id}") return key_from_melody # key_estimation == "infer_if_necessary" return key_from_melody if key_from_melody is not None else inferred_key def _tonality_correlations_for_key(correlations: list[tuple[str, float]], key_string: str) -> list[tuple[str, float]]: """Return correlations reordered so the requested key is first.""" normalized_target = key_string.lower() for index, (candidate_key, _) in enumerate(correlations): if candidate_key.lower() == normalized_target: if index == 0: return correlations return [correlations[index], *correlations[:index], *correlations[index + 1 :]] # Fallback keeps downstream scalar helpers deterministic for chosen key. return [(key_string, 1.0), *correlations]
[docs] @midi_toolbox @tonality @pitch def key( melody: Melody, key_estimation: Literal["always_read_from_file", "infer_if_necessary", "always_infer"] = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler" ) -> str: """ The key of the melody, either read from the MIDI file or estimated using the specified key finding algorithm, depending on the key estimation strategy. Parameters ---------- melody : Melody A melody-features Melody object key_estimation : str, optional Key estimation strategy, default "infer_if_necessary" Can be "always_read_from_file", "infer_if_necessary", or "always_infer" key_finding_algorithm : Literal["krumhansl_schmuckler"], optional Key-finding algorithm to use when inferring key. Default is "krumhansl_schmuckler". Returns ------- str The key of the melody, in the format "key name major/minor" Citation ---------- Krumhansl (1990) Note ----- This feature is named `keyname` in MIDI Toolbox. """ resolved_key = _resolve_key_for_melody( melody, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm, ) return resolved_key if resolved_key else "unknown"
keyname = key
[docs] @fantastic @tonality @pitch def tonalness(pitches: list[int]) -> float: """The magnitude of the highest correlation with a precomputed key profile. This key profile is established and elaborated on in Krumhansl (1990). Parameters ---------- pitches : list[int] List of MIDI pitch values Returns ------- float Magnitude of highest key correlation value Citation -------- Krumhansl (1990) """ pitch_classes = [pitch % 12 for pitch in pitches] correlation = compute_tonality_vector(pitch_classes) if not correlation: return 0.0 return abs(correlation[0][1])
[docs] @fantastic @tonality @pitch def tonal_clarity(pitches: list[int]) -> float: """The ratio between the top two key correlation values. Citation ------------------ Temperley (2007) Parameters ---------- pitches : list[int] List of MIDI pitch values Returns ------- float Ratio between highest and second highest key correlation values. Returns `0.0` when top/second correlations are unavailable or near-zero. """ pitch_classes = [pitch % 12 for pitch in pitches] correlations = compute_tonality_vector(pitch_classes) if len(correlations) < 2: return 0.0 # Get top 2 correlation values top_corr = abs(correlations[0][1]) second_corr = abs(correlations[1][1]) # Avoid division by zero if top_corr <= 0 or second_corr <= 0: return 0.0 return top_corr / second_corr
[docs] @fantastic @tonality @pitch def tonal_spike(pitches: list[int]) -> float: """The ratio between the highest key correlation and the sum of all other correlations. Parameters ---------- pitches : list[int] List of MIDI pitch values Returns ------- float Ratio between highest correlation value and sum of all others. Returns `0.0` when top/other correlations are unavailable or near-zero. """ pitch_classes = [pitch % 12 for pitch in pitches] correlations = compute_tonality_vector(pitch_classes) if len(correlations) < 2: return 0.0 # Get highest correlation and sum of rest top_corr = abs(correlations[0][1]) other_sum = sum(abs(corr[1]) for corr in correlations[1:]) # Avoid division by zero if top_corr <= 0 or other_sum <= 0: return 0.0 return top_corr / other_sum
[docs] @idyom @tonality @pitch def referent( melody: Melody, key_estimation: Literal["always_read_from_file", "infer_if_necessary", "always_infer"] = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler", ) -> int: """ Calculate the `referent` (pitch-class root) of a melody. Parameters ---------- melody : Melody The melody to analyze Returns ------- int Pitch class in semitones above C for the resolved key root; returns -1 when no key can be resolved. """ resolved_key = _resolve_key_for_melody( melody, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm, ) if not resolved_key: return -1 key_name = resolved_key.split()[0] key_distances = _get_key_distances() return key_distances.get(key_name, -1)
[docs] @partitura @tonality @pitch def tonal_tension( melody: Melody, ws: float = 1.0, ss: str = "onset", scale_factor: float = SCALE_FACTOR, w: np.ndarray = DEFAULT_WEIGHTS, alpha: float = ALPHA, beta: float = BETA, tonality_vector: Optional[list] = None, key_estimation: Literal["always_read_from_file", "infer_if_necessary", "always_infer"] = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler" ) -> dict: """Computes tension ribbons using the tonal tension algorithm. Provides a means of comparing Chew's spiral array and the tonal tension profiles produced from Herremans and Chew's tension ribbons. This returns a dictionary containing the cloud diameter, cloud momentum, tensile strain, ordered by onset. Parameters ---------- melody : Melody A melody-features Melody object. ws : float, optional Window size in beats. Default is 1.0 beat. ss : str, optional Step size in beats or score position for computing the tonal tension features. Default is "onset" (compute at each unique score position). scale_factor : float, optional Multiplicative scaling factor. Default uses the distance between C and B#. w : np.ndarray, optional Weights for the chords. Default is [0.516, 0.315, 0.168] alpha : float, optional Preference for V vs v chord in minor key (0-1). Default is 0.75. beta : float, optional Preference for iv vs IV in minor key (0-1). Default is 0.75. tonality_vector : list, optional Pre-computed tonality vector (list of (key_name, correlation) tuples). Default is None. key_estimation : Literal["always_read_from_file", "infer_if_necessary", "always_infer"], optional Key estimation strategy: "always_read_from_file", "infer_if_necessary", or "always_infer". Default is "infer_if_necessary". key_finding_algorithm : Literal["krumhansl_schmuckler"], optional Key-finding algorithm to use when inferring key. Default is "krumhansl_schmuckler". Returns ------- dict Dictionary containing tonal tension values keyed by `cloud_diameter`, `cloud_momentum`, and `tensile_strain`, along with onset-aligned index fields from Partitura. Citation -------- Herremans & Chew (2016) """ return estimate_tonaltension( melody, ws=ws, ss=ss, scale_factor=scale_factor, w=w, alpha=alpha, beta=beta, tonality_vector=tonality_vector, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm )
[docs] @partitura @tonality @pitch def mean_cloud_diameter( melody: Melody, ws: float = 1.0, ss: str = "onset", scale_factor: float = SCALE_FACTOR, w: np.ndarray = DEFAULT_WEIGHTS, alpha: float = ALPHA, beta: float = BETA, tonality_vector: Optional[list] = None, key_estimation: str = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler" ) -> float: """Mean cloud diameter from the tonal tension model. Cloud Diameter provides a measure of the maximal tonal distance of the notes in a chord, following the definition in Partitura. Parameters ---------- melody : Melody A melody-features Melody object ws : float, optional Window size in beats. Default is 1.0 beat. ss : str, optional Step size or score position for computing the tonal tension features. Default is "onset" (compute at each unique score position). scale_factor : float, optional Multiplicative scaling factor. Default uses the distance between C and B#. w : np.ndarray, optional Weights for the chords. Default is [0.516, 0.315, 0.168]. alpha : float, optional Preference for V vs v chord in minor key (0-1). Default is 0.75. beta : float, optional Preference for iv vs IV in minor key (0-1). Default is 0.75. tonality_vector : list, optional Pre-computed tonality vector (list of (key_name, correlation) tuples). Default is None. key_estimation : str, optional Key estimation strategy, default "infer_if_necessary" key_finding_algorithm : Literal["krumhansl_schmuckler"], optional Key-finding algorithm to use when inferring key. Default is "krumhansl_schmuckler". Returns ------- float Mean cloud diameter value Citation -------- Herremans & Chew (2016) """ tension_dict = estimate_tonaltension( melody, ws=ws, ss=ss, scale_factor=scale_factor, w=w, alpha=alpha, beta=beta, tonality_vector=tonality_vector, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm ) cloud_diameter = tension_dict.get("cloud_diameter", []) if not cloud_diameter: return 0.0 return float(np.mean(cloud_diameter))
[docs] @partitura @tonality @pitch def std_cloud_diameter( melody: Melody, ws: float = 1.0, ss: str = "onset", scale_factor: float = SCALE_FACTOR, w: np.ndarray = DEFAULT_WEIGHTS, alpha: float = ALPHA, beta: float = BETA, tonality_vector: Optional[list] = None, key_estimation: str = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler" ) -> float: """Standard deviation of cloud diameter from the tonal tension model. Cloud Diameter provides a measure of the maximal tonal distance of the notes in a chord, following the definition in Partitura. Parameters ---------- melody : Melody A melody-features Melody object ws : float, optional Window size in beats. Default is 1.0 beat. ss : str, optional Step size or score position for computing the tonal tension features. Default is "onset" (compute at each unique score position). scale_factor : float, optional Multiplicative scaling factor. Default uses the distance between C and B#. w : np.ndarray, optional Weights for the chords. Default is [0.516, 0.315, 0.168]. alpha : float, optional Preference for V vs v chord in minor key (0-1). Default is 0.75. beta : float, optional Preference for iv vs IV in minor key (0-1). Default is 0.75. tonality_vector : list, optional Pre-computed tonality vector (list of (key_name, correlation) tuples). Default is None. key_estimation : str, optional Key estimation strategy, default "infer_if_necessary" key_finding_algorithm : Literal["krumhansl_schmuckler"], optional Key-finding algorithm to use when inferring key. Default is "krumhansl_schmuckler". Returns ------- float Standard deviation of cloud diameter values Citation -------- Herremans & Chew (2016) """ tension_dict = estimate_tonaltension( melody, ws=ws, ss=ss, scale_factor=scale_factor, w=w, alpha=alpha, beta=beta, tonality_vector=tonality_vector, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm ) cloud_diameter = tension_dict.get("cloud_diameter", []) if len(cloud_diameter) < 2: return 0.0 return float(np.std(cloud_diameter, ddof=1))
[docs] @partitura @tonality @pitch def mean_cloud_momentum( melody: Melody, ws: float = 1.0, ss: str = "onset", scale_factor: float = SCALE_FACTOR, w: np.ndarray = DEFAULT_WEIGHTS, alpha: float = ALPHA, beta: float = BETA, tonality_vector: Optional[list] = None, key_estimation: str = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler" ) -> float: """Mean cloud momentum from the tonal tension model. Cloud momentum captures movement of pitch sets in the spiral array space, weighted by note durations, following the definition in Partitura. Parameters ---------- melody : Melody A melody-features Melody object ws : float, optional Window size in beats. Default is 1.0 beat. ss : str, optional Step size or score position for computing the tonal tension features. Default is "onset" (compute at each unique score position). scale_factor : float, optional Multiplicative scaling factor. Default uses the distance between C and B#. w : np.ndarray, optional Weights for the chords. Default is [0.516, 0.315, 0.168]. alpha : float, optional Preference for V vs v chord in minor key (0-1). Default is 0.75. beta : float, optional Preference for iv vs IV in minor key (0-1). Default is 0.75. tonality_vector : list, optional Pre-computed tonality vector (list of (key_name, correlation) tuples). Default is None. key_estimation : str, optional Key estimation strategy, default "infer_if_necessary" key_finding_algorithm : Literal["krumhansl_schmuckler"], optional Key-finding algorithm to use when inferring key. Default is "krumhansl_schmuckler". Returns ------- float Mean cloud momentum value Citation -------- Herremans & Chew (2016) """ tension_dict = estimate_tonaltension( melody, ws=ws, ss=ss, scale_factor=scale_factor, w=w, alpha=alpha, beta=beta, tonality_vector=tonality_vector, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm ) cloud_momentum = tension_dict.get("cloud_momentum", []) if not cloud_momentum: return 0.0 return float(np.mean(cloud_momentum))
[docs] @partitura @tonality @pitch def std_cloud_momentum( melody: Melody, ws: float = 1.0, ss: str = "onset", scale_factor: float = SCALE_FACTOR, w: np.ndarray = DEFAULT_WEIGHTS, alpha: float = ALPHA, beta: float = BETA, tonality_vector: Optional[list] = None, key_estimation: str = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler" ) -> float: """Standard deviation of cloud momentum from the tonal tension model. Cloud Momentum provides a measure of movement of pitch sets in the spiral array space, weighted by note durations, following the definition in Partitura. Parameters ---------- melody : Melody A melody-features Melody object ws : float, optional Window size in beats. Default is 1.0 beat. ss : str, optional Step size or score position for computing the tonal tension features. Default is "onset" (compute at each unique score position). scale_factor : float, optional Multiplicative scaling factor. Default uses the distance between C and B#. w : np.ndarray, optional Weights for the chords. Default is [0.516, 0.315, 0.168]. alpha : float, optional Preference for V vs v chord in minor key (0-1). Default is 0.75. beta : float, optional Preference for iv vs IV in minor key (0-1). Default is 0.75. tonality_vector : list, optional Pre-computed tonality vector (list of (key_name, correlation) tuples). Default is None. key_estimation : str, optional Key estimation strategy, default "infer_if_necessary" key_finding_algorithm : Literal["krumhansl_schmuckler"], optional Key-finding algorithm to use when inferring key. Default is "krumhansl_schmuckler". Returns ------- float Standard deviation of cloud momentum values Citation -------- Herremans & Chew (2016) """ tension_dict = estimate_tonaltension( melody, ws=ws, ss=ss, scale_factor=scale_factor, w=w, alpha=alpha, beta=beta, tonality_vector=tonality_vector, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm ) cloud_momentum = tension_dict.get("cloud_momentum", []) if len(cloud_momentum) < 2: return 0.0 return float(np.std(cloud_momentum, ddof=1))
[docs] @partitura @tonality @pitch def mean_tensile_strain( melody: Melody, ws: float = 1.0, ss: str = "onset", scale_factor: float = SCALE_FACTOR, w: np.ndarray = DEFAULT_WEIGHTS, alpha: float = ALPHA, beta: float = BETA, tonality_vector: Optional[list] = None, key_estimation: str = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler" ) -> float: """Mean tensile strain from the tonal tension model. Tensile strain provides a measure of the distance between the local and global tonal context, following the definition in Partitura. Parameters ---------- melody : Melody A melody-features Melody object ws : float, optional Window size in beats. Default is 1.0 beat. ss : str, optional Step size or score position for computing the tonal tension features. Default is "onset" (compute at each unique score position). scale_factor : float, optional Multiplicative scaling factor. Default uses the distance between C and B#. w : np.ndarray, optional Weights for the chords. Default is [0.516, 0.315, 0.168]. alpha : float, optional Preference for V vs v chord in minor key (0-1). Default is 0.75. beta : float, optional Preference for iv vs IV in minor key (0-1). Default is 0.75. tonality_vector : list, optional Pre-computed tonality vector (list of (key_name, correlation) tuples). Default is None. key_estimation : str, optional Key estimation strategy, default "infer_if_necessary" key_finding_algorithm : Literal["krumhansl_schmuckler"], optional Key-finding algorithm to use when inferring key. Default is "krumhansl_schmuckler". Returns ------- float Mean tensile strain value Citation -------- Herremans & Chew (2016) """ tension_dict = estimate_tonaltension( melody, ws=ws, ss=ss, scale_factor=scale_factor, w=w, alpha=alpha, beta=beta, tonality_vector=tonality_vector, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm ) tensile_strain = tension_dict.get("tensile_strain", []) if not tensile_strain: return 0.0 return float(np.mean(tensile_strain))
[docs] @partitura @tonality @pitch def std_tensile_strain( melody: Melody, ws: float = 1.0, ss: str = "onset", scale_factor: float = SCALE_FACTOR, w: np.ndarray = DEFAULT_WEIGHTS, alpha: float = ALPHA, beta: float = BETA, tonality_vector: Optional[list] = None, key_estimation: str = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler" ) -> float: """Standard deviation of tensile strain from the tonal tension model. Tensile strain provides a measure of the distance between the local and global tonal context, following the definition in Partitura. Parameters ---------- melody : Melody A melody-features Melody object ws : float, optional Window size in beats. Default is 1.0 beat. ss : str, optional Step size or score position for computing the tonal tension features. Default is "onset" (compute at each unique score position). scale_factor : float, optional Multiplicative scaling factor. Default uses the distance between C and B#. w : np.ndarray, optional Weights for the chords. Default is [0.516, 0.315, 0.168]. alpha : float, optional Preference for V vs v chord in minor key (0-1). Default is 0.75. beta : float, optional Preference for iv vs IV in minor key (0-1). Default is 0.75. tonality_vector : list, optional Pre-computed tonality vector (list of (key_name, correlation) tuples). Default is None. key_estimation : str, optional Key estimation strategy, default "infer_if_necessary" key_finding_algorithm : Literal["krumhansl_schmuckler"], optional Key-finding algorithm to use when inferring key. Default is "krumhansl_schmuckler". Returns ------- float Standard deviation of tensile strain values Citation -------- Herremans & Chew (2016) """ tension_dict = estimate_tonaltension( melody, ws=ws, ss=ss, scale_factor=scale_factor, w=w, alpha=alpha, beta=beta, tonality_vector=tonality_vector, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm ) tensile_strain = tension_dict.get("tensile_strain", []) if len(tensile_strain) < 2: return 0.0 return float(np.std(tensile_strain, ddof=1))
[docs] @novel @tonality @pitch def tonalness_histogram(pitches: list[int]) -> dict: """Equal-width histogram of all 24 Krumhansl-Schmuckler key correlations. Parameters ---------- pitches : list[int] List of MIDI pitch values Returns ------- dict Bin-range strings mapped to counts (24 correlations, 24 bins). Citation -------- Krumhansl (1990) """ pitch_classes = [p % 12 for p in pitches] correlation_values = [value for _, value in compute_tonality_vector(pitch_classes)] return histogram_bins(correlation_values, 24)
[docs] @idyom @pitch @tonality def inscale( melody: Melody, key_estimation: Literal["always_read_from_file", "infer_if_necessary", "always_infer"] = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler", ) -> list[int]: """For each pitch in the melody, returns 1 if the pitch is in the estimated key's scale, or 0 if it deviates from the scale. Parameters ---------- melody : Melody The melody to analyze Returns ------- list[int] List of 0/1 values indicating if each pitch is in the estimated key's scale """ """For each pitch, returns 1 if pitch class is in the resolved key scale, else 0. Key resolution follows `key_estimation`: - `always_read_from_file`: require key signature in MIDI metadata - `infer_if_necessary`: use MIDI key signature when present, else infer - `always_infer`: always infer from note content """ pitches = melody.pitches pitch_classes = [pitch % 12 for pitch in pitches] resolved_key = _resolve_key_for_melody( melody, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm, ) if not resolved_key: return [] key_name, mode = resolved_key.split() key_distances = _get_key_distances() root = key_distances.get(key_name) if root is None: return [] is_major = mode == "major" scale = [0, 2, 4, 5, 7, 9, 11] if is_major else [0, 2, 3, 5, 7, 8, 10] scale = [(note + root) % 12 for note in scale] return [1 if pc in scale else 0 for pc in pitch_classes]
[docs] @novel @tonality @pitch def proportion_inscale( melody: Melody, key_estimation: Literal["always_read_from_file", "infer_if_necessary", "always_infer"] = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler", ) -> float: """The proportion of notes in the melody that are in the scale of the estimated key. Parameters ---------- melody : Melody The melody to analyze Returns ------- float Proportion of notes in the scale """ inscale_vals = inscale( melody, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm, ) if not inscale_vals: return -1.0 return sum(inscale_vals) / len(inscale_vals)
[docs] @novel @tonality @pitch def longest_monotonic_conjunct_scalar_passage( melody: Melody, key_estimation: Literal["always_read_from_file", "infer_if_necessary", "always_infer"] = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler", ) -> int: """The longest sequence of consecutive notes that fit within the estimated key's scale that move in the same direction. Parameters ---------- melody : Melody The melody to analyze Returns ------- int Length of the longest monotonic conjunct scalar passage """ from ..algorithms import longest_monotonic_conjunct_scalar_passage as _longest_monotonic_conjunct_scalar_passage pitches = melody.pitches pitch_classes = [pitch % 12 for pitch in pitches] correlations = compute_tonality_vector(pitch_classes) resolved_key = _resolve_key_for_melody( melody, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm, ) if resolved_key: correlations = _tonality_correlations_for_key(correlations, resolved_key) return _longest_monotonic_conjunct_scalar_passage(pitches, correlations)
[docs] @novel @tonality @pitch def longest_conjunct_scalar_passage( melody: Melody, key_estimation: Literal["always_read_from_file", "infer_if_necessary", "always_infer"] = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler", ) -> int: """The longest sequence of consecutive notes that fit within the estimated key's scale. For example, a melody estimated to be in C major with notes C, D, E, F, G would have a longest conjunct scalar passage of 5. Parameters ---------- melody : Melody The melody to analyze Returns ------- int Length of the longest conjunct scalar passage """ from ..algorithms import longest_conjunct_scalar_passage as _longest_conjunct_scalar_passage pitches = melody.pitches pitch_classes = [pitch % 12 for pitch in pitches] correlations = compute_tonality_vector(pitch_classes) resolved_key = _resolve_key_for_melody( melody, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm, ) if resolved_key: correlations = _tonality_correlations_for_key(correlations, resolved_key) return _longest_conjunct_scalar_passage(pitches, correlations)
[docs] @novel @tonality @pitch def proportion_conjunct_scalar( melody: Melody, key_estimation: Literal["always_read_from_file", "infer_if_necessary", "always_infer"] = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler", ) -> float: """Longest conjunct scalar passage length divided by total note count. Parameters ---------- melody : Melody The melody to analyze Returns ------- float Proportion of conjunct scalar motion """ from ..algorithms import proportion_conjunct_scalar as _proportion_conjunct_scalar pitches = melody.pitches pitch_classes = [pitch % 12 for pitch in pitches] correlations = compute_tonality_vector(pitch_classes) resolved_key = _resolve_key_for_melody( melody, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm, ) if resolved_key: correlations = _tonality_correlations_for_key(correlations, resolved_key) return _proportion_conjunct_scalar(pitches, correlations)
[docs] @novel @tonality @pitch def proportion_scalar( melody: Melody, key_estimation: Literal["always_read_from_file", "infer_if_necessary", "always_infer"] = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler", ) -> float: """Longest monotonic conjunct scalar passage length divided by total notes. Parameters ---------- melody : Melody The melody to analyze Returns ------- float Proportion of scalar motion """ from ..algorithms import proportion_scalar as _proportion_scalar pitches = melody.pitches pitch_classes = [pitch % 12 for pitch in pitches] correlations = compute_tonality_vector(pitch_classes) resolved_key = _resolve_key_for_melody( melody, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm, ) if resolved_key: correlations = _tonality_correlations_for_key(correlations, resolved_key) return _proportion_scalar(pitches, correlations)
[docs] @fantastic @tonality @pitch def mode( melody: Melody, key_estimation: Literal["always_read_from_file", "infer_if_necessary", "always_infer"] = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler" ) -> str: """Calculate the mode (major/minor) of a melody, either read from the MIDI file or estimated using the specified key finding algorithm. Parameters ---------- melody : Melody The melody to analyze key_estimation : str, optional Key estimation strategy, default "infer_if_necessary" Can be "always_read_from_file", "infer_if_necessary", or "always_infer" key_finding_algorithm : Literal["krumhansl_schmuckler"], optional Key-finding algorithm to use when inferring mode. Default is "krumhansl_schmuckler". Returns ------- str The mode: "major" or "minor" """ resolved_key = _resolve_key_for_melody( melody, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm, ) if not resolved_key: return "unknown" return resolved_key.split()[1]
[docs] def get_tonality_features( melody: Melody, key_estimation: Literal["always_read_from_file", "infer_if_necessary", "always_infer"] = "infer_if_necessary", key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler" ) -> Dict: """Compute all tonality-based features for a melody. Parameters ---------- melody : Melody The melody to analyze key_estimation : Literal["always_read_from_file", "infer_if_necessary", "always_infer"], optional Key estimation strategy. Default is "infer_if_necessary". key_finding_algorithm : Literal["krumhansl_schmuckler"], optional Key-finding algorithm to use when inferring key. Default is "krumhansl_schmuckler". Returns ------- Dict Dictionary of tonality-based feature values """ tonality_features = {} pcs = [pitch % 12 for pitch in melody.pitches] correlations = compute_tonality_vector(pcs) # Keep batch and standalone behavior consistent. tonality_features["tonalness"] = tonalness(melody.pitches) tonality_features["tonal_clarity"] = tonal_clarity(melody.pitches) tonality_features["tonal_spike"] = tonal_spike(melody.pitches) tonality_features["tonalness_histogram"] = tonalness_histogram(melody.pitches) key_for_features = _resolve_key_for_melody( melody, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm, ) if key_for_features: tonality_features["referent"] = referent( melody, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm, ) tonality_features["inscale"] = inscale( melody, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm, ) tonality_features["key"] = key_for_features tonality_features["mode"] = key_for_features.split()[1] else: tonality_features["referent"] = -1 tonality_features["inscale"] = [] tonality_features["key"] = "unknown" tonality_features["mode"] = "unknown" # Scalar passage features from ..algorithms import longest_monotonic_conjunct_scalar_passage as _longest_monotonic_conjunct_scalar_passage from ..algorithms import longest_conjunct_scalar_passage as _longest_conjunct_scalar_passage scalar_correlations = correlations if key_for_features: scalar_correlations = _tonality_correlations_for_key(correlations, key_for_features) tonality_features["longest_monotonic_conjunct_scalar_passage"] = ( _longest_monotonic_conjunct_scalar_passage(melody.pitches, scalar_correlations) ) tonality_features["longest_conjunct_scalar_passage"] = ( _longest_conjunct_scalar_passage(melody.pitches, scalar_correlations) ) from ..algorithms import proportion_conjunct_scalar as _proportion_conjunct_scalar from ..algorithms import proportion_scalar as _proportion_scalar tonality_features["proportion_conjunct_scalar"] = _proportion_conjunct_scalar( melody.pitches, scalar_correlations ) tonality_features["proportion_scalar"] = _proportion_scalar(melody.pitches, scalar_correlations) tonality_features["proportion_inscale"] = proportion_inscale( melody, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm, ) tension_dict = estimate_tonaltension( melody, key_estimation=key_estimation, key_finding_algorithm=key_finding_algorithm ) # Extract individual statistics cloud_diameter = tension_dict.get("cloud_diameter", []) if cloud_diameter: tonality_features["mean_cloud_diameter"] = float(np.mean(cloud_diameter)) tonality_features["std_cloud_diameter"] = float(np.std(cloud_diameter, ddof=1)) if len(cloud_diameter) > 1 else 0.0 else: tonality_features["mean_cloud_diameter"] = 0.0 tonality_features["std_cloud_diameter"] = 0.0 cloud_momentum = tension_dict.get("cloud_momentum", []) if cloud_momentum: tonality_features["mean_cloud_momentum"] = float(np.mean(cloud_momentum)) tonality_features["std_cloud_momentum"] = float(np.std(cloud_momentum, ddof=1)) if len(cloud_momentum) > 1 else 0.0 else: tonality_features["mean_cloud_momentum"] = 0.0 tonality_features["std_cloud_momentum"] = 0.0 tensile_strain = tension_dict.get("tensile_strain", []) if tensile_strain: tonality_features["mean_tensile_strain"] = float(np.mean(tensile_strain)) tonality_features["std_tensile_strain"] = float(np.std(tensile_strain, ddof=1)) if len(tensile_strain) > 1 else 0.0 else: tonality_features["mean_tensile_strain"] = 0.0 tonality_features["std_tensile_strain"] = 0.0 # Keep the full dict for backward compatibility tonality_features["tonal_tension"] = tension_dict return tonality_features