Secondary moments of symmetry
A secondary moment of symmetry (MOS) is built from a larger parent MOS by stepping through it in steps of a given size, wrapping at the period. For example, take the parent MOS as the seven-note scale built by stacking 4/3
1/1, 9/8, 81/64, 4/3, 3/2, 27/16, 243/128
Stepping through five times in steps of three scale degrees, we can choose to start on each of the seven scale degrees, giving the seven sets of notes
1: 1/1, 4/3, 243/128, 81/64, 27/16 2: 9/8, 3/2, 1/1, 4/3, 243/128 3: 81/64, 27/16, 9/8, 3/2, 1/1 4: 4/3, 243/128, 81/64, 27/16, 9/8 5: 3/2, 1/1, 4/3, 243/128, 81/64 6: 27/16, 9/8, 3/2, 1/1, 4/3 7: 243/128, 81/64, 27/16, 9/8, 3/2
Sorting and taking the lowest note in each scale as tonic gives
1: 1/1, 81/64, 4/3, 27/16, 243/128 2: 1/1, 9/8, 4/3, 3/2, 243/128 3: 1/1, 9/8, 81/64, 3/2, 27/16 4: 1/1, 9/8, 32/27, 3/2, 27/16 5: 1/1, 81/64, 4/3, 3/2, 243/128 6: 1/1, 9/8, 4/3, 3/2, 27/16 7: 1/1, 9/8, 4/3, 3/2, 27/16
This is a family of secondary MOS called the Tanabe cycle.
Only five of the seven scales are unique (not modes of each other).
The ordinary five-note MOS from stacking 4/3 has two step sizes
S = 9/8 T = 32/27
The family of secondary MOS together use four step sizes
S1 = 9/8 S2 = 256/243 T1 = 32/27 T2 = 81/64
In general, starting with an N-note parent MOS, which has two step sizes, we get a family of n distinct n-note secondary MOS (where n < N), together using four step sizes.
Further reading
- Introduction to Erv Wilson's Moments of Symmetry, The Wilson Archives
Python code
from fractions import Fraction
from math import floor, log2
def step_through(s, n, step, start=0):
"""
>>> parent = [Fraction(1, 1), Fraction(9, 8), Fraction(81, 64), Fraction(4, 3), Fraction(3, 2), Fraction(27, 16), Fraction(243, 128)]
>>> step_through(parent, 5, step=3, start=0)
[Fraction(1, 1), Fraction(81, 64), Fraction(4, 3), Fraction(27, 16), Fraction(243, 128)]
>>> step_through(parent, 5, step=3, start=3)
[Fraction(9, 8), Fraction(81, 64), Fraction(4, 3), Fraction(27, 16), Fraction(243, 128)]
"""
N = len(s)
count = 0
i = start
t = []
while count < n:
t.append(s[i])
i = (i + step) % N
count += 1
return sorted(t)
def secondary_mos_family(parent_mos, n, step):
"""
>>> parent = [Fraction(1, 1), Fraction(9, 8), Fraction(81, 64), Fraction(4, 3), Fraction(3, 2), Fraction(27, 16), Fraction(243, 128)]
>>> for x in secondary_mos_family(parent, 5, step=3): print(x)
[Fraction(1, 1), Fraction(81, 64), Fraction(4, 3), Fraction(27, 16), Fraction(243, 128)]
[Fraction(1, 1), Fraction(9, 8), Fraction(4, 3), Fraction(3, 2), Fraction(243, 128)]
[Fraction(1, 1), Fraction(9, 8), Fraction(81, 64), Fraction(3, 2), Fraction(27, 16)]
[Fraction(1, 1), Fraction(9, 8), Fraction(32, 27), Fraction(3, 2), Fraction(27, 16)]
[Fraction(1, 1), Fraction(81, 64), Fraction(4, 3), Fraction(3, 2), Fraction(243, 128)]
[Fraction(1, 1), Fraction(9, 8), Fraction(4, 3), Fraction(3, 2), Fraction(27, 16)]
[Fraction(1, 1), Fraction(9, 8), Fraction(4, 3), Fraction(3, 2), Fraction(27, 16)]
"""
result = []
for i in range(len(parent_mos)):
scale = step_through(parent_mos, n, step, start=i)
transposed_scale = [x / scale[0] for x in scale]
result.append(transposed_scale)
assert len(set(standard_mode_steps(x) for x in result)) == n
return result
def find_secondary_mos(generator, N, n, step):
"""
>>> family = find_secondary_mos(Fraction(4, 3), 7, 5, 3)
>>> len(family)
7
>>> family = find_secondary_mos(Fraction(4, 3), 17, 7, 5)
>>> len(family)
17
"""
parent = sorted(reduce(generator**i) for i in range(N))
family = secondary_mos_family(parent, n, step)
analytic_step_sizes = secondary_mos_step_sizes(
generator, parent.index(generator), N, step, n
)
computed_step_sizes = {s for scale in family for s in steps(scale)}
assert set(analytic_step_sizes) == computed_step_sizes
return family
def steps(scale):
scale = scale + [Fraction(2)]
return tuple(y / x for x, y in zip(scale, scale[1:]))
def standard_mode_steps(scale):
"""
>>> scale = [Fraction(1, 1), Fraction(81, 64), Fraction(4, 3), Fraction(27, 16), Fraction(243, 128)]
>>> standard_mode_steps(scale)
(Fraction(81, 64), Fraction(9, 8), Fraction(256, 243), Fraction(81, 64), Fraction(256, 243))
"""
s = steps(scale)
return max(s[i:] + s[:i] for i in range(len(s)))
def reduce(x):
return x * Fraction(2) ** (-floor(log2(x)))
def stern_brocot(num, denom):
"""
>>> stern_brocot(5, 17)
[((0, 1), (1, 0)), ((0, 1), (1, 1)), ((0, 1), (1, 2)), ((0, 1), (1, 3)), ((1, 4), (1, 3)), ((2, 7), (1, 3)), ((2, 7), (3, 10))]
"""
a, c = 0, 1 # left = 0/1
b, d = 1, 0 # right = 1/0 (infinity)
result = []
while True:
m, n = a + b, c + d
result.append(((a, c), (b, d)))
if (m, n) == (num, denom):
break
if d == 0 or num * n < denom * m:
b, d = m, n # mediant is to the right of fraction
else:
a, c = m, n # mediant is to the left of fraction
return result
def secondary_mos_step_sizes(G, M, N, step, n):
"""
Analytic formulae for secondary MOS family step sizes
These can be derived by mapping the parent MOS to a two-dimensional keyboard.
`G` is the generator of the parent MOS, e.g. 4/3. `M` is the index of the
generator in the parent MOS. `N` is the size of the parent MOS. `step` is the
step used to find the secondary MOS by stepping through the parent MOS. `n` is
the number of notes in the secondary MOS.
Examples from Wilson's MOS letter:
Tanabe cycle:
>>> secondary_mos_step_sizes(Fraction(4, 3), 3, 7, 3, 5)
(Fraction(9, 8), Fraction(256, 243), Fraction(32, 27), Fraction(81, 64))
Cycle of 17 scales:
>>> secondary_mos_step_sizes(Fraction(4, 3), 7, 17, 5, 7)
(Fraction(2187, 2048), Fraction(65536, 59049), Fraction(16777216, 14348907), Fraction(9, 8))
"""
M_inv = pow(M, -1, N)
k = (M_inv * step) % N
# Secondary MOS size n must be a valid mos size for step/N
c, d = None, None
for (a, c), (b, d) in stern_brocot(step, N):
if c + d == n:
break
assert c is not None, f"{n} not a valid MOS size for {step}/{N}"
neg_kd = (-k * d) % N
kc = (k * c) % N
# Analytic formulae for secondary MOS step sizes
S1 = reduce(G ** (neg_kd - N))
S2 = reduce(G**neg_kd)
T1 = reduce(G**kc)
T2 = reduce(G ** (kc - N))
# Equal ratio property
assert S2 / S1 == T1 / T2
# Equal ratio between two smallest and two largest steps
sorted_steps = sorted({S1, S2, T1, T2})
assert sorted_steps[1] / sorted_steps[0] == sorted_steps[3] / sorted_steps[2]
return (S1, S2, T1, T2)