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 | 04 | Combination Product Sets

import sympy as sp
from fractions import Fraction
import numpy as np

from klotho import plot, play
from klotho.topos.collections import CombinationSet as CS
from klotho.tonos import (
    CombinationProductSet, Hexany, Eikosany, Hebdomekontany, MasterSet,
    PitchCollection as PC, Chord, ChordSequence,
)
from klotho.tonos.systems.combination_product_sets import match_pattern
Loading...
def chord_from_shape(cps, shape, root='C4'):
    ratios = sorted(cps.graph[n]['ratio'] for n in shape)
    return Chord(ratios).root(root)

def chords_from_matches(cps, matches, root='C4'):
    return ChordSequence([chord_from_shape(cps, m, root) for m in matches])

Combination Product Sets

Combination Product Sets (CPS) are a special type of lattice structure created by Erv Wilson.

So far, all the lattices we’ve looked at had a tonal center—meaning, there is an origin where the ratio is 1 and all other points represent successive multiplications.

CPS lattices are different in that there is no tonal center—i.e., there is no origin. Which is to say, there is no hierarchy. There is no “gravity”.

Deriving CPS

Ok, here’s the deal: there’s really no easy way to explain this without being a little confusing so I’m just going go through it step-by-step and (hopefully) it’ll all make sense in the end.

Don’t worry, just follow along...

1. Initial Set

Imagine we have a collection of n-elements:

{A B C D}\{A\ B\ C\ D\}

In this case, 4 elements. These letters don’t represent note names, they’re just algebraic variables—they could have been emojis instead of letters, it really doesn’t matter.

2. Arrange the Elements Spatially

Arrange each element so that we form a complete graph. A complete graph means a graph where every node is connected to every other node.

plot(MasterSet.tetrad(), figsize=(7,7))
Loading...

Ok, this geometric form is what we’re calling our generating tetrad. Put it aside for now, we’re going to use it later...

3. Compute k-wise Groupings

We started with 4 elements ({A B C D}\{A\ B\ C\ D\}), let’s find every possible pairing, or 2-wise grouping of elements:

elems = ('A', 'B', 'C', 'D')

for combo in CS(elems, 2).combos:
  print(*combo, end='\n\n')
A B

A D

B C

C D

B D

A C

4. Review Basic Algebra

That’s right, let’s review our algebra...

A, B, C, D = sp.symbols('A B C D', nonzero=True)

e1 = A*B
e2 = A*C
print(f'({e1}) / ({e2}) = {sp.simplify(e1 / e2)}', end='\n\n')

e1 = B*C
e2 = A*C
print(f'({e1}) / ({e2}) = {sp.simplify(e1 / e2)}', end='\n\n')

e1 = B*D
e2 = A*B
print(f'({e1}) / ({e2}) = {sp.simplify(e1 / e2)}', end='\n\n')

print('etc...')
(A*B) / (A*C) = B/C

(B*C) / (A*C) = B/A

(B*D) / (A*B) = D/A

etc...

Is it all coming back? Ok, good...

5. Review Basic Geometry

Let’s look again at our generating tetrad:

plot(MasterSet.tetrad(), figsize=(7,7))
Loading...

We’re going to take the angle formed by each pair of adjacent nodes and assign them to the ratios of node1 / node2 and node2 / node1.

What do I mean by this? E.g., take nodes A and B. The edge between them forms a horizontal line. So, we’re going to say that the ratio of A/B or B/A means an angle of either 0 or 180 degrees.

Look at nodes C and D. The angle formed by the edge between them is a vertical line. So, we’re going to say that the ratio of C/D or D/C means an angle of either 90 or 270 degrees.

And so on for each pair of adjacent nodes...

6. Map Ratios to Groups

Find the ratio between each pairing of k-wise groupings.

If the ratio is an edge-group found in the set of k-wise groupings, keep it.

cs = CS(elems, 2)
for combo1 in cs.combos:
  for combo2 in cs.combos:
    if combo1 != combo2:
      e1 = sp.sympify(f'{combo1[0]}/{combo1[1]}')
      e2 = sp.sympify(f'{combo2[0]}/{combo2[1]}')
      simp = sp.simplify(e1 / e2)
      if tuple(str(simp).split('/')) in cs.combos:
        print(f'({e1}) / ({e2}) = {simp}', end='\n\n')
(A/D) / (A/B) = B/D

(A/D) / (C/D) = A/C

(A/D) / (B/D) = A/B

(A/D) / (A/C) = C/D

(B/D) / (B/C) = C/D

