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)
Coefficient 1, Coefficient 2, Coefficient 3, Coefficient 4, Coefficient 5, Coefficient 6


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)
Coefficient 1, Coefficient 2, Coefficient 3, Coefficient 4, Coefficient 5, Coefficient 6


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

Gallery generated by Sphinx-Gallery