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_monzoHarmonic 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:
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 .
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 can be expressed as the coordinate 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 , any ratio in the corresponding prime-limit group can be written:
The integer vector is the monzo of that ratio.
For example, if our prime list is :
| Ratio | Monzo |
|---|---|
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. If you hover over the origin (coordinate (0,0)), you’ll notice the ratio is 1. This is because .
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 = ToneLattice(2, resolution=3)
plot(tone_lattice_2d, figsize=(7,7))Question: but why do these points all look like fractions?¶
If we start at the origin and move along, e.g., the x-axis, shouldn’t we get
etc...?
The answer is: yes, we actually do! The reason we’re seeing {, , }, and not {3, 9, 27} is because each point is octave reduced.
Recall that if you double or halve a frequency, you get the same “note” but either an octave higher or lower. Any frequency that 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 —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 .
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 and . We’re working in the quotient group , 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 inbipolar=False-> ratios in
Let’s verify this. The ratio at coordinate should be , but after octave reduction it becomes :
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 and 5/4 instead of 3 and 5. There’s actually a mathematical reason for why we’re able to do this—but don’t worry about that for right now, we’ll get into that later. 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, animate=True, dur=0.25)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(2, resolution=2)
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)]
stair_path = [(-1,-1), (0,-1), (0,0), (1,0), (1,1), (2,1), (2,2)]
zigzag_path = [(-1,0), (-1,1), (0,1), (0,0), (1,0), (1,-1), (2,-1), (2,0)]print("Pure fifths (axis 0):")
plot(tl_2d, path=fifths_path, figsize=(7,7), animate=True, dur=0.25)Pure fifths (axis 0):
print("Pure thirds (axis 1):")
plot(tl_2d, path=thirds_path, figsize=(7,7), animate=True, dur=0.25)
# ratios = [str(tl_2d.get_ratio(c)) for c in thirds_path]
# print(f" ratios: {ratios}")
# 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)Pure thirds (axis 1):
print("Diagonal (fifths + thirds):")
plot(tl_2d, path=diag_path, figsize=(7,7), animate=True, dur=0.25)
# ratios = [str(tl_2d.get_ratio(c)) for c in diag_path]
# print(f" ratios: {ratios}")
# 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)Diagonal (fifths + thirds):
print("Staircase:")
plot(tl_2d, path=stair_path, figsize=(7,7), animate=True, dur=0.25)
# ratios = [str(tl_2d.get_ratio(c)) for c in stair_path]
# print(f" ratios: {ratios}")
# 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)Staircase:
print("Zigzag:")
plot(tl_2d, path=zigzag_path, figsize=(7,7), animate=True, dur=0.25)
# ratios = [str(tl_2d.get_ratio(c)) for c in zigzag_path]
# print(f" ratios: {ratios}")
# 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)Zigzag:
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(3, resolution=1)
tone_lattice_3d = ToneLattice.from_generators(('3/2','5/4','7/4'), resolution=1)
plot(tone_lattice_3d, node_size=2, figsize=(7,7))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, animate=True, dur=0.2)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.
Harmonic Distance¶
We’ve been talking about points being “close” or “far” in the lattice. But what does that mean, harmonically?
Informally, harmonic distance is how “simple” or “complex” an interval feels. A perfect fifth () is simple—it’s one of the most consonant intervals. The ratio is complex—it’s dissonant and hard to hear as a single interval.
James Tenney formalized this idea. For a ratio (in lowest terms), the Tenney height is:
Smaller numerator and denominator means lower Tenney height means harmonically “closer” to the unison. Larger numbers means higher Tenney height means harmonically “further.”
| Ratio | Tenney height | |
|---|---|---|
| 6 | ||
| 20 | ||
| 72 | ||
| 31104 |
Let’s see this in action. We’ll find the shortest path between a few pairs of points in a 2-D lattice and look at the harmonic distance of each ratio along the way:
tl_hd = ToneLattice(2, resolution=3)
pairs = [((0, 0), (2, 0)), ((0, 0), (0, 2)), ((0, 0), (2, 2)), ((0, 0), (3, -2))]
for start, end in pairs:
sp = shortest_path(tl_hd, start, end)
plot(tl_hd, path=sp, figsize=(5,5), fit=True)
print(f"Path: {start} -> {end} ({len(sp)-1} steps)")
for c in sp:
r = tl_hd.get_ratio(c)
hd = harmonic_distance(r)
print(f" {c} -> {r} (HD = {hd:.2f})")
print()Path: (0, 0) -> (2, 0) (3 steps)
(0, 0) -> 1 (HD = 0.00)
(0, 0) -> 1 (HD = 0.00)
(1, 0) -> 3/2 (HD = 2.58)
(2, 0) -> 9/8 (HD = 6.17)
Path: (0, 0) -> (0, 2) (3 steps)
(0, 0) -> 1 (HD = 0.00)
(0, 0) -> 1 (HD = 0.00)
(0, 1) -> 5/4 (HD = 4.32)
(0, 2) -> 25/16 (HD = 8.64)
Path: (0, 0) -> (2, 2) (5 steps)
(0, 0) -> 1 (HD = 0.00)
(0, 0) -> 1 (HD = 0.00)
(1, 0) -> 3/2 (HD = 2.58)
(2, 0) -> 9/8 (HD = 6.17)
(2, 1) -> 45/32 (HD = 10.49)
(2, 2) -> 225/128 (HD = 14.81)
Path: (0, 0) -> (3, -2) (6 steps)
(0, 0) -> 1 (HD = 0.00)
(0, 0) -> 1 (HD = 0.00)
(1, 0) -> 3/2 (HD = 2.58)
(2, 0) -> 9/8 (HD = 6.17)
(2, -1) -> 9/5 (HD = 5.49)
(2, -2) -> 18/25 (HD = 8.81)
(3, -2) -> 27/25 (HD = 9.40)
Notice: points close to the origin tend to have low harmonic distance. Points further away tend to have higher harmonic distance. The lattice geometry and harmonic complexity are correlated.
One important nuance: the Tenney height of a ratio is a property of the ratio itself—it doesn’t depend on which generators define the lattice axes. But how lattice step count maps to harmonic distance does depend on the generators. In a well-conditioned basis (like or ), each step corresponds to a musically meaningful interval, so step count tracks harmonic distance well. In a poorly-conditioned basis (like one using ), a single step might correspond to a tiny, complex interval—many steps for little harmonic movement.
Summary¶
Here’s what we covered:
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.
Octave (equave) reduction collapses the
2-axis, giving us pitch classes instead of absolute pitches. This is why lattice nodes show fractions within an octave.Tone lattices let us visualize and navigate harmonic space. We explored 2-D (prime limit 5) and 3-D (prime limit 7) lattices through random walks and deliberate patterns.
Harmonic distance (Tenney height) measures the complexity of a ratio. In a prime-basis lattice, nearby points tend to have low harmonic distance—the geometry and harmonic complexity are correlated.
In the next notebook, we’ll look at how musical scales can be embedded in these lattice structures, revealing geometric properties of familiar and unfamiliar tuning systems.