(B/D) / (C/D) = B/C

(A/C) / (A/B) = B/C

(A/C) / (B/C) = A/B

7. Make the Graph

Compare the resultant ratio to the edges in the generating tetrad to determine the angle of the line between them:

print("GENERATING TETRAD")
plot(MasterSet.tetrad(), figsize=(7,7))

print("\nCPS")
plot(Hexany(), figsize=(7,7))
GENERATING TETRAD
Loading...

CPS
Loading...
Loading...

Ok, whew...

I know, it’s a bit confusing, but take a look at the result.

Look at each node in the CPS (here, each node represents a k-wise grouping—pairs in our case) and notice the angle of the line between them.

E.g., the edge between AD and BD is a horizontal line. Why?

AD/BD=A/BAD / BD = A/B

Look at the generating tetrad. The angle between nodes A and B. It’s a horizontal line.

Look back at the CPS graph. Take a look at nodes CD and BD and take note of the angle of the edge between them. It’s the same angle as between C and B in the generating tetrad. Because CD/BD=C/BCD / BD = C/B.

And so on...

Hexany

The above CPS is the simplest, known as the Hexany.

So, what do these letters actually mean? Each element in the initial set represents an integer that we will use as a harmonic multiple of some fundamental frequency. So, let’s say:

A = 1

B = 3

C = 5

D = 7

They don’t have to be these numbers, but let’s go with these.

Each combination of elements is called a product. Why? Because:

AB = 1 * 3 = 3

BC = 3 * 5 = 15

CD = 5 * 7 = 35

etc...

which results in a set of ratios—the tone world of this particular Hexany.

hx = Hexany()
print('Ratios: ', *[str(r) for r in hx.ratios])
plot(hx, figsize=(7,7))
pc = PC.from_degrees(list(hx.ratios), equave='2/1')
# idx = list(range(-len(hx.ratios)*2 + len(hx.ratios), len(hx.ratios)*2 + len(hx.ratios)))
idx = list(range(-len(hx.ratios)*2, len(hx.ratios)*2))
play(pc.root('C4')[idx + idx[-2::-1]], dur=0.2)
Ratios:  35/32 5/4 21/16 3/2 7/4 15/8
Loading...
Loading...

Now, recall that those letters in the initial set are just variables; we can change their values and get a structure with different intervals.

Let’s try that:

hx2 = Hexany((1, 5, 13, 31))
print('Ratios: ', *[str(r) for r in hx2.ratios])
plot(hx2, figsize=(7,7))
pc = PC.from_degrees(list(hx2.ratios), equave='2/1')
idx = list(range(-len(hx2.ratios)*2, len(hx2.ratios)*2))
play(pc.root('C4')[idx + idx[-2::-1]], dur=0.2)
Ratios:  65/64 155/128 5/4 403/256 13/8 31/16
Loading...
Loading...

Compare it to the one above. The graph structure will be the same since the same abstract algebraic relationships remain intact, but their specific values are different. Thus, the resultant “tone world” will be different.

Let’s do another one...

hx3 = Hexany((3, 17, 23, 53))
print('Ratios: ', *[str(r) for r in hx3.ratios])
plot(hx3, figsize=(7,7))
pc = PC.from_degrees(list(hx3.ratios), equave='2/1')
idx = list(range(-len(hx3.ratios)*2, len(hx3.ratios)*2))
play(pc.root('C4')[idx + idx[-2::-1]], dur=0.2)
Ratios:  69/64 1219/1024 159/128 391/256 51/32 901/512
Loading...
Loading...

Interesting...

Ok, let’s go back to the initial Hexany we created. It gets cooler.

print('Ratios: ', *[str(r) for r in hx.ratios])
plot(hx, figsize=(7,7))
pc = PC.from_degrees(list(hx.ratios), equave='2/1')
idx = list(range(-len(hx.ratios)*2, len(hx.ratios)*2))
play(pc.root('C4')[idx + idx[-2::-1]], dur=0.2)
Ratios:  35/32 5/4 21/16 3/2 7/4 15/8
Loading...
Loading...

This is a geometric form. Which means, yes, we have points (individual notes), but we also have faces. I.e., we can select multiple nodes and create a surface. What does that give us?

Well, if a single point is a single note, then multiple points are multiple notes—i.e., chords...

shape = [0, 4, 3]
print('Target Shape:')
matches = match_pattern(hx, shape, sort_by='position', include_target=True)
plot(hx, shape=matches, figsize=(7,7)).play(dur=2, releaseTime=2)
Target Shape:
Loading...

