Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

02 | Tones | 03 | Generators and Custom Lattices

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 sp

Generators 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 31=33^1 = 3, which octave-reduces to 32\frac{3}{2}—a perfect fifth. Moving one step along the 5-axis gives you 51=55^1 = 5, which octave-reduces to 54\frac{5}{4}—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.

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 {2,3,5}\{2, 3, 5\} 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 {2,3,5}\{2, 3, 5\} 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 74\frac{7}{4} 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 g1,g2,,gng_1, g_2, \dots, g_n 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 AA:

A=[m(g1)m(g2)m(gn)]A = \begin{bmatrix} \vert & \vert & & \vert\\ \mathbf{m}(g_1) & \mathbf{m}(g_2) & \cdots & \mathbf{m}(g_n)\\ \vert & \vert & & \vert \end{bmatrix}

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 AA.

  • If you have prime coordinates and want to convert to generator coordinates: multiply by A1A^{-1}.

Let’s build one. We’ll use {2,54,65}\{2, \frac{5}{4}, \frac{6}{5}\} as our generators. First, the monzo of each generator in the prime basis [2,3,5][2, 3, 5]:

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 AA:

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 ±1\pm 1 means the transformation preserves the “size” of things perfectly.

For our purposes, the crucial condition is:

det(A)=±1\det(A) = \pm 1

A matrix with this property is called unimodular. What does that mean in practice?

It means the inverse matrix A1A^{-1} 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 det(A)=0\det(A) = 0: 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 det(A)>1|\det(A)| > 1: 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 {2,3,5}\{2, 3, 5\} is trivially unimodular—its basis matrix is just the identity matrix. The set {2,54,65}\{2, \frac{5}{4}, \frac{6}{5}\} is also unimodular, so it’s a valid alternative basis.

But {2,98,54}\{2, \frac{9}{8}, \frac{5}{4}\} is not—its determinant is 2, meaning it only reaches a sublattice (every other point, roughly). And {2,32,98}\{2, \frac{3}{2}, \frac{9}{8}\} has determinant 0: since 98\frac{9}{8} is just (32)2(\frac{3}{2})^2 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 {2,54,65}\{2, \frac{5}{4}, \frac{6}{5}\} 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, 54\frac{5}{4} has the monzo (2,0,1)(-2, 0, 1)—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, 54\frac{5}{4}, is just (0,1,0)(0, 1, 0)—one step along the second axis. Because the second axis is 54\frac{5}{4}.

Same story for 65\frac{6}{5}: in prime coords it’s (1,1,1)(1, 1, -1), but in generator coords it’s (0,0,1)(0, 0, 1)—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 =±1= \pm 1), some are “tidier” than others.

The thing to look at is the inverse matrix A1A^{-1}. If its entries are small—mostly 0s and ±1\pm 1s—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 54\frac{5}{4} 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), animate=True, 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)
Loading...
Prime basis: generators = [Fraction(3, 1), Fraction(5, 1)]

Now, the same coordinates on a lattice where the axes are 54\frac{5}{4} (major third) and 65\frac{6}{5} (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), animate=True, 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)
Loading...
Thirds basis: generators = [Fraction(5, 4), Fraction(6, 5)]

And once more—axes of 43\frac{4}{3} (perfect fourth) and 53\frac{5}{3} (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), animate=True, 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)
Loading...
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 scale structures. The Highland Bagpipes scale (which we embedded in a lattice in the previous notebook) has a rich 3-D structure. Let’s grab its coordinates and re-interpret them through a different set of generators.

First, the original embedding. We’ll collect the coordinates (dropping the 2-axis as before):

bagpipes_scale = Scale.bagpipes()

coords = ratios_to_coordinates(bagpipes_scale.degrees)
bagpipe_nodes = [tuple(int(x) for x in c[1:]) for c in coords]

tl_prime_3d = ToneLattice(3, resolution=4)

plot(tl_prime_3d, nodes=bagpipe_nodes, figsize=(9,4), mute_background=False, fit=True)
Loading...
print("Prime basis:")
for degree, node in zip(bagpipes_scale.degrees, bagpipe_nodes):
    print(f"  {node} -> {tl_prime_3d.get_ratio(node)}")
play(bagpipes_scale.root('G4'))
Prime basis:
  (0, 0, 0) -> 1
  (2, 0, 0) -> 9/8
  (0, 1, 0) -> 5/4
  (-1, 0, 0) -> 2/3
  (3, -1, 0) -> 27/20
  (1, 0, 0) -> 3/2
  (-1, 1, 0) -> 5/3
  (0, 0, 1) -> 7/4
  (-2, 0, 0) -> 8/9
  (2, -1, 0) -> 9/5
Loading...

Now, place those same coordinates in a lattice with generators {54,65,74}\{\frac{5}{4}, \frac{6}{5}, \frac{7}{4}\}. The structure is the same—same nodes, same connections. But every coordinate now means a different ratio:

tl_alt_3d = ToneLattice.from_generators(
    # generators=['6/5', '5/4', '7/4'],
    generators=[11,13,17],
    resolution=4,
    equave_reduce=True,
)
plot(tl_alt_3d, nodes=bagpipe_nodes, figsize=(9,4), mute_background=False, fit=True)
Loading...
print("Alternative basis {5/4, 6/5, 7/4}:")
new_ratios = []
for node in bagpipe_nodes:
    r = tl_alt_3d.get_ratio(node)
    new_ratios.append(str(r))
    print(f"  {node} -> {r}")

alt_scale = Scale(new_ratios)
play(alt_scale.root('G4'))
Alternative basis {5/4, 6/5, 7/4}:
  (0, 0, 0) -> 1
  (2, 0, 0) -> 121/64
  (0, 1, 0) -> 13/8
  (-1, 0, 0) -> 8/11
  (3, -1, 0) -> 1331/832
  (1, 0, 0) -> 11/8
  (-1, 1, 0) -> 13/11
  (0, 0, 1) -> 17/16
  (-2, 0, 0) -> 64/121
  (2, -1, 0) -> 121/104
Loading...

Same geometry, different scale. The lattice structure is preserved—the nodes are still connected in the same way—but the intervals are entirely new. The bagpipes scale has been re-interpreted through a different harmonic lens.


One More Thing...

Every lattice we’ve explored in this notebook has one thing in common: the origin is always the unison—the ratio 11\frac{1}{1}. 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:

  1. Generator bases let us re-axis the lattice with any set of intervals we like—as long as the basis matrix is unimodular (det(A)=±1\det(A) = \pm 1). A valid generator set produces the exact same infinite set of ratios as the primes—they’re just arranged and connected differently.

  2. The basis matrix encodes each generator as a monzo (column vector). Its determinant tells us whether the generators form a valid, reversible basis.

  3. Coordinate conversion between prime and generator bases is straightforward linear algebra: multiply by the inverse basis matrix.

  4. Conditioning distinguishes good bases from awkward ones: even among unimodular bases, some produce cleaner, smaller coordinates than others. Unimodularity is necessary but not sufficient.

  5. 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.

  6. Structure re-interpretation: scale embeddings can be transplanted into lattices with different generators, producing new scales that share the same geometric fingerprint.

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.