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 | 01 | Harmonic Space and Tone Lattices

from fractions import Fraction

from klotho import plot, play
from klotho.tonos import ToneLattice, PitchCollection as PC
from klotho.topos.graphs.lattices.algorithms import random_walk, shortest_path
from klotho.tonos.utils import harmonic_distance
from klotho.utils.algorithms import to_factors
from klotho.utils.algorithms.basis import monzo_from_ratio, ratio_from_monzo
Loading...

Harmonic Space

Any rational number (i.e., a fraction expressed with whole-numbers) can be written as a product of prime numbers and exponents.

For example:

1615=1611315=243151\frac{16}{15} = \frac{16}{1} \cdot \frac{1}{3} \cdot \frac{1}{5} = 2^{4} \cdot 3^{-1} \cdot 5^{-1}

In a fraction, numbers on the top have a positive exponent while numbers on the bottom have a negative exponent. So, in the above example, 3-1 is the same as 13\frac{1}{3}.

A harmonic space is a coordinate system, or lattice, with a separate axis for each prime number. Units along each axis enumerate the exponents, indicating the number of “steps” in a particular “prime dimension” of the space.

So, the ratio 1615\frac{16}{15} can be expressed as the coordinate (4,1,1)({4, -1, -1}) in a 3-D harmonic space.

Try it yourself:

ratio = '9/8'

factors = to_factors(ratio)
print(f"{ratio} as prime factors: {factors}")
print()
print("Why?")
print()
for prime, exp in sorted(factors.items()):
    print(f"  ({prime}^{exp})")
frac = Fraction(ratio)
print(f"\n  = {frac.numerator}/{frac.denominator}")
9/8 as prime factors: {3: 2, 2: -3}

Why?

  (2^-3)
  (3^2)

  = 9/8

Monzos

This prime-exponent representation has a name: a monzo (named after Joe Monzo). Given an ordered list of primes P=[p1,p2,,pn]P = [p_1, p_2, \dots, p_n], any ratio in the corresponding prime-limit group can be written:

r=i=1npiei,eiZr = \prod_{i=1}^n p_i^{e_i}, \quad e_i \in \mathbb{Z}

The integer vector e=(e1,e2,,en)Zn\mathbf{e} = (e_1, e_2, \dots, e_n) \in \mathbb{Z}^n is the monzo of that ratio.

For example, if our prime list is [2,3,5][2, 3, 5]:

RatioMonzo
32\frac{3}{2}(1,1,0)(-1, 1, 0)
54\frac{5}{4}(2,0,1)(-2, 0, 1)
1615\frac{16}{15}(4,1,1)(4, -1, -1)
98\frac{9}{8}(3,2,0)(-3, 2, 0)
primes = [2, 3, 5]

for r in ['3/2', '5/4', '16/15', '9/8']:
    m = monzo_from_ratio(r, primes)
    print(f"{r:>8s}  ->  monzo: {tuple(int(x) for x in m)}")
     3/2  ->  monzo: (-1, 1, 0)
     5/4  ->  monzo: (-2, 0, 1)
   16/15  ->  monzo: (4, -1, -1)
     9/8  ->  monzo: (-3, 2, 0)

We can also go in reverse—from monzo back to ratio:

print(ratio_from_monzo([-1, 1, 0], primes))
print(ratio_from_monzo([-2, 0, 1], primes))
print(ratio_from_monzo([4, -1, -1], primes))
3/2
5/4
16/15

Tone Lattices

2-D

In this lattice, the x-axis (horizontal) represents the prime dimension of 3 and the y-axis (vertical) represents the prime dimension of 5. Together, this defines a 2-dimensional harmonic space of 5-limit intervals—every ratio that can be built purely from the primes 2, 3, and 5.

We omit the 2 dimension (the octave axis) entirely. Since octaves are the most basic equivalence in music, keeping them as an explicit axis would add a dimension that, for our purposes, mostly just clutters the picture. So we project them out and focus on the more musically interesting prime dimensions.

Let’s see what this looks like without any octave reduction—every coordinate simply gives us the raw product of prime powers:f 5. If you hover over the origin (coordinate (0,0)), you’ll notice the ratio is 1. This is because 3050=11=13^{0} \cdot 5^{0} = 1 \cdot 1 = 1.

Because the largest prime dimension in this lattice is 5, we say that the resultant harmonic system has a prime limit of 5.

Hover each node in the lattice to see its value.

NOTE: we typically do not represent the axis corresponding to the prime 2 because powers of 2 are just octaves.

tone_lattice_2d_raw = ToneLattice(2, resolution=2, equave_reduce=False)
plot(tone_lattice_2d_raw, figsize=(7,7))
Loading...
Loading...

