from klotho import plot, play
from klotho.tonos import ToneLattice, Scale, PitchCollection as PC
from klotho.utils.algorithms import ratios_to_coordinates
from klotho.utils.algorithms.basis import (
monzo_from_ratio, ratio_from_monzo, basis_matrix, is_unimodular,
change_of_basis, prime_to_generator_coords, generator_to_prime_coords,
ratio_from_prime_coords, ratio_from_generator_coords
)
import sympy as spGenerators and Change of Basis¶
So far, every lattice we’ve built has used prime numbers as its axes. The x-axis is 3, the y-axis is 5, etc. This is the “default” or “canonical” basis for a harmonic space and it works perfectly well.
In the “canonical” prime lattice, moving one step along the 3-axis gives you , which octave-reduces to —a perfect fifth. Moving one step along the 5-axis gives you , which octave-reduces to —a major third. So our two axes correspond to steps of perfect fifths and major thirds.
That’s fine, but it’s not the only option. We can use (almost) any set of ratios as our generators—the intervals that define the steps of each dimensional axis.
Why would we want to do this? Think about it from a musical perspective. What if both axes were thirds—one major, one minor? Or one axis was a fourth and the other a sixth?
The result is that different axis choices organize the same set of ratios differently in the lattice. The ratios themselves don’t change—but which ones are neighbors, which ones are far apart, and which relationships are easy to see all depend on the choice of generators.
Before we get into the math of how this works, let’s hear why it matters. Here’s the same path — just moving along the x-axis — on two different lattices. One uses the prime basis (perfect fifths along x), the other uses major thirds (5/4) as its x-axis:
x_axis_path = [(-2,0), (-1,0), (0,0), (1,0), (2,0)]
tl_prime = ToneLattice(2, resolution=2)
print("Prime basis (x = perfect fifths 3/2):")
plot(tl_prime, path=x_axis_path, figsize=(7,5)).play(dur=0.3)
tl_thirds = ToneLattice.from_generators(['5/4', '6/5'], resolution=2, equave_reduce=True)
print("\nCustom basis (x = major thirds 5/4):")
plot(tl_thirds, path=x_axis_path, figsize=(7,5)).play(dur=0.3)Prime basis (x = perfect fifths 3/2):
Custom basis (x = major thirds 5/4):
Same path, completely different sound. The geometry is identical — five points along the x-axis — but the intervals are different because the axes now correspond to different ratios. This is the power of changing the basis.
Now let’s understand the math behind it...
But we can’t just pick anything...¶
There’s a catch. Our new set of generators has to describe the same harmonic space as the primes. If it doesn’t, then some ratios become unreachable—we’d have gaps in our lattice.
More precisely: the primes generate an infinite set of ratios—every ratio you can build by multiplying powers of 2, 3, and 5. A valid set of generators must produce exactly the same infinite set. Not a subset. Not a superset. The same one. Different coordinates, same ratios.
The way we check this involves a bit of linear algebra. Don’t worry, we’ll take it step by step.
A note on “infinite”¶
We just said the primes generate an infinite set of ratios. It’s worth being precise about what this means—because “infinite” does not mean “all.”
The set of all possible ratios (using any primes whatsoever) is also infinite, but it’s a larger infinity. Our 5-limit set is a proper subset of it. For instance, the ratio does not exist in 5-limit space—you’d need to include the prime 7 to reach it.
This is actually useful. Choosing a prime limit (or a specific set of generators) is a way of narrowing down the space of possibilities. Instead of the infinite ocean of all possible ratios, we work within a specific infinite subset—one that has a particular harmonic character determined by which primes we include.
Setting a resolution on the lattice narrows things further, from an infinite set down to a finite one. Each of these choices—prime limit, generators, resolution—is a constraint that defines the space we’re working in.
This is what design is: making decisions. And the word decision itself comes from the Latin decidere—“to cut off.” Every decision removes possibilities. A prime limit cuts away certain primes. A resolution cuts away distant points. What’s left is the space you actually compose in.
The Basis Matrix¶
Let’s say we want to use generators instead of primes. First, we express each generator as a monzo (its prime exponents). Then we line those monzos up as columns of a matrix. This gives us the basis matrix :
This matrix is a translation dictionary between two coordinate systems:
If you have coordinates in the generator system and want to convert to prime coordinates: multiply by .
If you have prime coordinates and want to convert to generator coordinates: multiply by .
Let’s build one. We’ll use as our generators. First, the monzo of each generator in the prime basis :
primes = [2, 3, 5]
generators = ['2/1', '5/4', '6/5']
for g in generators:
m = monzo_from_ratio(g, primes)
print(f"{g:>6s} -> monzo: {tuple(int(x) for x in m)}") 2/1 -> monzo: (1, 0, 0)
5/4 -> monzo: (-2, 0, 1)
6/5 -> monzo: (1, 1, -1)
Now we stack those monzos as columns to form the basis matrix :
A = basis_matrix(primes, generators)
print("Basis matrix A (columns = generator monzos):\n")
sp.pprint(A)
print()
print(f"det(A) = {int(A.det())}")Basis matrix A (columns = generator monzos):
⎡1 -2 1 ⎤
⎢ ⎥
⎢0 0 1 ⎥
⎢ ⎥
⎣0 1 -1⎦
det(A) = -1
The Determinant and Unimodularity¶
See that det(A) value? That’s the determinant of the matrix—a single number computed from its entries that tells us something fundamental about the transformation it represents.
If you haven’t encountered determinants before, the intuition is this: the determinant measures how a matrix scales things. A determinant of 2 means the transformation doubles areas (or volumes, in higher dimensions). A determinant of 0 means the transformation crushes everything down to a lower dimension—something gets lost. A determinant of means the transformation preserves the “size” of things perfectly.
For our purposes, the crucial condition is:
A matrix with this property is called unimodular. What does that mean in practice?
It means the inverse matrix also has integer entries. So when we convert coordinates (the exponents, i.e., the monzo values) between the two systems, we always get whole numbers. No fractional exponents, no rounding, no information loss. Every point in the prime lattice maps to exactly one point in the generator lattice, and vice versa.
(Note: the ratios themselves are still fractions, of course. What stays integer are the coordinates—the exponent vectors we use to address points in the lattice.)
What happens when this condition is not met?¶
If : the generators are linearly dependent—they don’t actually span the full space. Some ratios are simply unreachable. It’s like trying to describe 3-D space with only 2 directions.
If : the generators span a sublattice—a subset of the full space. You can reach some ratios but not others. The ones you can’t reach would require fractional exponents, which defeats the purpose of an integer coordinate system.
So: unimodularity is what guarantees that our new generators describe the same harmonic space as the primes. Not a subset. Not a superset. The same one.
print(f"Is {{2, 5/4, 6/5}} unimodular? {is_unimodular(basis_matrix([2,3,5], ['2/1', '5/4', '6/5']))}")
print(f"Is {{2, 9/8, 5/4}} unimodular? {is_unimodular(basis_matrix([2,3,5], ['2/1', '9/8', '5/4']))}")
print(f"Is {{2, 3/2, 9/8}} unimodular? {is_unimodular(basis_matrix([2,3,5], ['2/1', '3/2', '9/8']))}")
print(f"Is {{2, 3, 5}} unimodular? {is_unimodular(basis_matrix([2,3,5], ['2/1', '3/1', '5/1']))}")Is {2, 5/4, 6/5} unimodular? True
Is {2, 9/8, 5/4} unimodular? False
Is {2, 3/2, 9/8} unimodular? False
Is {2, 3, 5} unimodular? True
The standard prime basis is trivially unimodular—its basis matrix is just the identity matrix. The set is also unimodular, so it’s a valid alternative basis.
But is not—its determinant is 2, meaning it only reaches a sublattice (every other point, roughly). And has determinant 0: since is just divided by 2, the two generators are redundant—they point in the same “direction” and can’t span the full space.
Coordinate Conversion¶
Ok, so what does it actually look like to convert between systems? Let’s work through it.
We’ll use the basis—where one axis is a major third and the other is a minor third:
primes = [2, 3, 5]
generators = ['2/1', '5/4', '6/5']
A, A_inv = change_of_basis(primes, generators)
print("A (generators -> primes):")
sp.pprint(A)
print()
print("A_inv (primes -> generators):")
sp.pprint(A_inv)A (generators -> primes):
⎡1 -2 1 ⎤
⎢ ⎥
⎢0 0 1 ⎥
⎢ ⎥
⎣0 1 -1⎦
A_inv (primes -> generators):
⎡1 1 2⎤
⎢ ⎥
⎢0 1 1⎥
⎢ ⎥
⎣0 1 0⎦
for r in ['3/2', '5/4', '6/5', '9/8', '16/15']:
prime_coords = monzo_from_ratio(r, primes)
gen_coords = prime_to_generator_coords(prime_coords, A_inv)
print(f"{r:>8s} prime: {tuple(int(x) for x in prime_coords)} -> generator: {tuple(gen_coords)}") 3/2 prime: (-1, 1, 0) -> generator: (0, 1, 1)
5/4 prime: (-2, 0, 1) -> generator: (0, 1, 0)
6/5 prime: (1, 1, -1) -> generator: (0, 0, 1)
9/8 prime: (-3, 2, 0) -> generator: (-1, 2, 2)
16/15 prime: (4, -1, -1) -> generator: (1, -2, -1)
Look at what happened. In the prime basis, has the monzo —meaning: go two octaves down and one step in the 5-direction. So, multiple steps in different dimensions.
In the generator basis, that same interval, , is just —one step along the second axis. Because the second axis is .
Same story for : in prime coords it’s , but in generator coords it’s —one step along the third axis.
Choosing generators lets us decide what it means to move one step in the lattice. The primes are the canonical mathematical basis—they’re always valid—but a different set of generators can make the lattice’s coordinate system line up with whatever intervals we’re interested in.
Conditioning¶
One more thing before we start building lattices with custom generators.
Not all unimodular bases are equally convenient. Even if a basis is valid (determinant ), some are “tidier” than others.
The thing to look at is the inverse matrix . If its entries are small—mostly 0s and s—then converting coordinates is straightforward. If the entries are large, then a simple interval in one system might require a big, unwieldy coordinate in the other.
Let’s compare two valid bases and see what looks like in each:
primes = [2, 3, 5]
gens_a = ['2/1', '5/4', '6/5']
gens_b = ['2/1', '3/2', '81/80']
A_a, A_inv_a = change_of_basis(primes, gens_a)
A_b, A_inv_b = change_of_basis(primes, gens_b)
print("Basis {2, 5/4, 6/5}:")
sp.pprint(A_inv_a)
print()
print("Basis {2, 3/2, 81/80}:")
sp.pprint(A_inv_b)
print()
print("Where does 5/4 land in each?")
m = monzo_from_ratio('5/4', primes)
print(f" in {{5/4, 6/5}}: {tuple(int(x) for x in A_inv_a * m)}")
print(f" in {{3/2, 81/80}}: {tuple(int(x) for x in A_inv_b * m)}")Basis {2, 5/4, 6/5}:
⎡1 1 2⎤
⎢ ⎥
⎢0 1 1⎥
⎢ ⎥
⎣0 1 0⎦
Basis {2, 3/2, 81/80}:
⎡1 1 0 ⎤
⎢ ⎥
⎢0 1 4 ⎥
⎢ ⎥
⎣0 0 -1⎦
Where does 5/4 land in each?
in {5/4, 6/5}: (0, 1, 0)
in {3/2, 81/80}: (-2, 4, -1)
print("Several ratios in both bases:")
print()
for r in ['3/2', '5/4', '6/5', '9/8', '16/15']:
m = monzo_from_ratio(r, primes)
ca = tuple(int(x) for x in A_inv_a * m)
cb = tuple(int(x) for x in A_inv_b * m)
print(f" {r:>8s} in {{5/4, 6/5}}: {ca} in {{3/2, 81/80}}: {cb}")Several ratios in both bases:
3/2 in {5/4, 6/5}: (0, 1, 1) in {3/2, 81/80}: (0, 1, 0)
5/4 in {5/4, 6/5}: (0, 1, 0) in {3/2, 81/80}: (-2, 4, -1)
6/5 in {5/4, 6/5}: (0, 0, 1) in {3/2, 81/80}: (2, -3, 1)
9/8 in {5/4, 6/5}: (-1, 2, 2) in {3/2, 81/80}: (-1, 2, 0)
16/15 in {5/4, 6/5}: (1, -2, -1) in {3/2, 81/80}: (3, -5, 1)
The pattern is clear: in the first basis, most coordinates are small. In the second, they tend to be larger and harder to interpret.
The takeaway: unimodularity is necessary, but not sufficient for a “good” basis. A good basis is unimodular and well-conditioned.
Custom Generator Lattices¶
Ok, enough math. Let’s actually hear what different generators do.
Paths¶
We’ll define a short path—a small gesture through the lattice—and play that exact same path through three different lattices, each with different generators. The coordinates are identical every time. The only thing that changes is what each step means musically.
gesture = [(0,0), (1,0), (1,1), (0,1), (-1,1), (-1,0), (-1,-1), (0,-1), (1,-1)]First, the default prime basis. Here, the axes are 3 and 5:
tl_prime = ToneLattice(2, resolution=2)
plot(tl_prime, path=gesture, figsize=(7,7)).play(dur=0.4)
print(f"Prime basis: generators = {tl_prime.generators}", end='\n\n')
# ratios = [str(tl_prime.get_ratio(c)) for c in gesture]
# pc = PC.from_degrees(ratios)
# print("As a sequence:")
# play(pc, dur=0.4)
# print("As a sonority:")
# play(pc, mode='chord', dur=2, strum=0.05)Prime basis: generators = [Fraction(3, 1), Fraction(5, 1)]
Now, the same coordinates on a lattice where the axes are (major third) and (minor third):
tl_thirds = ToneLattice.from_generators(
generators=['5/4', '6/5'],
resolution=2,
equave_reduce=True,
)
plot(tl_thirds, path=gesture, figsize=(7,7)).play(dur=0.4)
print(f"Thirds basis: generators = {tl_thirds.generators}", end='\n\n')
# ratios = [str(tl_thirds.get_ratio(c)) for c in gesture]
# pc = PC.from_degrees(ratios)
# print("As a sequence:")
# play(pc, dur=0.4)
# print("As a sonority:")
# play(pc, mode='chord', dur=2, strum=0.05)Thirds basis: generators = [Fraction(5, 4), Fraction(6, 5)]
And once more—axes of (perfect fourth) and (major sixth):
tl_fourths = ToneLattice.from_generators(
generators=['4/3', '5/3'],
resolution=2,
equave_reduce=True,
)
plot(tl_fourths, path=gesture, figsize=(7,7)).play(dur=0.4)
print(f"Fourths basis: generators = {tl_fourths.generators}", end='\n\n')
# ratios = [str(tl_fourths.get_ratio(c)) for c in gesture]
# pc = PC.from_degrees(ratios)
# print("As a sequence:")
# play(pc, dur=0.4)
# print("As a sonority:")
# play(pc, mode='chord', dur=2, strum=0.05)Fourths basis: generators = [Fraction(4, 3), Fraction(5, 3)]
The coordinates are identical in every case—same loop, same shape. But the ratios at each point are completely different because each lattice interprets those coordinates through a different generator basis.
In the prime lattice, each step is a multiplication by 3 or 5 (octave-reduced). In the thirds lattice, each step is a major or minor third. In the fourths lattice, each step is a fourth or a sixth.
Same path. Same lattice geometry. Completely different music.
Structures¶
We can play the same game with entire scale structures. Let’s start with something familiar: the just-intonation major scale (the Ionian mode). Klotho has all the diatonic modes built in as class methods on Scale:
major = Scale.ionian()
print("Major scale degrees:", major.degrees)
coords = ratios_to_coordinates(major.degrees, generators=(2, '3/2', '5/4'))
major_nodes = [tuple(int(x) for x in c[1:]) for c in coords]
print("Lattice coordinates:", major_nodes)
tl_prime = ToneLattice.from_generators(('3/2', '5/4'), resolution=3)
plot(tl_prime, nodes=major_nodes, figsize=(7,4), mute_background=False, fit=True)Major scale degrees: [Fraction(1, 1), Fraction(9, 8), Fraction(5, 4), Fraction(4, 3), Fraction(3, 2), Fraction(5, 3), Fraction(15, 8)]
Lattice coordinates: [(0, 0), (2, 0), (0, 1), (-1, 0), (1, 0), (-1, 1), (1, 1)]
print("Major scale in 3/2 × 5/4 basis:")
for degree, node in zip(major.degrees, major_nodes):
print(f" {node} -> {tl_prime.get_ratio(node)}")
play(major.root('C4'))Major scale in 3/2 × 5/4 basis:
(0, 0) -> 1
(2, 0) -> 9/8
(0, 1) -> 5/4
(-1, 0) -> 2/3
(1, 0) -> 3/2
(-1, 1) -> 5/6
(1, 1) -> 15/8
That’s the familiar major scale. Now let’s do the same thing we did with paths: place the exact same set of coordinates into a lattice with different generators. The geometric shape is preserved, but every coordinate now maps to a different ratio:
tl_alt = ToneLattice.from_generators(('6/5', '5/4'), resolution=3)
plot(tl_alt, nodes=major_nodes, figsize=(7,4), mute_background=False, fit=True)print("Same shape, 6/5 × 5/4 basis:")
new_ratios = []
for node in major_nodes:
r = tl_alt.get_ratio(node)
new_ratios.append(str(r))
print(f" {node} -> {r}")
alt_scale = Scale(new_ratios)
play(alt_scale.root('C4'))Same shape, 6/5 × 5/4 basis:
(0, 0) -> 1
(2, 0) -> 36/25
(0, 1) -> 5/4
(-1, 0) -> 5/6
(1, 0) -> 6/5
(-1, 1) -> 25/24
(1, 1) -> 3/2
Same geometry, different scale. The lattice nodes sit in the same positions, but the intervals are entirely new. The major scale has been re-interpreted through a different harmonic lens.
Melodies¶
This gets really interesting when we go beyond scales and encode an actual melody as a path through the lattice. Klotho’s Scale class supports equave-cyclic indexing—you can index past the end of the scale and it wraps into the next octave automatically. This lets us encode scale-degree sequences very naturally:
Let’s encode “The First Noel” (which sweeps through all eight scale degrees in its opening phrase) as a sequence of scale-degree indices, find their lattice coordinates, and listen:
noel_degrees = [2, 1, 0, 1, 2, 3, 4, 5, 6, 7, 6, 5, 6, 7, 4]
noel_ratios = [major[d] for d in noel_degrees]
print("Melody ratios:", noel_ratios)
noel_coords_raw = ratios_to_coordinates([str(r) for r in noel_ratios], generators=(2, '3/2', '5/4'))
noel_path = [tuple(int(x) for x in c[1:]) for c in noel_coords_raw]
print("Melody path:", noel_path)
tl_melody = ToneLattice.from_generators(('3/2', '5/4'), resolution=3)
print("\nThe First Noel — major scale (3/2 × 5/4):")
plot(tl_melody, path=noel_path, figsize=(7,7), fit=True).play(dur=0.3)Melody ratios: [Fraction(5, 4), Fraction(9, 8), Fraction(1, 1), Fraction(9, 8), Fraction(5, 4), Fraction(4, 3), Fraction(3, 2), Fraction(5, 3), Fraction(15, 8), Fraction(2, 1), Fraction(15, 8), Fraction(5, 3), Fraction(15, 8), Fraction(2, 1), Fraction(3, 2)]
Melody path: [(0, 1), (2, 0), (0, 0), (2, 0), (0, 1), (-1, 0), (1, 0), (-1, 1), (1, 1), (0, 0), (1, 1), (-1, 1), (1, 1), (0, 0), (1, 0)]
The First Noel — major scale (3/2 × 5/4):
Now, the same geometric path through a lattice with different generators. The shape of the melody is preserved, but the harmonic content is completely transformed:
tl_noel_alt1 = ToneLattice.from_generators(('6/5', '5/4'), resolution=3)
print("Same melody path — 6/5 × 5/4 basis:")
plot(tl_noel_alt1, path=noel_path, figsize=(7,7), fit=True).play(dur=0.3)Same melody path — 6/5 × 5/4 basis:
tl_noel_alt2 = ToneLattice.from_generators(('4/3', '5/3'), resolution=3)
print("Same melody path — 4/3 × 5/3 basis:")
plot(tl_noel_alt2, path=noel_path, figsize=(7,7), fit=True).play(dur=0.3)Same melody path — 4/3 × 5/3 basis:
The “ghost” of the original melody is still audible in the contour—the ups and downs are the same—but the intervals are entirely different. This is the power of the lattice as a compositional tool: geometric operations (translation, reflection, change of basis) become musical transformations.
One More Thing...¶
Every lattice we’ve explored in this notebook has one thing in common: the origin is always the unison—the ratio . No matter the generators, the dimensionality, or the resolution, there’s always this gravitational center. Everything radiates outward from it.
We’ve been taking this for granted, but it’s not a given. Not all lattice-like structures have a tonal center.
In the next notebook, we’ll look at a special type of lattice formalization created by Erv Wilson called Combination Product Sets (CPS). These structures are even more abstract: they have no unison, no origin, no hierarchy. There is no “gravity.”
Summary¶
Here’s what we covered:
Generator bases let us re-axis the lattice with any set of intervals we like—as long as the basis matrix is unimodular (). A valid generator set produces the exact same infinite set of ratios as the primes—they’re just arranged and connected differently.
The basis matrix encodes each generator as a monzo (column vector). Its determinant tells us whether the generators form a valid, reversible basis.
Coordinate conversion between prime and generator bases is straightforward linear algebra: multiply by the inverse basis matrix.
Conditioning distinguishes good bases from awkward ones: even among unimodular bases, some produce cleaner, smaller coordinates than others. Unimodularity is necessary but not sufficient.
Same path, different generators: identical lattice coordinates produce entirely different music depending on the generator basis. The geometry is preserved but the harmonic content changes.
Structure re-interpretation: scale embeddings can be transplanted into lattices with different generators, producing new scales that share the same geometric fingerprint. Using
Scaleclass methods and equave-cyclic indexing, we can encode familiar scales and melodies as lattice paths and transform them through different bases.Melodic transformation: a melody encoded as a lattice path preserves its geometric contour across different generator bases, but the harmonic content is completely transformed. This turns change-of-basis into a powerful compositional tool.
In future notebooks, we’ll use these lattice structures for more sophisticated compositional applications—Combination Product Sets, pattern matching on lattice faces, and higher-dimensional navigation.