Practical: Tonnetz GenerationΒΆ

InΒ [1]:
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as patches
import music21 as m21
import pygame
import muprocdurham as mpd
import muprocdurham.sound as mps
from pitchtypes import Spelled, Enharmonic

mpd.seed_everything(42)
pygame 2.6.1 (SDL 2.28.4, Python 3.10.16)
Hello from the pygame community. https://www.pygame.org/contribute.html

This practical is about using the Tonnetz as a starting point to generate music.

VisualisationΒΆ

Below is a helper class (using a static class, similar to a module, to avoide polluting the global namespace) to plot the Tonnetz and some related functionality for visualising things on the Tonnetz.

InΒ [2]:
class Tonnetz:
    # basic grid layout; transform (N, 2)-shaped arrays of indices to coordinates with 'indices @ basis'
    basis = np.array([
        [1., 0.],
        [np.cos(np.pi / 3), np.sin(np.pi / 3)],
    #     [0., 1.],
    ])
    # in continuous space
#     unit_cell = [[0, 0], [4, -1], [4, 2], [0, 3]]
#     unit_cell_offset = [-2, -1]
    # on pitch classes
    unit_cell = [[0, 0], [3, 0], [3, 2], [0, 2]] 
    unit_cell_offset = [0, 0]
    # including chords
    unit_cell = [[0, 0], [4, 0], [4, 3], [0, 3]] - np.array([0.1, 0.1])
    unit_cell_offset = [0, 0]
    # chords
    major_triangle = [[0, 0], [0, 1], [1, 0]] @ basis
    minor_triangle = [[0, 0], [1, -1], [1, 0]] @ basis
    ma_off = major_triangle.mean(axis=0)  # offset of a major triad
    mi_off = minor_triangle.mean(axis=0)  # offset of a minor triad
    # plotting
    pitch_spelling = ['just', 'spelled', 'enharmonic'][2]  # which pitch spelling to use
    font_scale = 0.5  # font rescaling
    margin_factor = 1 # margin around the unit cell
    centre_pitch_class = 'C' # pitch class at [0, 0]
    
    @staticmethod
    def plot(
        axis=None,  # axis to plot to
        figsize=(10, 7),
    ):
        # unit cell and plotting range
        unit_cell_ = (np.array(Tonnetz.unit_cell) + np.array(Tonnetz.unit_cell_offset)) @ Tonnetz.basis
        unit_cell_margin = unit_cell_ + Tonnetz.margin_factor * ([[-1, 0], [0, -1], [1, 0], [0, 1]] @ Tonnetz.basis)
        min_xy = unit_cell_margin.min(axis=0)
        max_xy = unit_cell_margin.max(axis=0)

        # for drawing pitches and chords
        hex_vertices = np.array([[1, 0], [0, 1], [-1, 1], [-1, 0], [0, -1], [1, -1]]) @ Tonnetz.basis
        hex_vertices = (hex_vertices + hex_vertices[(np.arange(6) + 1) % 6]) / 3

        # check pitch spelling
        font_scale_ = Tonnetz.font_scale
        if Tonnetz.pitch_spelling in ['just', 'spelled']:
            c = Spelled.PitchClass(Tonnetz.centre_pitch_class)
            fifth = Spelled.IntervalClass('P5')
            
        else:
            c = Enharmonic.PitchClass(Tonnetz.centre_pitch_class)
            fifth = Enharmonic.IntervalClass('P5')
            font_scale_ *= 1.5
        
        # generate the grid
        steps = []
        labels = []
        for fifths in range(-10, 10):
            for thirds in range(-10, 10):
                pc = c + (fifths + 4 * thirds) * fifth
                l = str(pc)
                if Tonnetz.pitch_spelling == 'just':
                    if thirds > 0:
                        l += "'" * thirds
                    if thirds < 0:
                        l += "," * -thirds
                labels.append(l)
                steps.append([fifths, thirds])
        steps = np.array(steps)
        xy = steps @ Tonnetz.basis

        # plot everything
        if axis is None:
            fig, ax = plt.subplots(1, 1, figsize=figsize)
        else:
            fig, ax = None, axis
        lines = []
        for xy_, l in zip(xy, labels):
            if np.any(xy_ < min_xy) or np.any(xy_ > max_xy):
                continue
            # pitches
            ax.add_patch(patches.Polygon(hex_vertices + xy_, closed=True, edgecolor=(0,0,0), facecolor=(0,0,0,0), zorder=-8))
            ax.add_patch(patches.Circle(xy_, radius=0.1, edgecolor=(0,0,0), facecolor=(0.95,0.95,0.95), zorder=-6))
            ax.text(xy_[0], xy_[1], l, fontsize=font_scale_ * 8, va='center', ha='center')
            # major chords
            ma = Tonnetz.ma_off + xy_
            ax.add_patch(patches.Polygon(Tonnetz.major_triangle + xy_, closed=True, edgecolor=(0,0,0), facecolor=(1,0.9,0.9), zorder=-10))
            ax.add_patch(patches.Circle(ma, radius=0.2, edgecolor=(0,0,0), facecolor=(1,0.95,0.95), zorder=-6))
            ax.text(ma[0], ma[1], l, fontsize=font_scale_ * 12, va='center', ha='center')
            # minor chords
            mi = Tonnetz.mi_off + xy_
            ax.add_patch(patches.Polygon(Tonnetz.minor_triangle + xy_, closed=True, edgecolor=(0,0,0), facecolor=(0.9,0.9,1), zorder=-10))
            ax.add_patch(patches.Circle(mi, radius=0.2, edgecolor=(0,0,0), facecolor=(0.95,0.95,1), zorder=-6))
            ax.text(mi[0], mi[1], l.lower(), fontsize=font_scale_ * 12, va='center', ha='center')
        ax.plot(*unit_cell_[np.arange(5) % 4].T, linewidth=5, solid_capstyle='round', c=(0.9, 0.1, 0.1, 0.3), zorder=1)
        ax.axis('equal')
        ax.axis('off')
        if axis is None:
            return fig, ax
    
    @staticmethod
    def check_idx(idx):
        idx = np.array(idx)
        assert len(idx.shape) == 2, "Index array must be 2D"
        assert idx.shape[1] == 2, "Second dimension of index array must contain (fifths, thirds)-steps"
        return idx
    
    @staticmethod
    def indices_to_coords(idx):
        idx = Tonnetz.check_idx(idx)
        return idx @ Tonnetz.basis
    
    @staticmethod
    def remap_to_unit_cell(idx):
        idx = Tonnetz.check_idx(idx)
        idx = idx - Tonnetz.unit_cell_offset
        for cond, offset in [
            (lambda x: x[:, 0] < 0, [4, -1]),
            (lambda x: x[:, 0] >= 4, [-4, 1]),        
            (lambda x: x[:, 1] < 0, [0, 3]),
            (lambda x: x[:, 1] >= 3, [0, -3]),
        ]:
            x = cond(idx)
            while np.any(x):
                idx[x] += offset
                x = cond(idx)
        return idx + Tonnetz.unit_cell_offset