Take a look at the ratios. Moving even a few steps along the 3-axis gives us 3,9,27,3, 9, 27, \ldots — these are whole-number multiples of the fundamental that rapidly shoot into extreme high registers. Moving in the negative direction gives us 13,19,\frac{1}{3}, \frac{1}{9}, \ldots — plummeting well below any audible bass. The 5-axis is similar: 5,25,125,5, 25, 125, \ldots

In other words, without any correction, most of the lattice represents intervals that are musically intractable. The useful “sweet spot” is crammed around the origin while the rest of the space explodes out of range.

The solution? Octave reduction. Recall from the previous notebook that multiplying or dividing by powers of 2 gives us the “same note” in a different octave. We can use this to fold every ratio back into a single octave (between 1 and 2). This is known as octave equivalence, and the operation of normalizing ratios into this range is called octave reduction (or, more generally, equave reduction for non-octave systems).

Let’s see what happens when we turn equave reduction on:t is multiplied or divided by a power of two is considered the same pitch class, i.e., it’s the same note, just in a different octave.

Octave reduction means that we iteratively halve the interval until the value is less than 21\frac{2}{1}—i.e., it is within an octave. If the interval falls on the negative side of the lattice, we iteratively double the interval until it is greater than 12\frac{1}{2}.

Thus, all the intervals in the lattice are within either one octave above or one octave below the fundamental. It doesn’t have to be this way—we could decide to not octave-reduce and represent the 2-axis if we wanted, but octave-reduction and the omission of the 2-axis is a common convention for simplicity of visualization.

More formally: if 2 is in the prime list, “octave equivalence” identifies ratios rr and 2r2r. We’re working in the quotient group G/2G \, / \, \langle 2 \rangle, the space of pitch classes within an octave. Klotho generalizes this to equave reduction, where the equave doesn’t have to be 2/1.

When equave_reduce=True, the ToneLattice drops the equave axis entirely and folds all ratios into the window determined by bipolar:

  • bipolar=True -> ratios in (1equave,equave)(\frac{1}{\text{equave}}, \text{equave})

  • bipolar=False -> ratios in [1,equave)[1, \text{equave})

tone_lattice_2d = ToneLattice(2, resolution=2)
plot(tone_lattice_2d, figsize=(7,7)).play()
Loading...

Now every ratio is a fraction between 1 and 2. Much more manageable! The intervals are musically meaningful across the whole lattice.

One thing to notice: because of the folding, contiguous paths along an axis no longer produce a strictly ascending or descending pitch contour. Instead, the pitch “wraps around” within the octave. This isn’t a problem—it’s just a natural consequence of octave equivalence. But it does mean that interpreting raw prime axes as melodic directions takes a bit of getting used to.

Let’s verify what octave reduction does. The ratio at coordinate (1,0)(1, 0) should be 31=33^1 = 3, but after octave reduction it becomes 32\frac{3}{2}:

print(f"(1, 0) -> {tone_lattice_2d.get_ratio((1, 0))}")
print(f"(2, 0) -> {tone_lattice_2d.get_ratio((2, 0))}")
print(f"(0, 1) -> {tone_lattice_2d.get_ratio((0, 1))}")
print(f"(1, 1) -> {tone_lattice_2d.get_ratio((1, 1))}")
(1, 0) -> 3/2
(2, 0) -> 9/8
(0, 1) -> 5/4
(1, 1) -> 15/8

NOTE: In the following examples, I’m going to slightly tweak the axes of our 2D lattice so that they correspond to 3/2 (perfect fifth) and 5/4 (major third) instead of the raw primes 3 and 5. This helps with the wrapping issue we just mentioned—moving in the positive direction along an axis now always goes up in pitch, and negative always goes down. There’s a mathematical reason for why we’re able to do this (it’s called a change of basis), but don’t worry about that for now—we’ll get into it in a later notebook. For now, take note about directionality in space and how it correlates to directionality in pitch.

Random Walks

Let’s start at the origin and take random steps. The rule is that, from any point, we are allowed to move to any other point so long as the next position is immediately adjacent to where we currently are.

# tone_lattice_2d = ToneLattice(2, resolution=3)
tone_lattice_2d = ToneLattice.from_generators(('3/2','5/4'), resolution=2, equave_reduce=False)
path = random_walk(tone_lattice_2d, (0, 0), num_steps=20, max_repeats=0)
plot(tone_lattice_2d, figsize=(7,7), path=path, fit=False).play(dur=0.25, releaseTime=0.5)
Loading...

Re-run the above cell for another traversal.

As for directionality in space and pitch, what did you notice? Take a look at position (0, 0), we call that the origin. This is where the ratio 1/1 lives—i.e., the unison. Did you notice that motion in the positive axes results in upward pitch direction and motion in the negative axes results in downward pitch direction?

