Use this Skill for computational musicology with music21: score analysis, harmonic reduction, melodic contour, counterpoint checking, and corpus comparison.
TL;DR — Analyse musical scores programmatically: load MusicXML/MIDI/ABC/Kern, run Roman numeral harmonic analysis, compute melodic contour, detect counterpoint errors, and query the built-in Bach chorale corpus.
Use this Skill whenever you need to:
| Task | music21 Entry Point |
|---|
| Load a score file | converter.parse(filepath) |
| Roman numeral analysis | harmony.romanNumeralFromChord() |
| Key detection | score.analyze('key') or key.analyze('Krumhansl') |
| Corpus queries | corpus.search(composer='bach') |
| Pitch class sets | chord.Chord.pitchClasses |
music21 is an MIT-developed Python toolkit for computational musicology. Its object
model mirrors Western music notation: a Score contains one or more Part objects;
each Part is a sequence of Measure objects; each Measure contains Note,
Chord, Rest, and other GeneralNote subclasses.
Score
└─ Part (e.g., Soprano, Alto, Tenor, Bass)
└─ Measure (bar number, time signature, key signature)
└─ Note / Chord / Rest
├─ Note: pitch (Pitch object), duration (Duration), offset
└─ Chord: list of pitches, root(), inversion(), pitchClasses
harmony.romanNumeralFromChord(chord, key) returns a
RomanNumeral object with .figure (string label) and .scaleDegree.Contour reduction (Morris 1987) simplifies a melody by retaining only local maxima and minima. This enables comparison of melodic shape across transpositions and rhythmic variations. Edit distance on the sequence of melodic intervals (in semitones) gives a measure of melodic similarity.
The standard prohibition rules in strict two-voice counterpoint:
| Rule | Definition |
|---|---|
| Parallel fifths | Two voices move in same direction, both intervals are P5 |
| Parallel octaves | Same motion, both intervals are P8/unison |
| Voice crossing | Lower voice pitch exceeds upper voice pitch |
| Hidden fifths | Outer voices approach P5 by similar motion |
# Create a dedicated conda environment
conda create -n music21-env python=3.11 -y
conda activate music21-env
pip install "music21>=9.1" "numpy>=1.23" "matplotlib>=3.6" "pandas>=1.5"
# Optional: install MuseScore for PDF/PNG rendering
# Ubuntu/Debian:
sudo apt-get install musescore3
# macOS:
brew install --cask musescore
# Configure music21 to find MuseScore (run once)
python -c "import music21; music21.environment.UserSettings()['musicxmlPath'] = '/usr/bin/musescore3'"
Verify installation:
import music21
from music21 import corpus, converter, harmony, key
print(f"music21 version: {music21.__version__}")
# Quick smoke test: load a Bach chorale from the built-in corpus
bwv66 = corpus.parse('bach/bwv66.6')
print(f"Parts: {[p.partName for p in bwv66.parts]}")
print(f"Measures: {len(list(bwv66.parts[0].getElementsByClass('Measure')))}")
import pandas as pd
from music21 import corpus, harmony, roman, chord, key as m21key
def analyze_bach_chorale_harmony(bwv_id: str = 'bach/bwv66.6') -> pd.DataFrame:
"""
Load a Bach chorale from the built-in corpus and perform Roman numeral
harmonic analysis on every beat-level chord slice.
Args:
bwv_id: corpus path string, e.g. 'bach/bwv66.6'
Returns:
DataFrame with columns: measure, beat, chord_pitches, key, roman_numeral,
scale_degree, chord_quality
"""
score = corpus.parse(bwv_id)
# Detect global key using Krumhansl-Schmuckler algorithm
detected_key = score.analyze('key')
print(f"Detected key: {detected_key} (confidence: {detected_key.correlationCoefficient:.3f})")
# Reduce score to chordified version (one chord per beat position)
chordified = score.chordify()
records = []
for measure in chordified.recurse().getElementsByClass('Measure'):
m_num = measure.number
for element in measure.notes:
if not isinstance(element, chord.Chord):
continue
# Local key at this position
local_key = element.getContextByClass('KeySignature')
analysis_key = detected_key # fall back to global key
try:
rn = roman.romanNumeralFromChord(element, analysis_key)
figure = rn.figure
scale_deg = rn.scaleDegree
quality = element.commonName
except Exception:
figure = '?'
scale_deg = -1
quality = 'unknown'
records.append({
'measure': m_num,
'beat': float(element.beat),
'chord_pitches': ' '.join(str(p) for p in element.pitches),
'key': str(detected_key),
'roman_numeral': figure,
'scale_degree': scale_deg,
'chord_quality': quality,
})
df = pd.DataFrame(records)
return df
def harmonic_progression_frequency(df: pd.DataFrame, n: int = 2) -> pd.Series:
"""
Compute bigram (or n-gram) frequency of Roman numeral progressions.
Args:
df: Output of analyze_bach_chorale_harmony()
n: n-gram size (default 2 = bigrams like I->V)
Returns:
Series of progression counts, sorted descending.
"""
rn_seq = df['roman_numeral'].tolist()
ngrams = [
' -> '.join(rn_seq[i:i + n])
for i in range(len(rn_seq) - n + 1)
]
freq = pd.Series(ngrams).value_counts()
return freq
# --- Run ---
df_harmony = analyze_bach_chorale_harmony('bach/bwv66.6')
print(df_harmony.head(10).to_string(index=False))
bigrams = harmonic_progression_frequency(df_harmony, n=2)
print("\nTop 10 harmonic bigrams:")
print(bigrams.head(10))
import numpy as np
import matplotlib.pyplot as plt
from music21 import corpus, note, interval
def extract_melodic_features(bwv_id: str = 'bach/bwv66.6',
part_index: int = 0) -> dict:
"""
Extract melodic interval sequence, contour reduction, and interval histogram
for a single part of a score.
Args:
bwv_id: corpus path string
part_index: which part to analyse (0=Soprano in Bach chorales)
Returns:
dict with keys: intervals_semitones, contour, interval_counts,
mean_interval, std_interval
"""
score = corpus.parse(bwv_id)
part = score.parts[part_index]
# Collect all Note objects (exclude rests and non-pitched)
notes = [n for n in part.flat.notes if isinstance(n, note.Note)]
# Compute melodic intervals in semitones
semitones = []
for i in range(len(notes) - 1):
intv = interval.Interval(notes[i], notes[i + 1])
semitones.append(intv.semitones)
# Contour reduction: keep local extrema (local max and min)
def contour_reduction(seq):
if len(seq) < 3:
return seq
reduced = [seq[0]]
for i in range(1, len(seq) - 1):
is_local_max = seq[i] > seq[i - 1] and seq[i] > seq[i + 1]
is_local_min = seq[i] < seq[i - 1] and seq[i] < seq[i + 1]
if is_local_max or is_local_min:
reduced.append(seq[i])
reduced.append(seq[-1])
return reduced
midi_pitches = [n.pitch.midi for n in notes]
contour = contour_reduction(midi_pitches)
# Interval histogram
interval_counts = {}
for s in semitones:
interval_counts[s] = interval_counts.get(s, 0) + 1
# Plot
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
# Pitch contour
axes[0].plot(midi_pitches, linewidth=0.7, color='steelblue', label='Original')
reduced_x = np.linspace(0, len(midi_pitches) - 1, len(contour))
axes[0].plot(reduced_x, contour, 'ro-', linewidth=1.5, markersize=4, label='Contour reduction')
axes[0].set_title(f'Melodic Contour — {bwv_id} Part {part_index}')
axes[0].set_xlabel('Note index')
axes[0].set_ylabel('MIDI pitch')
axes[0].legend()
# Interval histogram
bins = sorted(interval_counts.keys())
counts = [interval_counts[b] for b in bins]
axes[1].bar(bins, counts, color='coral', edgecolor='black', linewidth=0.5)
axes[1].set_title('Melodic Interval Histogram (semitones)')
axes[1].set_xlabel('Interval (semitones)')
axes[1].set_ylabel('Frequency')
fig.tight_layout()
fig.savefig('melodic_contour.png', dpi=150)
plt.close(fig)
return {
'intervals_semitones': semitones,
'contour': contour,
'interval_counts': interval_counts,
'mean_interval': float(np.mean(semitones)) if semitones else 0.0,
'std_interval': float(np.std(semitones)) if semitones else 0.0,
}
# --- Run ---
features = extract_melodic_features('bach/bwv66.6', part_index=0)
print(f"Mean interval: {features['mean_interval']:.2f} semitones")
print(f"Std interval: {features['std_interval']:.2f} semitones")
print(f"Contour length after reduction: {len(features['contour'])} points")
from music21 import corpus, chord, interval, note
from typing import List, Tuple
def detect_parallel_intervals(
score_path: str = 'bach/bwv66.6',
upper_part_idx: int = 0,
lower_part_idx: int = 1,
target_semitones: List[int] = None,
) -> List[dict]:
"""
Detect parallel fifths and parallel octaves between two voices.
A parallel fifth/octave occurs when two consecutive harmonic intervals
of the same size are approached by both voices moving in the same direction.
Args:
score_path: corpus path or file path
upper_part_idx: index of the upper voice part
lower_part_idx: index of the lower voice part
target_semitones: list of interval sizes to flag (default: [7, 12] = P5, P8)
Returns:
List of dicts describing each violation: measure, beat, upper_notes,
lower_notes, interval_size, violation_type
"""
if target_semitones is None:
target_semitones = [7, 12] # Perfect fifth, perfect octave/unison
# Load from corpus or file
try:
score = corpus.parse(score_path)
except Exception:
from music21 import converter
score = converter.parse(score_path)
upper = score.parts[upper_part_idx].flat.notes
lower = score.parts[lower_part_idx].flat.notes
# Align notes by offset
upper_notes = [(n.offset, n) for n in upper if isinstance(n, note.Note)]
lower_notes = [(n.offset, n) for n in lower if isinstance(n, note.Note)]
# Build dict: offset -> pitch
upper_dict = {off: n for off, n in upper_notes}
lower_dict = {off: n for off, n in lower_notes}
shared_offsets = sorted(set(upper_dict.keys()) & set(lower_dict.keys()))
violations = []
for i in range(len(shared_offsets) - 1):
off1 = shared_offsets[i]
off2 = shared_offsets[i + 1]
u1, u2 = upper_dict[off1], upper_dict[off2]
l1, l2 = lower_dict[off1], lower_dict[off2]
# Harmonic intervals at each timepoint
harm_int1 = abs(u1.pitch.midi - l1.pitch.midi) % 12
harm_int2 = abs(u2.pitch.midi - l2.pitch.midi) % 12
# Motion directions
upper_motion = u2.pitch.midi - u1.pitch.midi
lower_motion = l2.pitch.midi - l1.pitch.midi
same_direction = (upper_motion > 0 and lower_motion > 0) or \
(upper_motion < 0 and lower_motion < 0)
for target in target_semitones:
# Check modulo 12 for compound intervals
if harm_int1 == target % 12 and harm_int2 == target % 12 and same_direction:
label = 'parallel fifths' if target == 7 else 'parallel octaves'
# Get measure number
m_num = u2.measureNumber if hasattr(u2, 'measureNumber') else '?'
violations.append({
'measure': m_num,
'beat_offset': off2,
'upper_notes': f"{u1.nameWithOctave}->{u2.nameWithOctave}",
'lower_notes': f"{l1.nameWithOctave}->{l2.nameWithOctave}",
'interval_semitones': target,
'violation_type': label,
})
return violations
# --- Run ---
violations = detect_parallel_intervals('bach/bwv66.6', upper_part_idx=0, lower_part_idx=3)
if violations:
print(f"Found {len(violations)} parallel interval violations:")
for v in violations[:10]:
print(f" Measure {v['measure']}: {v['violation_type']} — "
f"upper {v['upper_notes']} / lower {v['lower_notes']}")