TN = Tonnetz  # like module "import as"

TN.plot()
Out[2]:
(<Figure size 1000x700 with 1 Axes>, <Axes: >)
No description has been provided for this image
  1. Familiarise yourself with the visualising things on the Tonnetz by drawing:
    • a path of pitch classes: C-G-D#-E
    • a path of triads: C-G-g-c-A
  2. Also implement a pitch_class function that transforms lists of locations (e.g. a path) to a number in {0,...,11} representing the pitch class as MIDI number.

Hints:

  • Tonnetz.plot plots the Tonnetz and returns (figure, axis), so you can add additional plots to axis.
  • You can specify locations in (fifths, thirds)-steps or indices with the pitch class C corresponding to (0, 0).
  • When working with enharmonically equivalent pitch classes (as in MIDI), you can remap lists of indices to the unit cell using Tonnetz.remap_to_unit_cell.
  • You can transform lists of indices to x/y-coordinates for plotting using the Tonnetz.indices_to_coords function.
  • Tonnetz.ma_off and Tonnetz.mi_off give the offsets (in x/y-coordinates) of major and minor chords, respectively, relative to their root pitch class.
  • A fifth are 7 semitone steps and a major third 4.
  • You can convert and print the MIDI pitch classes using Enharmonic.PitchClass from the pitchtypes library.