So, direction in space equates with direction in pitch. What about the specific axes? Meaning, what does motion on the x-axis vs. motion on the y-axis correspond to? Let’s find out...

Patterns

Random walks are fun, but let’s also hear what patterned movement sounds like. What does it sound like to move purely along one axis? Or diagonally? Or in a zigzag?

Here are a few short gestures on a smaller lattice. Listen to how different directions produce different harmonic qualities:

tl_2d = ToneLattice.from_generators(('3/2','5/4'), resolution=2, equave_reduce=False)

fifths_path = [(-2,0), (-1,0), (0,0), (1,0), (2,0)]
thirds_path = [(0,-2), (0,-1), (0,0), (0,1), (0,2)]
diag_path   = [(-2,-2), (-1,-1), (0,0), (1,1), (2,2)]
spiral_path = [(0,0), (1,0), (1,1), (0,1), (-1,1), (-1,0), (-1,-1), (0,-1), (1,-1), (2,-1), (2,0)]
zigzag_path = [(-1,0), (-1,1), (0,1), (0,0), (1,0), (1,-1), (2,-1), (2,0)]
print("Pure perfect fifths — 3/2 (axis 0):")
plot(tl_2d, path=fifths_path, figsize=(7,7)).play(dur=0.25, releaseTime=2)
Pure perfect fifths — 3/2 (axis 0):
Loading...
print("Pure major thirds — 5/4 (axis 1):")
plot(tl_2d, path=thirds_path, figsize=(7,7)).play(dur=0.25, releaseTime=2)
Pure major thirds — 5/4 (axis 1):
Loading...
print("Diagonal — P5 + M3 = M7 (3/2 × 5/4 = 15/8):")
plot(tl_2d, path=diag_path, figsize=(7,7)).play(dur=0.25, releaseTime=2)
Diagonal — P5 + M3 = M7 (3/2 × 5/4 = 15/8):
Loading...
print("Spiral:")
plot(tl_2d, path=spiral_path, figsize=(7,7)).play(dur=0.25)
Spiral:
Loading...
print("Zigzag:")
plot(tl_2d, path=zigzag_path, figsize=(7,7)).play(dur=0.25)
Zigzag:
Loading...

3-D

What happens when we add another dimension? We’ll keep the 3 and 5 dimensions from the previous lattice and add a 7 dimension. The lattice is now 3-D and now has a prime limit of 7:

tone_lattice_3d = ToneLattice.from_generators(('3/2','5/4','7/4'), resolution=1)
plot(tone_lattice_3d, node_size=2, figsize=(7,7)).play()
Loading...

Random Walks

We can repeat the same random walk game but, this time, we have more degrees of freedom for any given step:

path = random_walk(tone_lattice_3d, (0, 0, 0), num_steps=20, max_repeats=0)
plot(tone_lattice_3d, path=path, figsize=(7,7), node_size=3, fit=True).play(dur=0.2, releaseTime=1)
Loading...

Re-run the above cell for another traversal.

Something interesting about this random-walk operation, regardless of dimensionality, is that the resultant sequence is rather consonant from each step to the next, yet... kind of weird overall.

It’s a bit like a Markov chain in that each step is syntactically “correct”, but the sequence as a whole is semantically nonsensical.

You can see a demonstration of this by only selecting the suggested words in your phone’s text messenger. Each word logically follows from the previous, but the sentence as a whole is a nonsensical word-salad.


We’ve been talking about points being “close” or “far” in the lattice. But what does that mean, harmonically? In the next notebook, we’ll formalize this intuition with a concrete measure called harmonic distance.-------|---------------| | 32\frac{3}{2} | 6 | 2.58\approx 2.58 | | 54\frac{5}{4} | 20 | 4.32\approx 4.32 | | 98\frac{9}{8} | 72 | 6.17\approx 6.17 | | 243128\frac{243}{128} | 31104 | 14.92\approx 14.92 |


Summary

Here’s what we covered:

  1. Harmonic space is a coordinate system where each axis corresponds to a prime number. Any rational interval has a unique position—called a monzo—in this space.

  2. Without octave reduction, intervals rapidly explode into extreme registers. Octave (equave) reduction folds everything back into a single octave, giving us pitch classes instead of absolute pitches—at the cost of non-monotonic pitch contours along axes.

  3. Tone lattices let us visualize and navigate harmonic space. We explored 2-D (prime limit 5) and 3-D (prime limit 7) lattices using both random walks and patterned paths to hear their harmonic character.

  4. Using generators like 3/2 and 5/4 instead of the raw primes restores a direct correspondence between spatial direction and pitch direction. We’ll explore why this works in the Generators and Custom Lattices notebook.