Note
Go to the end to download the full example code.
Tonnetz Torus
…
import numpy as np
import matplotlib.pyplot as plt
import plotly.graph_objects as go
from examples.util import show
from examples.plot_get_fourier_coefficients import dft
from examples.plot_3D_objects import Surface, Sphere, Torus, plot_surface
/home/runner/work/snippets/snippets/examples/plot_3D_objects.py:34: SyntaxWarning:
invalid escape sequence '\ '
Helper functions
def remap(x, y, z, R=1, r=0.5, twist=1):
"""
Take a "flattened" torus in x/y and roll it up. x will be the "small" circle with radius r, y will be the "large"
circle with radius R. 'twist' is the number of times the torus is twisted in itself (around the small circle)
while going around the large circle.
"""
return Torus.torus(phi1=y + twist * x, phi2=x, r1=R, r2=r)
def remap_surface(s):
"""roll up a surface"""
x, y, z = s.vertices.T
x, y, z = remap(x, y, z)
s.vertices = np.stack([x, y, z], axis=-1)
return s
def transform_vertices(surface, scale, x, y, z):
"""shift and scale surface"""
surface.vertices *= scale
surface.vertices += [x, y, z]
return surface
def barycentric(points, weights=None):
"""compute new point as weighted average of given points"""
if weights is None:
weights = np.ones(len(points))
point = None
wsum = None
for p, w in zip(points, weights):
p = np.array(p)
if point is None:
point = w * p
wsum = w
else:
point += w * p
wsum += w
return point / wsum
def get_plotly_fig(data=(), fig=None):
"""
Add traces to a plotly figure; optionally first create the figure with good layout (equal axis scaling,
axes invisible).
"""
data = list(data)
if fig is None:
fig = go.Figure(layout=dict(scene=dict(aspectmode='data',
xaxis=dict(visible=False),
yaxis=dict(visible=False),
zaxis=dict(visible=False),
)))
for d in data:
fig.add_trace(d)
return fig
Tonnetz Torus
# lay out 12 pitch classes in flattened torus
xy = [(x, y)
for x in np.linspace(0, 1, 4, endpoint=False)
for y in np.linspace(0, 1, 3, endpoint=False)]
# create triangles for major and minor chords
major_surfaces = [Surface(vertices=[[x, y, 0],
[x + 1/4, y, 0],
[x, y + 1/3, 0]],
faces=[[0, 1, 2]])
for x, y in xy]
minor_surfaces = [Surface(vertices=[[x + 1 / 4, y + 1 / 3, 0],
[x, y + 1/3, 0],
[x + 1/4, y, 0]],
faces=[[0, 1, 2]])
for x, y in xy]
# refine and roll up
for s in major_surfaces + minor_surfaces:
s.refine(repeat=4)
major_surfaces = [remap_surface(m) for m in major_surfaces]
minor_surfaces = [remap_surface(m) for m in minor_surfaces]
# create spheres for pitch classes, major and minor chords
pitch_classes = []
for x, y in xy:
x, y, z = remap(x, y, 0)
s = Sphere()
transform_vertices(s, 0.05, x, y, z)
pitch_classes.append(s)
major_chords = []
for x, y in xy:
x, y = barycentric([[x, y], [x, y - 1 / 3], [x + 1 / 4, y - 1 / 3]], [2, 1, 2])
major_chords.append(transform_vertices(Sphere(), 0.1, *remap(x, y, 0)))
minor_chords = []
for x, y in xy:
x, y = barycentric([[x, y], [x + 1 / 4, y], [x + 1 / 4, y - 1 / 3]], [2, 1, 2])
minor_chords.append(transform_vertices(Sphere(), 0.1, *remap(x, y, 0)))
# create the plotly figure with torus surface (major/minor chord triangle)
plotly_fig = None
for t, surface_kwargs in [(major_surfaces, dict(color='palevioletred', showlegend=False)),
(minor_surfaces, dict(color='lightblue', showlegend=False))]:
for s in t:
plotly_fig = plot_surface(s,
surface_kwargs=surface_kwargs,
# line_kwargs=dict(showlegend=False),
# vertex_kwargs=dict(showlegend=False),
smooth=True,
fig=plotly_fig)
# add spheres for pitch classes and chords
for s in pitch_classes:
plotly_fig = plot_surface(s, surface_kwargs=dict(color='gray', showlegend=False), smooth=True, fig=plotly_fig)
for m, surface_kwargs in [(major_chords, dict(color='palevioletred', showlegend=False)),
(minor_chords, dict(color='lightblue', showlegend=False))]:
for s in m:
plotly_fig = plot_surface(s, surface_kwargs=surface_kwargs, smooth=True, fig=plotly_fig)
show(plotly_fig, __name__)
Tonnetz Torus Shells
fig = None
fig = plot_surface(Torus(R=2, r=1, sphi1=0.7, sphi2=0.8, dphi1=0, dphi2=0.2),
surface_kwargs=dict(color='rgb(0.9, 0.3, 0.3)'), fig=fig, smooth=True)
fig = plot_surface(Torus(R=2, r=0.9, sphi1=0.8, sphi2=0.8, dphi1=0, dphi2=0.2), fig=fig, smooth=True)
fig = plot_surface(Torus(R=2, r=0.8, sphi1=0.9, sphi2=0.8, dphi1=0, dphi2=0.2), fig=fig, smooth=True)
show(fig)
Pitch-class distributions
Plot distributions of pitch-class distributions and relate them to Fourier coefficients
# single pitch class transitions
def single_pitch_class_tonnetz(n=500):
"""
Create PCDs that transition from one single pitch class to another (by linearly interpolating weights,
so half way, both pitch classes have a weight of 0.5) along the connections of the Tonnetz.
"""
up = np.linspace(0, 1, n)
down = np.linspace(1, 0, n)
pcds = np.zeros(((n + 1) * 12 * 3, 12))
for trans in range(12):
offset = (n + 1) * trans * 3
pc1 = trans
for idx, pc2 in enumerate([
(pc1 + 7) % 12, # fifths
(pc1 + 4) % 12, # major thirds
(pc1 + 3) % 12, # minor thirds
]):
o = offset + (n + 1) * idx
pcds[o:o + n, pc1] = down
pcds[o:o + n, pc2] = up
pcds[o + n:o + n + 1, pc1] = np.nan
pcds[o + n:o + n + 1, pc2] = np.nan
pcds /= pcds.sum(axis=1, keepdims=True)
mags, phas = dft(pcds)
return mags, phas
# visualise coefficients in 2D
def viz_pcds_2D(mags, phas, fig_axes=None):
"""Visualise the 6 coefficients in polar plots"""
if fig_axes is None:
fig, axes = plt.subplots(
3, # rows
2, # columns
figsize=(8, 10),
subplot_kw={'projection': 'polar'}
)
else:
fig, axes = fig_axes
axes = axes.ravel() # flatten [[row][col]] → [idx]
for k, ax in enumerate(axes):
p = phas[:, k + 1] # phase column k
r = mags[:, k + 1] # magnitude column k
ax.scatter(p, r, s=12, alpha=0.75) # scatter size 12 pt
ax.set_title(f"Coefficient {k+1}", pad=12) # pad lifts title off the plot
ax.grid(True)
ax.set_rlabel_position(0)
ax.tick_params(labelsize=8)
fig.tight_layout()
return fig, axes
# visualise in 3D
def viz_pcds_3D(mags, phas, **kwargs):
"""Map 5th and 3rd coefficient to torus and plot as 3D scatter plot"""
x, y, z = Torus.torus(phi1=phas[:, 5] / (2 * np.pi),
phi2=phas[:, 3] / (2 * np.pi),
# r1=mags[:, 5],
r1=np.ones_like(mags[:, 5]),
r2=2 * mags[:, 3],
)
return go.Scatter3d(x=x, y=y, z=z, **kwargs)
Show distributions of PCDs
# random PCDs with extreme values using Dirichlet
pcds = np.random.dirichlet(np.ones(12) * 0.01, 100000)
mags, phas = dft(pcds)
## select extreme points
# print(mags.min(), mags.max())
# select = mags > 0.1
# print(select.min(), select.max(), select.sum())
# select = select.sum(axis=1).astype(bool)
# print(select.min(), select.max(), select.sum())
# select pcds with well-defined "key" (large magnitude of fifths coefficient)
# select = mags[:, 5] > 0.12
select = np.ones_like(mags[:, 0], dtype=bool)
for i in range(1, 7):
select = np.logical_and(select, mags[:, 5] >= mags[:, i])
mags = mags[select]
phas = phas[select]
viz_pcds_2D(mags, phas)[0].show()
plotly_fig = get_plotly_fig([viz_pcds_3D(mags, phas, mode='markers', marker=dict(size=1))])
show(plotly_fig)

Show single pitch classes and their connections along the Tonnetz
mags, phas = single_pitch_class_tonnetz(100)
mags_, phas_ = single_pitch_class_tonnetz(2)
fig_axes = None
for m, p in [
(mags, phas),
(mags_, phas_),
]:
fig_axes = viz_pcds_2D(m, p, fig_axes=fig_axes)
fig, axes = fig_axes
fig.show()
plotly_fig = get_plotly_fig([
viz_pcds_3D(mags=mags, phas=phas, mode='lines', line=dict(width=3, color='black')),
viz_pcds_3D(mags=mags_, phas=phas_, mode='markers', marker=dict(size=5, color='red')),
],
fig=plotly_fig # add to scatter plot from above (comment out for separate figures)
)
show(plotly_fig)

Total running time of the script: (0 minutes 31.402 seconds)