InΒ [3]:
def pitch_class(locs):
    # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    # Put your code here!
    # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
InΒ [4]:
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[C, G, D#, E]
[C, G, G, C, A]
Out[4]:
[<matplotlib.lines.Line2D at 0x7f46b1ad5150>]
No description has been provided for this image

Sample PathsΒΆ

Generate paths (of a fixed length for now) on the Tonnetz by sampling steps along the three axes of the Tonnetz with different probabilities. Visualise them to get an intuition.

Hints:

  • Steps should be defined in terms of fifths and major thirds.
  • The third axis of the Tonnetz corresponds to a minor third, which is the difference of a fifth and a major third.
  • numpy.random.choice is your friend for sampling indices with given probabilites.
  • Adding a bit of random noise to paths can make it easier to follow if it goes back and forth.
  • You can change Tonnetz.margin_factor (default is 1) to increase the area of the Tonnetz if paths go outside the drawing area.
  • You can use np.cumsum to add up steps.
InΒ [5]:
def sample_step(weights,
                steps=[
    # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    # Put your code here!
    # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^            
                ]
):
    # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    # Put your code here!
    # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

def sample_path(n, weights, start=(0, 0)):
    # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    # Put your code here!
    # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

print(sample_step(weights=[1., 1., 1., 1., 1., 1.]))
print(sample_path(n=5, weights=[1., 1., 1., 1., 1., 1.]))
[0, 1]
[[ 0  0]
 [-1  1]
 [ 0  0]
 [ 0 -1]
 [ 1 -1]
 [ 2 -1]]
InΒ [6]:
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Out[6]:
[<matplotlib.lines.Line2D at 0x7f46b17cece0>]
No description has been provided for this image

Now generalise the path sampling by also randomising the path length. Define an extend(n) function, which returns the probability to further extend a path of length n.

Hints:

  • you can start by just returning a constant probability and later experiment with other functions
  • the plots below show you
    • the probability to extend at each step
    • the cumulative probability, that is, the probability of reaching at least this step
    • the length probability, that is, the probability to reach exactly this step
InΒ [7]:
def extend(n):
    # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    # Put your code here!
    # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

probs = np.array([extend(n) for n in range(10)])
cum_probs = np.concatenate([[1], np.exp(np.cumsum(np.log(probs)))])
len_probs = cum_probs[:-1] * (1 - probs)
print(f"{sum(len_probs)} approx. 1")
plt.plot(probs, label='step-wise probabilities')
plt.plot(cum_probs, label='cumulative probability')
plt.plot(len_probs, label='length probability')
plt.legend()
0.9997254169637901 approx. 1
Out[7]:
<matplotlib.legend.Legend at 0x7f46b5aec190>
No description has been provided for this image

Update the sample_path function to use the extend function.

InΒ [8]:
def sample_path(weights, start=(0, 0)):
    # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    # Put your code here!
    # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
InΒ [9]:
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Out[9]:
[<matplotlib.lines.Line2D at 0x7f46b1381ae0>]
No description has been provided for this image

Sample Distributions [optional]ΒΆ

Generate a number of paths and plot the distribution over their end points. This corresponds to a Monte Carlo approximation of the distribution defined by the Tonal Diffusion Model.

InΒ [10]:
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Out[10]:
<matplotlib.collections.PathCollection at 0x7f46b167f490>
No description has been provided for this image

Stack AutomatonΒΆ

Now, you know how to extend a path by sampling new steps and, of course, you can also shorten a path by dropping elements from its end. This is all you need for implementing a stack automaton:

  • start with a stack containing a single location (say, [0, 0]) and an empty sequence
  • with the extend probability, sample a step and push/append the resulting location to the stack
  • if you do not extend the stack, pop its last element and append it to the sequence
  • keep going until the stack is empty (you can restart if you want to get a minimum sequence length)

Hints:

  • make sure your extend probability is always below 1, otherwise the stack will never become empty and the automaton will not terminate
  • you can transform the sequence of locations to pitch classes using the pitch_class function from above
InΒ [11]:
def stack_automaton(weights, start=(0, 0), min_len=0, max_len=None, verbose=False):
    # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    # Put your code here!
    # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    return sequence
InΒ [12]:
seq = stack_automaton(weights=weights, min_len=10)
print([Enharmonic.PitchClass(i) for i in pitch_class(seq)])
seq = TN.remap_to_unit_cell(seq)
coords = TN.indices_to_coords(seq)
coords += np.random.uniform(-0.1, 0.1, coords.shape)
fig, ax = TN.plot()
ax.plot(*coords.T, '-o', ms=15, lw=5)
[C, C, C, C, C, D, G, G, F, C, G, G, C]
Out[12]:
[<matplotlib.lines.Line2D at 0x7f46b0d6a0b0>]
No description has been provided for this image

MelodiesΒΆ

Transform the sequence of pitch classes to actual pitches by defining the octave (i.e., adding some multiple of 12 to the MIDI pitch class).

Hints:

  • you can try to avoid large jumps at the octave boundaries by allowing for a larger window of pitches and trying to define the octave of the next pitch such that the step to the previous pitch is as small as possible
InΒ [13]:
def to_melody(seq, offset=60, min_pitch=58, max_pitch=72):
    # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    # Put your code here!
    # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    return melody

def get_melody(**kwargs):
    return to_melody(pitch_class(stack_automaton(**kwargs)))

melody = get_melody(weights=weights, min_len=10, max_len=20)
print(melody)
print([Enharmonic.Pitch(i) for i in melody])
[65, 67, 67, 72, 65, 60, 65, 62, 67, 67, 67, 62, 62, 67, 67, 67, 72]
[F4, G4, G4, C5, F4, C4, F4, D4, G4, G4, G4, D4, D4, G4, G4, G4, C5]

On NCC: Use the muprocdurham.sound.sound function to generate and play the melody with simple sinodial tones.

  • Hints:
    • use Enharmonic.Pitch(...).freq() to get the correct frequencies (or compute by hand as learned in the lecture)

On your own machine: Use music21 to make a score and play the melody.

  • Hints:
    • start by creating a music21.stream.Stream object
    • create music21.note.Note objects using the MIDI pitch from the melody and append these to the stream
    • you can set the duration of notes by changing their quarterLength property
InΒ [14]:
melody = get_melody(weights=weights, min_len=10, max_len=20)
print([Enharmonic.Pitch(i) for i in melody])
[C4, F4, A#4, A4, D4, A4, E4, E4, A4, C5, G4, B4, C5, G4, F4, C4, F4, G4, A4, A4]
InΒ [15]:
# NCC
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Your browser does not support the audio element.
InΒ [16]:
# with music21
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
/lib/x86_64-linux-gnu/libjack.so.0
/lib/x86_64-linux-gnu/libnss3.so
No description has been provided for this image

RhythmΒΆ

Below is a function that generates a very simple rhythm as a sequence of (duration, beat) pairs, where duration is the event's duration in quarter beats and beat indicates whether the event is a note (True) or a rest (False). Use this to add a rhythm to the melody.

InΒ [17]:
def get_rhythm(n_bars, split_prob=0.5, beat_prob=0.8, max_splits=4, min_splits=2, final=True):
    overall_rhythm = []
    # generate bars independently
    for _ in range(n_bars):
        # recursively split
        rhythm = [(4, False, None)]  # (duration, terminal, beat)
        for depth in range(max_splits):
            new_rhythm = []
            for d, t, b in rhythm:
                if t:
                    # keep terminals unchanged
                    new_rhythm += [(d, t, b)]
                elif depth < min_splits or np.random.uniform(0, 1) < split_prob:
                    # split (without terminating)
                    new_rhythm += [(d/2, t, b), (d/2, t, b)]
                elif np.random.uniform(0, 1) < beat_prob:
                    # terminat with a beat
                    new_rhythm += [(d, True, True)]
                else:
                    # terminate with a rest
                    new_rhythm += [(d, True, False)]
            rhythm = new_rhythm
        # fill non-terminated and drop terminal flag
        new_rhythm = []
        for d, t, b in rhythm:
            if b is None:
                b = np.random.uniform(0, 1) < beat_prob
            new_rhythm.append((d, b))
        overall_rhythm += new_rhythm
    # append one final closing beat
    if final:
        overall_rhythm += [(1, True)]
    return overall_rhythm

get_rhythm(1)
Out[17]:
[(1.0, False),
 (1.0, True),
 (1.0, True),
 (0.5, False),
 (0.25, True),
 (0.25, True),
 (1, True)]
InΒ [18]:
def rhythm_and_melody(rhythm, melody, show=True, play=True, use_m21=False):
    # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    # Put your code here!
    # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
InΒ [19]:
melody = get_melody(weights=weights, min_len=50, max_len=100)
rhythm = get_rhythm(4)
print([Enharmonic.Pitch(i) for i in melody])
print(rhythm)
rhythm_and_melody(rhythm=rhythm, melody=melody)
rhythm_and_melody(rhythm=rhythm, melody=melody, use_m21=True)
[G4, G#4, C5, F4, C4, C#4, G#4, C#4, G#4, C5, F4, A4, D4, E4, D4, A4, G4, D4, A4, C5, G4, A#4, C5, F4, G4, E4, A4, A4, D4, G4, D4, G4, D4, G4, D#4, E4, A4, D4, D4, D4, G4, C5, A4, A4, E4, A4, D4, G4, G4, E4, B4, F#4, A4, D4, G4, G4, D#4, C4, F4, G4, C5, D4, G4, C5, F4, F4, C4, G4, G4, A4, D4, G4, A4, A4, D4, G4, D4, G4, F4, A4, D4, A4, D4, C4, G4, C5, D4, D4, G4, C5]
[(1.0, True), (0.5, True), (0.25, True), (0.25, True), (1.0, True), (0.25, True), (0.25, True), (0.5, True), (0.5, True), (0.5, True), (0.5, False), (0.25, True), (0.25, True), (0.25, True), (0.25, True), (0.5, True), (1.0, True), (0.25, False), (0.25, True), (0.25, True), (0.25, True), (1.0, True), (0.5, False), (0.25, True), (0.25, True), (0.5, False), (0.5, True), (0.5, False), (0.5, True), (0.5, False), (0.5, True), (1.0, False), (0.5, True), (0.5, True), (1, True)]
No description has been provided for this image
Your browser does not support the audio element.
/lib/x86_64-linux-gnu/libjack.so.0
/lib/x86_64-linux-gnu/libnss3.so
No description has been provided for this image

Random MelodyΒΆ

Generate a random melody and compare its character to the melody generated using the Tonnetz.

InΒ [20]:
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
rhythm = get_rhythm(4)
rhythm_and_melody(rhythm, melody)
No description has been provided for this image
Your browser does not support the audio element.

More possibilities...ΒΆ

The Tonnetz melody does sound "less random" than the actual random melody. But it still does not sound like a "proper" melody. There are several reasons for this, the main one being that the Tonnetz generates harmonically related pitch classes that do not always result in nice melodic lines. There are several directions to further experiment and improve the generation:

  • The melodies can be elaborated, for instance, by repeating notes (e.g. sampling additional notes from the last k notes in the original sequence), adding ornamentations (such as trills or neighbour notes), or by filling larger jumps with intermediate notes to generate smoother melodic lines.
  • Instead of using the pitch-based version of the Tonnetz, one can use the triad based version with 5 possible steps corresponding to moving up/down on the line/circle of fifths and the three Neo-Riemannian transformations. Pitches can then be sampled from the respective triads (hint: good melodic lines emphasise the changing notes in the chord).
  • For a given chord, the corresponding scale can be used to generate additional notes and filling patterns (as above but better matching the current harmony).