Usage
This guide documents the main workflows with explicit return types, so you
do not have to guess whether a helper returns a single object, a list, or
None.
For the interactive feature table, see Feature catalogue. For generated API docs, see API reference.
Examples below use import melody_features as mf. Helpers that are not
re-exported on the package root (for example load_midi and Melody)
still use submodule imports.
Loading melodies
The package represents one monophonic melody as a
Melody object. Load a file once into a
Melody, then pass its attributes (melody.pitches, melody.starts,
…) into feature functions — you should not need to unpack the MIDI yourself.
Single MIDI file
from melody_features.io.midi import load_midi
from melody_features.core.representations import Melody
melody = load_midi("example.mid")
# Returns: Optional[Melody]
# - Melody on success
# - None if the file cannot be imported or is empty of notes
assert melody is None or isinstance(melody, Melody)
load_midi() always returns one object (or
None). It never returns a list.
Useful attributes on a successful load:
Attribute |
Type |
Meaning |
|---|---|---|
|
|
Source path / identifier |
|
|
MIDI pitch numbers in note order |
|
|
Note onset times (seconds) |
|
|
Note offset times (seconds) |
|
|
Tempo in BPM |
|
|
|
|
|
First time signature |
|
|
All meters as |
|
|
Total duration in seconds |
|
|
Raw import dictionary |
Lower-level import (dict, not Melody)
from melody_features.io.midi import import_midi
data = import_midi("example.mid")
# Returns: dict | None
# dict keys include: ID, pitches, starts, ends, tempo, tempo_changes,
# time_signature_info, key_signature_info, total_duration, ...
Use load_midi() unless you specifically need the
raw dictionary.
Directory of MIDI files
Two helpers load many files; both return a list of Melody objects:
import melody_features as mf
from melody_features.io.midi import import_midi_from_directory, list_midi_files
# Preferred MIDI helper in melody_features.io.midi
paths = list_midi_files("path/to/dir", recursive=False)
# Returns: list[str] (naturally sorted absolute paths)
# Raises: FileNotFoundError if the directory is missing or has no MIDI files
melodies = import_midi_from_directory("path/to/dir", recursive=False)
# Returns: list[Melody]
# Failed files are skipped (warnings logged); successful loads only.
# Corpus-oriented loader (MIDI directory; JSON is legacy — see below)
melodies = mf.load_melodies_from_directory("path/to/dir", file_type="midi")
# Returns: list[Melody]
Constructing a Melody without a file
from melody_features.core.representations import Melody
melody = Melody.from_notes(
pitches=[60, 62, 64],
starts=[0.0, 0.5, 1.0],
ends=[0.5, 1.0, 1.5],
tempo=120.0,
melody_id="toy",
)
# Returns: Melody
Bundled corpora
import melody_features as mf
names = mf.list_available_corpora()
# Returns: list[str] e.g. ["essen", "pearce_default_idyom"]
root = mf.get_corpus_path("essen")
# Returns: pathlib.Path (directory of MIDI files)
# Raises: ValueError for unknown names; FileNotFoundError if missing on disk
files = mf.get_corpus_files("essen", max_files=10)
# Returns: list[pathlib.Path]
# max_files=None returns every MIDI file in natural sort order
Example: load one bundled file and inspect it:
import melody_features as mf
from melody_features.io.midi import load_midi
path = mf.get_corpus_files("essen", max_files=1)[0] # Path
melody = load_midi(path) # Optional[Melody]
if melody is None:
raise RuntimeError(f"failed to load {path}")
print(len(melody.pitches), melody.tempo, melody.meter)
Calling individual features
Atomic feature functions are imported from the package root (or
melody_features.features). Their signatures take note lists (and
sometimes tempo or related fields) rather than a Melody argument — but
Melody exposes exactly those lists as
attributes, so everyday use is still a one-liner after loading:
import melody_features as mf
from melody_features.io.midi import load_midi
melody = load_midi("example.mid")
if melody is None:
raise RuntimeError("import failed")
# Melody gives you the lists; pass them straight into the feature
span = mf.pitch_range(melody.pitches)
# pitch_range(pitches: list[int]) -> int
hist = mf.basic_pitch_histogram(melody.pitches)
# basic_pitch_histogram(pitches: list[int]) -> dict[int, int]
When a feature needs several fields, pull them from the same Melody:
import melody_features as mf
value = mf.melodic_pitch_variety(
melody.pitches,
melody.starts,
tempo=melody.tempo,
)
# -> float
Discover what is available:
import melody_features as mf
names = mf.list_available_features()
# Returns: list[str] (feature function names)
records = mf.list_available_features(source="jsymbolic", detailed=True)
# Returns: list[dict] when detailed=True
# Optional filters: domain ("pitch"|"rhythm"|"both"),
# feature_type (e.g. "absolute", "interval"),
# source (e.g. "fantastic", "jsymbolic")
See also melody_features.get_feature_metadata() for a full metadata
table, and Feature catalogue for human-readable descriptions.
Batch extraction with get_all_features
Signature (simplified):
get_all_features(
input: PathLike | list[PathLike] | list[Melody],
config: Config | None = None,
log_level: int = logging.INFO,
skip_idyom: bool = False,
long_format: bool = False,
join_metadata: bool = True,
) -> pandas.DataFrame | None
Returns: a pandas.DataFrame, or None if no valid melodies
load. If melodies load but nothing is extracted, the result is an empty
DataFrame.
Accepted input forms:
directory path containing MIDI files → every
.mid/.midiinsidesingle MIDI file path
list of MIDI file paths
list of in-memory
Melodyobjects
Polyphonic files are skipped with a warning.
Minimal example:
import melody_features as mf
import pandas as pd
results = mf.get_all_features("path/to/midi/files", skip_idyom=True)
assert isinstance(results, pd.DataFrame)
# Default is wide format (see below).
Wide vs long output
The snippets below are trimmed excerpts from a real run on two Essen melodies
(skip_idyom=True). A full wide table has on the order of 280 feature
columns; only a few are shown here.
Wide format (default)
Shape: one row per melody
ID columns:
melody_num,melody_idFeature columns:
{family}.{feature_name}(e.g.absolute_pitch.pitch_range)Cell types: scalars for descriptors; Python objects (lists/dicts) for sequence features
import melody_features as mf
wide = mf.get_all_features("path/to/midi", skip_idyom=True, long_format=False)
print(wide.shape) # e.g. (2, 280)
print(wide[
[
"melody_num",
"melody_id",
"absolute_pitch.pitch_range",
"absolute_pitch.first_pitch",
"absolute_pitch.basic_pitch_histogram",
]
])
Example output (paths shortened):
melody_num melody_id absolute_pitch.pitch_range absolute_pitch.first_pitch absolute_pitch.basic_pitch_histogram
1 appenzel.mid 24 62 {62: 9, 64: 3, 66: 4, 67: 10, ...}
2 arabic01.mid 20 74 {66: 3, 67: 9, 69: 8, 70: 4, ...}
Here pitch_range / first_pitch are Descriptor (scalar) columns;
basic_pitch_histogram is a Sequence column whose cells are dict
objects.
Long format
Shape: one row per melody × feature
Core columns:
melody_num,melody_id,feature_name,valueWith
join_metadata=True(default): alsofamily,source,domain,type,description,notes,references
import melody_features as mf
long_df = mf.get_all_features(
"path/to/midi",
skip_idyom=True,
long_format=True,
join_metadata=True,
)
print(long_df[
["melody_num", "feature_name", "family", "type", "value"]
].head())
Example output (same two melodies; metadata columns beyond family /
type omitted for space):
melody_num feature_name family type value
1 absolute_pitch.pitch_range absolute_pitch Descriptor 24
2 absolute_pitch.pitch_range absolute_pitch Descriptor 20
1 absolute_pitch.first_pitch absolute_pitch Descriptor 62
2 absolute_pitch.first_pitch absolute_pitch Descriptor 74
1 absolute_pitch.basic_pitch_histogram absolute_pitch Sequence {62: 9, 64: 3, ...}
2 absolute_pitch.basic_pitch_histogram absolute_pitch Sequence {66: 3, 67: 9, ...}
Filter by metadata after the fact:
descriptors = long_df[
(long_df["type"] == "Descriptor")
& (long_df["source"].str.contains("jSymbolic", na=False))
]
Reshape an existing wide table later:
import melody_features as mf
long_df = mf.to_long_format(wide, join_metadata=True)
# Returns: pandas.DataFrame
meta = mf.get_feature_metadata()
# Returns: pandas.DataFrame with columns
# feature_name, family, source, domain, type, description, notes, references
Configuration
Config controls how melody-features behaves when you
call get_all_features(). Pass a Config to set:
corpus — reference MIDI directory for FANTASTIC corpus statistics and (by default) IDyOM long-term-model pretraining
fantastic — a
FantasticConfig(n-gram order, phrase gap, optional corpus override)idyom — a dictionary of named
IDyOMConfigobjects (see below)key_estimation / key_finding_algorithm — how tonal context is obtained
If you omit config, sensible defaults are used:
corpus: bundled Pearce (2018) 903-melody set
fantastic:
max_ngram_order=5,phrase_gap=1.5idyom: four built-in runs (pitch/rhythm × short-term/long-term models)
key_estimation:
"infer_if_necessary"key_finding_algorithm:
"krumhansl_schmuckler"
FANTASTIC settings
FantasticConfig controls FANTASTIC-style features:
phrase-based tokenization, lexical-diversity (m-type) measures, and
corpus-relative n-gram statistics (Müllensiefen, 2009). Its fields are:
max_ngram_order (
int, default5) — inclusive maximum n-gram / m-type length. Features use orders1through this value (matching FANTASTIC’s usualn.limitsof 1–5). Raising it includes longer patterns; lowering it truncates them. Must be>= 1.phrase_gap (
float, default1.5) — inter-onset interval threshold in quarter-note units. Within a melody, an IOI larger than this value starts a new phrase for tokenization. Smaller values yield more / shorter phrases; larger values keep notes together longer. Must be> 0.corpus (optional path) — MIDI directory used to build the reference corpus statistics for distributional FANTASTIC features. If
None, the parentConfigcorpus is used (bundled Pearce set by default). This does not change IDyOM pretraining; useIDyOMConfig.corpusfor that.
import melody_features as mf
mf.FantasticConfig(
max_ngram_order=5, # n-grams / m-types of length 1..5
phrase_gap=1.5, # new phrase when IOI > 1.5 quarter notes
corpus=None, # fall back to Config.corpus
)
IDyOM viewpoints
Each IDyOMConfig takes:
target_viewpoints — what to predict (commonly
["cpitch"]or["onset"])source_viewpoints — what conditions that prediction
Build viewpoint lists from atomic names (strings) and optional linked viewpoints. A linked viewpoint is a tuple (Python parentheses) of two or more atomic names; IDyOM treats that combination as one joint source. You can mix strings and tuples in the same list:
# Atomic sources only
source_viewpoints=["cpitch", "cpintfref"]
# One linked viewpoint: (cpint × cpintfref)
source_viewpoints=[("cpint", "cpintfref")]
# Mix linked and atomic
source_viewpoints=[("cpint", "cpintfref"), "cpcint"]
# Longer link (used by the package defaults for pitch)
source_viewpoints=[("cpitch", "cpint", "cpintfref")]
Atomic names must be in
melody_features.idyom.config.VALID_VIEWPOINTS (for example cpitch,
cpint, cpintfref, onset, ioi, ioi-ratio). Linked tuples
must contain at least two atoms; every atom is checked against that set.
Multiple IDyOM runs
Config.idyom is a non-empty dict[str, IDyOMConfig]. Each entry is a
separate IDyOM job. The dict key is a label that appears in the output
column names for that run, so you can compare models, viewpoints, or corpora
in one get_all_features call.
For example, run a pitch short-term model (with a linked source viewpoint) and a rhythm long-term model:
import melody_features as mf
config = mf.Config(
corpus=mf.get_corpus_path("pearce_default_idyom"),
fantastic=mf.FantasticConfig(max_ngram_order=5, phrase_gap=1.5),
idyom={
"pitch_stm": mf.IDyOMConfig(
target_viewpoints=["cpitch"],
source_viewpoints=[("cpitch", "cpint", "cpintfref")],
ppm_order=2,
models=":stm",
),
"rhythm_ltm": mf.IDyOMConfig(
target_viewpoints=["onset"],
source_viewpoints=["ioi", "ioi-ratio"],
ppm_order=2,
models=":ltm",
corpus=mf.get_corpus_path("pearce_default_idyom"),
),
},
key_estimation="infer_if_necessary",
)
results = mf.get_all_features("path/to/midi", config=config)
# Wide columns are labelled by the dict keys, for example:
# idyom.pitch_stm_mean_information_content
# idyom.rhythm_ltm_mean_information_content
Pattern: idyom.<config_key>_<metric>. See IDyOM for the default
four-run column names and how they relate to expectation.* helpers.
You can add as many named IDyOMConfig entries as you need (different
models, viewpoints, ppm_order, or pretraining corpus). Per-entry
IDyOMConfig.corpus overrides Config.corpus for that run; short-term
models (models=":stm") do not use a pretraining corpus.
Minimal single-run example
import melody_features as mf
config = mf.Config(
corpus=mf.get_corpus_path("pearce_default_idyom"),
fantastic=mf.FantasticConfig(
max_ngram_order=5,
phrase_gap=1.5,
corpus=None, # fall back to Config.corpus
),
idyom={
"default": mf.IDyOMConfig(
target_viewpoints=["cpitch"],
# Linked viewpoint via parentheses (tuple), plus an atomic one
source_viewpoints=[("cpint", "cpintfref"), "cpcint"],
ppm_order=2,
models=":both", # ":stm" | ":ltm" | ":both"
corpus=None,
)
},
key_estimation="infer_if_necessary",
# "always_read_from_file" | "infer_if_necessary" | "always_infer"
)
results = mf.get_all_features("path/to/midi", config=config)
Skipping IDyOM
import melody_features as mf
results = mf.get_all_features("path/to/midi", skip_idyom=True)
Default IDyOM jobs and standalone run_idyom are documented in IDyOM.
Install/verify steps: Installing IDyOM.
Key estimation
Config key_estimation controls how tonal context
is obtained for tonality features, pitch spelling, and temporary MIDI written
for IDyOM:
"infer_if_necessary"(default) — use a key signature from the MIDI file when present; otherwise infer with the key-finding algorithm"always_read_from_file"— require key metadata in the file; do not infer"always_infer"— ignore file keys and always infer from note content
key_finding_algorithm is currently "krumhansl_schmuckler" only.
import melody_features as mf
config = mf.Config(
corpus=mf.get_corpus_path("pearce_default_idyom"),
fantastic=mf.FantasticConfig(max_ngram_order=5, phrase_gap=1.5),
idyom={
"pitch_stm": mf.IDyOMConfig(
target_viewpoints=["cpitch"],
source_viewpoints=[("cpitch", "cpint", "cpintfref")],
ppm_order=2,
models=":stm",
),
},
key_estimation="always_infer",
)
results = mf.get_all_features("path/to/midi", config=config, skip_idyom=True)
Selective feature extraction
For a single Melody, scoped helpers return
a dict of feature name → value without running the full batch pipeline.
They live on melody_features.features (also imported where re-exported).
By source (toolkit provenance):
from melody_features.features import (
get_jsymbolic_features,
get_fantastic_features,
get_must_features,
get_midi_toolbox_features,
get_simile_features,
get_novel_features,
)
from melody_features.io.midi import load_midi
melody = load_midi("example.mid")
jsym = get_jsymbolic_features(melody)
must = get_must_features(melody)
get_fantastic_features accepts optional corpus_stats, phrase_gap,
and max_ngram_order. Precompute stats with
make_corpus_stats() (see Corpora).
get_idyom_features only collects functions tagged @idyom that can run
from the melody alone — full IDyOM information-content columns still come from
get_all_features / IDyOM.
By family / domain (examples):
from melody_features.features import (
get_pitch_features,
get_pitch_class_features,
get_interval_features,
get_timing_features,
get_inter_onset_interval_features,
get_rhythm_features,
get_metre_features,
get_expectation_features,
get_complexity_features,
get_contour_features,
get_tonality_features,
get_lexical_diversity_features,
get_corpus_features,
)
pitch = get_pitch_features(melody)
timing = get_timing_features(melody)
# get_corpus_features(melody, corpus_stats, phrase_gap, max_ngram_order)
For most corpus-scale jobs prefer mf.get_all_features. Contour class
APIs (full vectors): Contour representations.
FANTASTIC workflow
FANTASTIC-style features (Müllensiefen, 2009) follow a fixed pipeline. Batch
runs do this for you via FantasticConfig; the steps
below are what happens under the hood, and what you can call yourself.
Phrase segmentation — split the melody on large inter-onset gaps (
phrase_gapin quarter notes).M-type tokenization — each step becomes an
MType(classified pitch interval × classified IOI ratio). Default scheme is"FANTASTIC";"SIMILE"uses a coarser signed-interval map.N-gram counting — accumulate m-type n-grams of order
1…max_ngram_orderwithNGramCounter.Lexical / corpus features — lexical-diversity (m-type) measures use the melody’s own counts; distributional corpus features compare those counts to reference document frequencies from
Config.corpus(or a precomputed stats file — see Corpora).
Inspect steps 1–3 directly:
from melody_features.melody_tokenizer import FantasticTokenizer
from melody_features.ngram_counter import NGramCounter
from melody_features.io.midi import load_midi
melody = load_midi("example.mid")
tok = FantasticTokenizer() # scheme="FANTASTIC" (or "SIMILE")
phrases = tok.segment_melody(melody, phrase_gap=1.5, units="quarters")
tokens = tok.tokenize_melody(melody.pitches, melody.starts, melody.ends)
counts = tok.ngram_counts() # all orders counted during tokenize_melody
# Or count with an explicit counter / max order:
counter = NGramCounter()
counter.count_ngrams(tokens, max_order=5)
bigrams = counter.get_counts(n=2)
Selective helpers that consume the same knobs (and optional corpus_stats):
from melody_features.features import (
get_fantastic_features,
get_lexical_diversity_features,
get_corpus_features,
)
# get_fantastic_features bundles lexical + corpus FANTASTIC outputs
# get_lexical_diversity_features(melody, phrase_gap=1.5, max_ngram_order=5)
# get_corpus_features(melody, corpus_stats, phrase_gap=1.5, max_ngram_order=5)
Phrase gap and max n-gram order for get_all_features are set on
FantasticConfig (see Configuration above). API
reference: Tokenizers and n-grams.
MUST tokenization
MustTokenizer builds pitch /
interval / duration distributions used by MUST-derived complexity features.
This is separate from the FANTASTIC m-type pipeline above.
from melody_features.melody_tokenizer import MustTokenizer
from melody_features.io.midi import load_midi
melody = load_midi("example.mid")
must = MustTokenizer()
pdist = must.pdist1(melody) # MustDistribution
print(pdist.as_dict())
print(pdist.entropy())
Feature families and types
Features are grouped into families such as absolute pitch, pitch class, pitch interval, contour, timing, inter-onset interval, tonality, metre, expectation, complexity, lexical diversity, and corpus statistics.
Return kinds (see the type column in long format / the catalogue):
Descriptor — scalar (
int,float,bool)Sequence — collection (
list,tuple,dict, …)
Putting it together
End-to-end pattern for a small custom run:
import melody_features as mf
from melody_features.io.midi import load_midi
# 1) Inspect one melody
path = mf.get_corpus_files("essen", max_files=1)[0]
melody = load_midi(path)
assert melody is not None
print("range:", mf.pitch_range(melody.pitches))
# 2) Batch-extract a few files (skip IDyOM for a faster local run)
files = mf.get_corpus_files("essen", max_files=5)
wide = mf.get_all_features(files, skip_idyom=True)
print(wide.shape)
print(wide[["melody_id", "absolute_pitch.pitch_range"]].head())
Legacy: JSON melody files
MIDI is the supported input. A legacy path still accepts a directory containing
one JSON file (a list of melody dicts) via
load_melodies_from_directory(..., file_type="json"). Each record needs
aligned pitches, starts, and ends (or a legacy MIDI Sequence
string). Prefer MIDI or from_notes()
for new work.