That’s a pretty cool sequence of chords. And we didn’t really need to work that hard to get it. All we did was find some pattern in the Hexany, find every other instance of that pattern elsewhere in the Hexany, then just... play them in order. That’s it.

I consider this the “reward” for going through all the abstract algebraic geometry earlier.

That triangle shape produces chords of a certain quality. What about other shapes?

shape = [1, 5, 0, 4]
print('Target Shape:')
matches = match_pattern(hx, shape, sort_by='rotation', include_target=True)
plot(hx, shape=matches, figsize=(7,7)).play(dur=1, pause=0)
Target Shape:
Loading...

Eikosany

There are other types of CPS structures (in fact, there are a lot of them). After the Hexany, the most common is the Eikosany.

This one is a little different. Instead of finding all possible 2-wise groups (i.e., pairs), we find all 3-wise groupings. We also use a different generating geometry to build the resultant graph.

The result is a more complex geometry and, thus, a more complex tone world:

print("GENERATING GEOMETRY")
plot(MasterSet.asterisk(), figsize=(6,6))

print("\nCPS")
ek = Eikosany(master_set='asterisk')
plot(ek, node_size=25, text_size=10, figsize=(8,8))
pc = PC.from_degrees(list(ek.ratios), equave='2/1')
idx = list(range(-len(ek.ratios)*2, len(ek.ratios)*2))
play(pc.root('C4')[idx + idx[-2::-1]], dur=0.15)
GENERATING GEOMETRY
Loading...

CPS
Loading...
Loading...

We can do all the same operations with shapes. These do not necessarily need to be faces, we can specify any configuration of nodes and find all matching instances:

shape = [7, 16, 19, 13]
print('Target Shape:')
matches = match_pattern(ek, shape, sort_by='rotation', include_target=True)
plot(ek, shape=matches, node_size=20, text_size=8, figsize=(8,8)).play(dur=1)
Target Shape:
Loading...

Eikosany also has other forms. We can derive them by using a different generating geometry:

plot(MasterSet.centered_pentagon(), figsize=(6,6))
Loading...
ek = Eikosany(master_set='centered_pentagon')
print('Ratios: ', *[str(r) for r in ek.ratios])
plot(ek, node_size=25, text_size=10, figsize=(8,8))
pc = PC.from_degrees(list(ek.ratios), equave='2/1')
idx = list(range(-len(ek.ratios)*2, len(ek.ratios)*2))
play(pc.root('C4')[idx + idx[-2::-1]], dur=0.15)
Ratios:  33/32 135/128 35/32 297/256 77/64 315/256 165/128 21/16 693/512 45/32 189/128 385/256 99/64 105/64 27/16 55/32 231/128 15/8 495/256 63/32
Loading...
Loading...
shape = [12, 14, 11, 13]
print('Target Shape:')
matches = match_pattern(ek, shape, sort_by='position', include_target=True)
plot(ek, shape=matches, node_size=20, text_size=8, figsize=(8,8)).play(dur=0.667, pause=0, sustainLevel=0.5)
Target Shape:
Loading...

Eikosany can also be built from a “distorted” hexagon. This form, devised by Erv Wilson, uses a slight geometric distortion to prevent two nodes from overlapping in the resultant CPS graph:

print('GENERATING GEOMETRY')
plot(MasterSet.irregular_hexagon(), figsize=(6,6))

print('\nCPS')
ek_ih = Eikosany(master_set='irregular_hexagon')
plot(ek_ih, node_size=25, text_size=10, figsize=(8,8))
pc = PC.from_degrees(list(ek_ih.ratios), equave='2/1')
idx = list(range(-len(ek_ih.ratios)*2, len(ek_ih.ratios)*2))
play(pc.root('C4')[idx + idx[-2::-1]], dur=0.15)
GENERATING GEOMETRY
Loading...

CPS
Loading...
Loading...
target_shapes = [[11, 16, 4, 0], [18, 6, 8, 19], [0, 12, 4], [12, 18, 5]]
print('Target Shape:')
matches = [match for shape in target_shapes for match in match_pattern(ek_ih, shape, include_target=True)]
np.random.shuffle(matches)
plot(ek_ih, shape=matches, node_size=20, text_size=8, figsize=(8,8)).play(dur=2, pause=0.5, strum=0.5, releaseTime=2)
Target Shape:
Loading...

Hebdomekontany

heb = Hebdomekontany()
plot(heb, node_size=15, text_size=5, figsize=(10,10))
Loading...
Loading...

...and so on...

Again, these are just a few CPS lattices. There are many, many more.