Source code for melody_features.pipeline.config

"""Configuration classes and defaults for the feature extraction pipeline."""

import os
from dataclasses import dataclass
from pathlib import Path
from typing import Literal, Optional

from ..idyom.config import IDyOMConfig, _DEFAULT_CORPUS, _default_idyom_configs


# Inclusive maximum m-type length (FANTASTIC n.limits default: 1-5).
DEFAULT_MAX_NGRAM_ORDER = 5


[docs] @dataclass class FantasticConfig: """Settings for FANTASTIC-style features (tokenization and corpus n-grams). These options apply to lexical-diversity / m-type features and to corpus-relative FANTASTIC statistics computed by :func:`~melody_features.get_all_features`. Parameters ---------- max_ngram_order : Inclusive maximum n-gram / m-type length (must be ``>= 1``). Features use orders ``1`` through this value (FANTASTIC default range is 1–5). phrase_gap : Inter-onset interval threshold in quarter-note units (must be ``> 0``). An IOI larger than this starts a new phrase when the melody is segmented for tokenization. corpus : Optional MIDI directory for FANTASTIC corpus statistics. If ``None``, the parent :class:`~melody_features.Config` corpus is used. Independent of IDyOM pretraining corpora. """ max_ngram_order: int phrase_gap: float corpus: Optional[os.PathLike] = None def __post_init__(self): """Validate the configuration after initialization.""" if not isinstance(self.max_ngram_order, int): raise ValueError( f"max_ngram_order must be an integer, got {type(self.max_ngram_order)}" ) if self.max_ngram_order < 1: raise ValueError( f"max_ngram_order must be at least 1, got {self.max_ngram_order}" ) if not isinstance(self.phrase_gap, (int, float)): raise ValueError( f"phrase_gap must be a number, got {type(self.phrase_gap)}" ) if self.phrase_gap <= 0: raise ValueError(f"phrase_gap must be positive, got {self.phrase_gap}") if self.corpus is not None: if not isinstance(self.corpus, (str, os.PathLike)): raise ValueError( f"corpus must be a string or PathLike, got {type(self.corpus)}" ) if not Path(self.corpus).exists(): raise ValueError(f"corpus path does not exist: {self.corpus}")
[docs] @dataclass class Config: """Configuration for :func:`~melody_features.get_all_features`. Use ``Config`` to control corpus statistics, FANTASTIC settings, key estimation, and IDyOM. The ``idyom`` field is a non-empty dictionary of named :class:`~melody_features.IDyOMConfig` objects: **each entry runs IDyOM once**, and the dict key labels that run in the output columns. That lets you compare several IDyOM setups (models, viewpoints, corpora) in a single feature-extraction call. Parameters ---------- idyom : Mapping of run label → :class:`~melody_features.IDyOMConfig`. Must contain at least one entry. fantastic : :class:`~melody_features.FantasticConfig` for corpus n-gram features. corpus : Optional default reference corpus path (FANTASTIC / IDyOM LTM). Per-entry ``IDyOMConfig.corpus`` and ``FantasticConfig.corpus`` override this when set. key_estimation : ``"always_read_from_file"``, ``"infer_if_necessary"`` (default), or ``"always_infer"``. key_finding_algorithm : Currently only ``"krumhansl_schmuckler"``. """ idyom: dict[str, IDyOMConfig] fantastic: FantasticConfig corpus: Optional[os.PathLike] = None key_estimation: Literal["always_read_from_file", "infer_if_necessary", "always_infer"] = "infer_if_necessary" key_finding_algorithm: Literal["krumhansl_schmuckler"] = "krumhansl_schmuckler" def __post_init__(self): """Validate the configuration after initialization.""" if self.corpus is not None: if not isinstance(self.corpus, (str, os.PathLike)): raise ValueError( f"corpus must be a string or PathLike, got {type(self.corpus)}" ) if not Path(self.corpus).exists(): raise ValueError(f"corpus path does not exist: {self.corpus}") if not isinstance(self.idyom, dict): raise ValueError(f"idyom must be a dictionary, got {type(self.idyom)}") if not self.idyom: raise ValueError("idyom dictionary cannot be empty") for name, config in self.idyom.items(): if not isinstance(name, str): raise ValueError( f"idyom dictionary keys must be strings, got {type(name)}" ) if not isinstance(config, IDyOMConfig): raise ValueError( f"idyom dictionary values must be IDyOMConfig objects, got {type(config)}" ) if not isinstance(self.fantastic, FantasticConfig): raise ValueError( f"fantastic must be a FantasticConfig object, got {type(self.fantastic)}" ) if self.key_estimation not in ["always_read_from_file", "infer_if_necessary", "always_infer"]: raise ValueError(f"key_estimation must be one of ['always_read_from_file', 'infer_if_necessary', 'always_infer'], got {self.key_estimation}") if self.key_finding_algorithm != "krumhansl_schmuckler": raise NotImplementedError( f"key_finding_algorithm '{self.key_finding_algorithm}' is not supported. " f"Currently only 'krumhansl_schmuckler' is implemented. More algorithms may be added in the future." )
def _setup_default_config(config: Optional[Config]) -> Config: """Set up default configuration if none is provided.""" if config is None: config = Config( corpus=_DEFAULT_CORPUS, idyom=_default_idyom_configs(_DEFAULT_CORPUS), fantastic=FantasticConfig( max_ngram_order=DEFAULT_MAX_NGRAM_ORDER, phrase_gap=1.5, corpus=None, ), key_estimation="infer_if_necessary", ) return config def _validate_config(config: Config) -> None: """Validate the configuration object.""" if not hasattr(config, "idyom") or not config.idyom: raise ValueError("Config must have at least one IDyOM configuration") if not hasattr(config, "fantastic"): raise ValueError("Config must have FANTASTIC configuration")