PLR group algebra for collaborative counterpoint β you can never play a wrong note.
In music theory, the space of chord progressions is vast. Most of it sounds bad. For centuries, composers have navigated this space by ear, by rules of thumb ("avoid parallel fifths"), and by studying counterpoint. But there's a mathematical structure underlying "good" voice leading, and it has a name: the PLR group.
If you're building generative music systems, collaborative music tools, or AI composition assistants, you need to know that every chord transition you produce is musically valid. You don't want to filter bad progressions β you want a space where bad progressions can't exist.
The PLR group (Parallel / Leading-tone exchange / Relative) is a group of 3 involutions that acts transitively on the 24 major and minor triads. It's isomorphic to the dihedral group Dββ β the symmetry group of a regular 12-gon. The profound fact is this:
Every PLR transformation produces a chord that shares at least one common tone with the previous chord.
This means voice leading is automatically smooth. You physically cannot "leap" to a distant chord β every step moves at most a few semitones per voice. The PLR lattice is a graph where every edge is a valid, smooth voice leading.
This crate implements the full PLR algebra:
- The 3 generators: P, L, R
- The 24-element group action on triads (root + quality pairs)
- The PLR lattice (Cayley graph) with BFS shortest paths
- Voice-leading distance computation
- Species counterpoint rules checking
- Nearest-triad finding β give it arbitrary pitches, get the closest valid triad
ββββββββββββββββ
β Triad β
β (root+quality)β
ββββββββ¬ββββββββ
β
βββββββββββββββββΌββββββββββββββββ
β β β
ββββββββΌβββββββ βββββββΌβββββββ ββββββββΌβββββββ
β P(allel) β β L(eading) β β R(elative) β
β flip qualityβ β mediant β β rel. major/ β
β same root β β exchange β β minor β
ββββββββ¬βββββββ βββββββ¬βββββββ ββββββββ¬βββββββ
β β β
βββββββββββββββββΌββββββββββββββββ
β
ββββββββββββββΌβββββββββββββ
β PLR Word β
β (sequence of P, L, R) β
β with compose, inverse, β
β reduce β
ββββββββββββββ¬βββββββββββββ
β
ββββββββββββββΌβββββββββββββ
β PLR Lattice β
β (24 nodes, 3 edges each)β
β BFS shortest path, β
β distance, at_distance β
ββββββββββββββ¬βββββββββββββ
β
ββββββββββββββββββββΌβββββββββββββββββββ
β β β
ββββββββββΌβββββββββ ββββββββΌββββββββ βββββββββΌβββββββββ
β Voice Leading β β Counterpoint β β Nearest β
β (minimal β β Rules β β Triad β
β semitone β β (no parallel β β (find closest β
β distance) β β 5ths/octs) β β valid triad) β
βββββββββββββββββββ ββββββββββββββββ ββββββββββββββββββ
| Module | Purpose |
|---|---|
chord |
Triad (root + quality), pitch classes, note names |
transform |
P, L, R transformations, PLR words (sequences) |
group |
Orbit computation, Cayley table, word finding, axiom verification |
lattice |
PLR lattice graph with BFS shortest path |
voice |
Voice-leading distance computation |
counterpoint |
Species counterpoint rules (no parallel 5ths/octaves) |
nearest |
Find the closest valid triad to arbitrary pitches |
Given a triad T = (root, quality), the three transformations are:
Same root, flip quality. P(C) = Cm, P(Am) = A.
C major β C minor (root stays, third drops by semitone)
G minor β G major (root stays, third rises by semitone)
Major: the third becomes the new root of a minor triad. L(C) = Em. Minor: the fifth moves up a semitone to become the root of a major triad. L(Cm) = Aβ.
L(C) = Em (E = third of C = root + 4)
L(Cm) = Ab (Ab = fifth of Cm + 1 semitone)
Relative major/minor, moving by a minor third.
R(C) = Am (down a minor third)
R(Am) = C (up a minor third)
- Involution: PΒ² = LΒ² = RΒ² = identity (each is its own inverse)
- Transitivity: starting from any triad, you can reach all 24 triads
- Isomorphism: the PLR group β Dββ (dihedral group of order 24)
- Common tones: every PLR edge shares at least one pitch class
use groovemesh_plr::{Triad, Quality, apply_p, apply_l, apply_r, PLR, apply};
// Create triads
let c_major = Triad::new_unchecked(0, Quality::Major);
let a_minor = Triad::new_unchecked(9, Quality::Minor);
// Apply transformations
assert_eq!(apply_p(c_major), Triad::new_unchecked(0, Quality::Minor)); // C β Cm
assert_eq!(apply_l(c_major), Triad::new_unchecked(4, Quality::Minor)); // C β Em
assert_eq!(apply_r(c_major), Triad::new_unchecked(9, Quality::Minor)); // C β Am
// PΒ² = identity (involution)
assert_eq!(apply(PLR::P, apply(PLR::P, c_major)), c_major);use groovemesh_plr::{Triad, Quality, Lattice, find_word};
let c = Triad::new_unchecked(0, Quality::Major);
let fs = Triad::from_name("F#m").unwrap();
// Find the shortest PLR path
let lattice = Lattice::build();
let path = lattice.shortest_path(c, fs).unwrap();
println!("C β F#m: {} steps: {:?}", path.len(), path);
// Or find the PLR word (sequence of operations)
let word = find_word(c, fs).unwrap();
println!("Word: {} (applied: {})", word, word.apply(c));use groovemesh_plr::{Triad, Quality, minimal_voice_leading, voice_leading_distance};
let c = Triad::new_unchecked(0, Quality::Major);
let cm = Triad::new_unchecked(0, Quality::Minor);
let vl = minimal_voice_leading(c, cm);
println!("C β Cm: distance = {} semitones", vl.distance);
println!("Movements: {:?}", vl.movements);use groovemesh_plr::{Triad, Quality, CounterpointRules};
let rules = CounterpointRules::new();
let c = Triad::new_unchecked(0, Quality::Major);
let cm = Triad::new_unchecked(0, Quality::Minor);
// Check if a transition is legal
assert!(rules.check(c, cm).is_ok()); // P transition: always legal
// Find a legal PLR path from C to Am
let am = Triad::new_unchecked(9, Quality::Minor);
let path = rules.legal_path(c, am, 10);
println!("Legal path C β Am: {} steps", path.len());use groovemesh_plr::{nearest_triad, nearest_plr_triad, Triad, Quality};
// Someone plays C, E, G β that's C major
let triad = nearest_triad(&[0, 4, 7]).unwrap();
assert_eq!(triad, Triad::new_unchecked(0, Quality::Major));
// Someone plays C, F# β find the closest triad
let ambiguous = nearest_triad(&[0, 6]).unwrap();
println!("Closest triad to C+F#: {}", ambiguous);
// From a current triad, take one PLR step toward a pitch set
let c = Triad::new_unchecked(0, Quality::Major);
let next = nearest_plr_triad(c, &[9, 0, 4]); // Am pitches
assert_eq!(next, Triad::new_unchecked(9, Quality::Minor)); // R(C) = Amuse groovemesh_plr::{orbit, verify_group_axioms, cayley_table, Triad, Quality};
// The orbit of any triad is all 24 triads (transitivity)
let c = Triad::new_unchecked(0, Quality::Major);
let all = orbit(c);
assert_eq!(all.len(), 24);
// Verify group axioms
assert!(verify_group_axioms());
// Full Cayley table
let table = cayley_table();
println!("Cayley table has {} entries", table.len());- Triad operations: O(1) β arithmetic on pitch classes
- Voice leading: O(1) β tries 6 permutations of 3 voices
- BFS shortest path: O(24) β the lattice has only 24 nodes
- Nearest triad: O(24) β scores all triads
- Group operations: O(24) for orbits, O(24) for Cayley table
The entire PLR lattice fits in constant space. There are only 24 triads and 72 edges (3 per triad). This means every operation is effectively O(1).
| Feature | groovemesh-plr | Music21 (Python) | Traditional theory |
|---|---|---|---|
| PLR group | β Full implementation | Partial | Manual |
| Voice leading | β Minimal distance | Yes | Approximate |
| Counterpoint rules | β Configurable | Yes | Manual |
| Nearest triad | β From arbitrary pitches | No | N/A |
| Lattice graph | β BFS shortest path | No | N/A |
| Group verification | β Automated | No | N/A |
| Language | Rust (zero-cost) | Python | N/A |
groovemesh-plr provides voice-leading algebra for:
spreadsheet-engineβ MIDI cells can use PLR for smooth harmonic transitionslotka-beatsβ species compete within PLR-legal harmonic spacestropical-synthβ timbre morphing along PLR lattice edges
MIT