Use this Skill for computational musicology with music21: score analysis, harmonic reduction, melodic contour, counterpoint checking, and corpus comparison.
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Um comando direto ignora o prompt de revisão. Verifique a origem antes de executá-lo.
Use this Skill for computational musicology with music21: score analysis, harmonic reduction, melodic contour, counterpoint checking, and corpus comparison.
Query the music21 built-in corpus (Bach chorales, classical works) for statistical analysis
Compare melodic similarity across pieces using edit distance on interval sequences
Build n-gram models of harmonic progressions for style analysis
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
Background
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.
Core Object Hierarchy
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
Harmonic Analysis Concepts
Roman numeral analysis: Each chord is labelled relative to the local key
(e.g., I, IV, V7, ii6). harmony.romanNumeralFromChord(chord, key) returns a
RomanNumeral object with .figure (string label) and .scaleDegree.
Pitch class sets: Ignore octave and register; a C major triad = {0, 4, 7}.
Useful for atonal / post-tonal analysis.
Krumhansl-Schmuckler key-finding: Correlates the distribution of pitch-class
durations against major/minor key profiles to estimate the most likely key.
Melodic Contour
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.
Counterpoint Rules (Two-Voice)
The standard prohibition rules in strict two-voice counterpoint:
Rule
Definition
Parallel fifths
Two voices move in same direction, both intervals are P5
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')))}")
Core Workflow
Step 1 — Load a Score and Run Roman Numeral Analysis
import pandas as pd
from music21 import corpus, harmony, roman, chord, key as m21key
defanalyze_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:
ifnotisinstance(element, chord.Chord):
continue# Local key at this position
local_key = element.getContextByClass('KeySignature')
analysis_key = detected_key # fall back to global keytry:
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
defharmonic_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 inrange(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))
Step 2 — Melodic Contour and Interval Histogram
import numpy as np
import matplotlib.pyplot as plt
from music21 import corpus, note, interval
defextract_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 ifisinstance(n, note.Note)]
# Compute melodic intervals in semitones
semitones = []
for i inrange(len(notes) - 1):
intv = interval.Interval(notes[i], notes[i + 1])
semitones.append(intv.semitones)
# Contour reduction: keep local extrema (local max and min)defcontour_reduction(seq):
iflen(seq) < 3:
return seq
reduced = [seq[0]]
for i inrange(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 else0.0,
'std_interval': float(np.std(semitones)) if semitones else0.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")
Step 3 — Parallel Fifths Detector in Two-Voice Counterpoint
from music21 import corpus, chord, interval, note
from typing importList, Tupledefdetect_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 isNone:
target_semitones = [7, 12] # Perfect fifth, perfect octave/unison# Load from corpus or filetry:
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 ifisinstance(n, note.Note)]
lower_notes = [(n.offset, n) for n in lower ifisinstance(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 inrange(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 > 0and lower_motion > 0) or \
(upper_motion < 0and lower_motion < 0)
for target in target_semitones:
# Check modulo 12 for compound intervalsif harm_int1 == target % 12and harm_int2 == target % 12and same_direction:
label = 'parallel fifths'if target == 7else'parallel octaves'# Get measure number
m_num = u2.measureNumber ifhasattr(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']}")
else:
print("No parallel fifths or octaves detected between selected voices.")
Advanced Usage
Corpus Search and Comparative Analysis
from music21 import corpus
import pandas as pd
defcompare_chorale_keys() -> pd.DataFrame:
"""
Search all Bach chorales in the corpus and compare key distribution.
Returns a DataFrame of detected keys and their frequencies.
"""
results = []
# Get all Bach chorale paths
chorale_paths = corpus.getComposer('bach')
for path in chorale_paths[:50]: # limit for demotry:
score = corpus.parse(path)
detected_key = score.analyze('key')
results.append({
'path': str(path),
'key': str(detected_key),
'mode': detected_key.mode,
'tonic': detected_key.tonic.name,
'confidence': round(detected_key.correlationCoefficient, 3),
})
except Exception as e:
continue
df = pd.DataFrame(results)
key_freq = df['key'].value_counts().head(20)
print("Most common keys in Bach chorales:")
print(key_freq)
return df
Melodic Similarity with Edit Distance
defmelodic_edit_distance(seq1: list, seq2: list) -> int:
"""
Compute Levenshtein edit distance between two melodic interval sequences.
Lower distance = more similar melodies.
Args:
seq1: list of semitone intervals for melody 1
seq2: list of semitone intervals for melody 2
Returns:
Integer edit distance.
"""
m, n = len(seq1), len(seq2)
dp = [[0] * (n + 1) for _ inrange(m + 1)]
for i inrange(m + 1):
dp[i][0] = i
for j inrange(n + 1):
dp[0][j] = j
for i inrange(1, m + 1):
for j inrange(1, n + 1):
cost = 0if seq1[i - 1] == seq2[j - 1] else1
dp[i][j] = min(
dp[i - 1][j] + 1, # deletion
dp[i][j - 1] + 1, # insertion
dp[i - 1][j - 1] + cost # substitution
)
return dp[m][n]
Voice Leading Statistics
defvoice_leading_stats(score_path: str) -> dict:
"""
Summarize voice leading motion statistics for a four-voice score:
fraction of oblique, contrary, similar, and parallel motion.
"""from music21 import corpus, note as m21note, converter
try:
score = corpus.parse(score_path)
except Exception:
score = converter.parse(score_path)
parts = score.parts
iflen(parts) < 2:
return {}
motion_counts = {'contrary': 0, 'parallel': 0, 'similar': 0, 'oblique': 0}
for part_a_idx inrange(len(parts) - 1):
for part_b_idx inrange(part_a_idx + 1, len(parts)):
notes_a = [n for n in parts[part_a_idx].flat.notes ifisinstance(n, m21note.Note)]
notes_b = [n for n in parts[part_b_idx].flat.notes ifisinstance(n, m21note.Note)]
min_len = min(len(notes_a), len(notes_b)) - 1for i inrange(min_len):
ma = notes_a[i + 1].pitch.midi - notes_a[i].pitch.midi
mb = notes_b[i + 1].pitch.midi - notes_b[i].pitch.midi
if ma == 0or mb == 0:
motion_counts['oblique'] += 1elif ma > 0and mb < 0or ma < 0and mb > 0:
motion_counts['contrary'] += 1elif ma == mb:
motion_counts['parallel'] += 1else:
motion_counts['similar'] += 1
total = sum(motion_counts.values())
return {k: round(v / total, 3) if total else0.0for k, v in motion_counts.items()}
Troubleshooting
Error
Cause
Fix
SubConverterException: Cannot find a path to MuseScore
MuseScore not installed or not on PATH
Install MuseScore or use score.show('text') for text output
CorpusException: Could not find path 'bach/...'
Incorrect corpus path
Use corpus.search('bach') to list available paths
AttributeError: 'Rest' object has no attribute 'pitch'