Chord progression analysis via holonomy β detect modulations, modal interchange, and cycle violations in harmony. Proves that harmonic movement = cycle consistency on the circle of fifths.
Holonomy-harmony treats the circle of fifths as a topological space and chord progressions as paths through it. When a progression returns to its starting tonal center, it has zero holonomy β the cycle is consistent. When it doesn't, the holonomy number tells you exactly how far the harmony wandered. This is constraint theory applied to music theory.
The library gives you:
- Holonomy computation β net circle-of-fifths displacement, winding number, max deviation for any chord progression
- Progression classification β diatonic, modal interchange, modulation, chromatic mediant, chromatic
- Roman numeral parsing β full parser for
I,V7/vi,bVI,iiΒ°, with secondary dominant detection - Tonal graph β directed weighted graph of pitch-class transitions with functional direction classification
- Modulation detection β automatic detection of key changes, secondary dominants, and borrowed chords
- Stability scoring β 0β1 scale measuring how "safe" vs "adventurous" a progression is
- Topological analysis β Euler characteristic, Betti numbers, fundamental group, homology groups of harmonic spaces
- Curvature flow β discrete Ricci flow that smooths tonal tension over time
- 20 built-in progressions β Pachelbel Canon, Giant Steps, blues, rhythm changes, Coltrane changes, Autumn Leaves, and more
Each chord transition moves you clockwise (dominant) or counter-clockwise (subdominant) on the circle of fifths. Sum all movements:
| Holonomy | Meaning |
|---|---|
| 0 | Cycle closes β progression returned to its tonal center (diatonic) |
| Non-zero | Cycle doesn't close β you modulated or borrowed from another key |
| Large winding number | The progression spirals around the circle of fifths (Coltrane, Giant Steps) |
A I-IV-V-I progression has holonomy 0 and winding number 0 β it's a closed walk. Giant Steps has non-zero holonomy because its major-third cycles don't close on the circle of fifths.
pip install holonomy-harmonyRequires Python β₯ 3.10. No external dependencies (pure Python).
from holonomy_harmony import analyze_progression, PROGRESSIONS
# Analyze the Pachelbel Canon in D major
symbols, tonic, mode = PROGRESSIONS["pachelbel_canon"]
result = analyze_progression(symbols, key_tonic=tonic, mode=mode)
print(f"Holonomy: {result.holonomy.holonomy}") # -5
print(f"Winding: {result.holonomy.winding_number}") # 0.083
print(f"Type: {result.holonomy.progression_type}") # MODULATION
print(f"Stability: {result.stability_score}") # 0.35
print(f"Chords: {len(result.chords)}") # 8for name in ["pachelbel_canon", "blues_12_bar", "giant_steps", "axis_progression"]:
symbols, tonic, mode = PROGRESSIONS[name]
result = analyze_progression(symbols, key_tonic=tonic, mode=mode)
print(f"{name:20s} holonomy={result.holonomy.holonomy:3d} "
f"type={result.holonomy.progression_type.name:20s} "
f"stability={result.stability_score:.2f}")
# pachelbel_canon holonomy= -5 type=MODULATION stability=0.35
# blues_12_bar holonomy= 0 type=DIATONIC stability=0.88
# giant_steps holonomy= 3 type=CHROMATIC_MEDIANT stability=0.38
# axis_progression holonomy= 0 type=DIATONIC stability=0.93from holonomy_harmony import parse_roman
# Simple
I = parse_roman("I", key_tonic=0, mode="major")
print(I.root, I.quality) # 0 'maj'
# Secondary dominant: V7/vi
V7_of_vi = parse_roman("V7/vi", key_tonic=0, mode="major")
print(V7_of_vi.root) # 10 (A#)
print(V7_of_vi.is_secondary_dominant) # True
print(V7_of_vi.implied_key) # (9, 'major')
# Borrowed chord: bVI in major
bVI = parse_roman("bVI", key_tonic=0, mode="major")
print(bVI.root) # 8 (Aβ)
print(bVI.is_diatonic) # Falsefrom holonomy_harmony import compute_holonomy, winding_number, classify_progression
roots = [0, 7, 9, 5, 0] # C β G β A β F β C
h = compute_holonomy(roots, wrap=True)
print(h.holonomy) # net circle-of-fifths displacement (semitones)
print(h.winding_number) # full rotations around Co5
print(h.max_deviation) # furthest wander from tonic
print(h.is_consistent()) # True if holonomy == 0
# Shortcuts
print(winding_number(roots)) # shortcut for winding
print(classify_progression(roots)) # ProgressionType enumfrom holonomy_harmony import TonalGraph
g = TonalGraph()
g.build_from_progression([0, 7, 9, 5, 0])
print(g) # <TonalGraph nodes=12 edges=4 total_weight=4.0>
print(g.adjacency_matrix()) # 12Γ12 transition weight matrix
print(g.transition_probability(0, 7)) # P(next=G | current=C)
print(g.neighbors(0)) # outgoing transitions from C
print(g.weight(0, 7)) # weight of CβG transitionfrom holonomy_harmony import TopologyAnalyzer
analyzer = TopologyAnalyzer()
# Build simplicial complex from a progression
roots = [0, 7, 9, 5, 0, 7, 2, 9, 4]
invariants = analyzer.analyze_progression(roots)
print(invariants.euler_characteristic) # Ο = V - E + F
print(invariants.betti) # Betti numbers (Ξ²β, Ξ²β, Ξ²β)
print(invariants.homology_0) # Hβ β
Z^{components}
print(invariants.homology_1) # Hβ β
Z^{holes}
print(invariants.fundamental_group) # Οβ generators and relations
print(invariants.genus) # Generalized genusfrom holonomy_harmony import TonalGraph, CurvatureFlow, CurvatureMeasure
g = TonalGraph()
g.build_from_progression([0, 7, 9, 5, 0, 2, 7, 0])
flow = CurvatureFlow(g, dt=0.1, curvature_measure=CurvatureMeasure.HARMONIC)
state = flow.run(max_steps=100)
print(f"Converged: {state.converged}")
print(f"Steps: {state.step}")
# Most tense transitions (highest curvature)
tense = flow.most_tense_transitions(n=3)
for e in tense:
print(f" {e.source}β{e.target}: curvature={e.curvature:.3f} ({e.direction.name})")| Function | Description |
|---|---|
analyze_progression(symbols, key_tonic, mode, wrap) |
Full analysis: chords β graph β holonomy β modulations β stability |
detect_modulations(analysis) |
Extract modulation points from an analysis |
score_stability(analysis) |
Get the stability score (0β1) |
| Function / Class | Description |
|---|---|
compute_holonomy(roots, wrap=False) |
Compute holonomy of a root-pitch-class progression |
winding_number(roots) |
Net rotations around the circle of fifths |
classify_progression(roots) |
Classify as ProgressionType enum |
HolonomyResult |
Frozen result with .holonomy, .winding_number, .max_deviation, .progression_type, .steps, .cumulative |
| Function / Class | Description |
|---|---|
parse_roman(symbol, key_tonic, mode) |
Parse "V7/vi" β Chord object |
Chord |
Frozen dataclass: root, quality, function, key, is_diatonic, is_secondary_dominant, implied_key |
| Class / Method | Description |
|---|---|
TonalGraph() |
Directed graph of pitch-class transitions |
.add_transition(from, to, weight) |
Add/increment a transition |
.build_from_progression(roots) |
Populate from root sequence |
.adjacency_matrix() |
12Γ12 normalised weight matrix |
.transition_probability(from, to) |
Conditional probability P(to|from) |
.neighbors(pc) |
Outgoing neighbors |
.weight(from, to) |
Raw transition weight |
classify_direction(from, to) |
Classify as DOMINANT, SUBDOMINANT, RESOLUTION, etc. |
| Class | Description |
|---|---|
TopologyAnalyzer(graph) |
Compute topological invariants |
.build_complex() |
Build simplicial complex from tonal graph |
.compute_euler() |
Euler characteristic Ο |
.compute_betti() |
Betti numbers (Ξ²β, Ξ²β, Ξ²β) |
.compute_fundamental_group() |
Οβ presentation (generators + relations) |
.compute_homology() |
Hβ, Hβ, Hβ as HomologyGroup objects |
.analyze() |
All invariants at once β TopologicalInvariants |
| Class / Method | Description |
|---|---|
CurvatureFlow(graph, dt, curvature_measure) |
Discrete Ricci flow |
.initialize() |
Set up initial flow state |
.step() |
One flow step β CurvatureSnapshot |
.run(max_steps, convergence_tol) |
Run until convergence |
.most_tense_transitions(n) |
Top-n highest-curvature edges |
.total_curvature() |
Sum of all edge curvatures |
CurvatureMeasure.COMBINATORIAL / .HARMONIC / .RICCI |
Curvature definitions |
20 famous progressions in PROGRESSIONS dict:
from holonomy_harmony import PROGRESSIONS
for name, (symbols, tonic, mode) in PROGRESSIONS.items():
print(f"{name}: {' '.join(symbols)}")Includes: pachelbel_canon, blues_12_bar, giant_steps, chopin_em_prelude, axis_progression, andulusian_cadence, doo_wop, sensitive_female, rhythm_changes, bird_changes, coltrane_changes, autumn_leaves, creep, hey_jude, take_five, minor_ii_v_i, and more.
βββββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββββ
β analyzer.py ββββββΆβ cycle_checker.py ββββββΆβ tonal_graph.py β
β β β β β β
β parse_roman() β β compute_holonomy()β β TonalGraph β
β analyze_ β β winding_number() β β TransitionDir β
β progression() β β classify_ β β adjacency_ β
β detect_ β β progression() β β matrix() β
β modulations() β β HolonomyResult β β neighbors() β
β score_stability β β β β β
βββββββββββββββββββ βββββββββββββββββββββ ββββββββββββββββββ
β β
β βββββββββ΄βββββββββ
β β β
β βββββββββΌβββββββ ββββββββΌβββββββββββ
β β topology.py β β flow.py β
β β β β β
β β Euler char β β CurvatureFlow β
β β Betti nums β β Ricci flow β
β β Οβ group β β CurvatureMeas β
β β Homology H_k β β CurvatureSnap β
β ββββββββββββββββ βββββββββββββββββββ
β
βΌ
Input: Roman numerals β Chord objects β pitch-class roots
Process: roots β circle-of-fifths steps β holonomy signature
Output: HolonomyResult + stability + modulations + topology
Given a chord progression with roots rβ, rβ, ..., rβ, each transition rα΅’ β rα΅’ββ maps to a step on the circle of fifths:
step(i) = Co5_position(rα΅’ββ) - Co5_position(rα΅’) (mod 12, shortest path)
The holonomy is the net displacement:
holonomy = Ξ£ step(i) (converted back to semitones via Γ7 mod 12)
- holonomy = 0: the progression closed in the same key
- holonomy β 0: you ended up in a different key than you started
The winding number counts full rotations:
winding = Ξ£ step(i) / 12
score = (diatonic_count / total_chords)
Γ penalty(holonomy β 0)
Γ penalty(max_deviation > threshold)
Γ penalty(modulations)
Γ penalty(modal_interchanges)
Clamped to [0, 1]. A fully diatonic I-IV-V-I scores ~1.0. Giant Steps scores ~0.38.
The harmonic space is modeled as a simplicial complex:
- 0-simplices: pitch classes that appear in the progression
- 1-simplices: directed edges between consecutive roots
- 2-simplices: triangles β triples of pitch classes forming closed 3-cycles
From this complex we compute Euler characteristic (Ο = V β E + F), Betti numbers (Ξ²β = components, Ξ²β = independent loops, Ξ²β = enclosed cavities), and the fundamental group Οβ.
Edge weights evolve according to:
dw(e)/dt = βΞΊ(e) Β· w(e)
Where ΞΊ(e) is the curvature on edge e. Three curvature measures are supported:
- Combinatorial: ΞΊ = 1/d_s + 1/d_t β w
- Harmonic: combinatorial + tonal direction bonus (dominant = high curvature, resolution = negative)
- Ollivier-Ricci: ΞΊ = 1 β Wβ(ΞΌβ, ΞΌα΅§) where Wβ is the Wasserstein-1 transport distance
pip install pytest
pytest tests/ -v183 tests across 5 test files covering tonal graph, cycle checker, analyzer, and topology.
MIT