Practical: Tonnetz GenerationΒΆ
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.
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()
(<Figure size 1000x700 with 1 Axes>, <Axes: >)
- 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
- 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 toaxis
.- You can specify locations in
(fifths, thirds)
-steps orindices
with the pitch classC
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
andTonnetz.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 thepitchtypes
library.
def pitch_class(locs):
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[C, G, D#, E] [C, G, G, C, A]
[<matplotlib.lines.Line2D at 0x7f46b1ad5150>]
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.
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]]
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[<matplotlib.lines.Line2D at 0x7f46b17cece0>]
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
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
<matplotlib.legend.Legend at 0x7f46b5aec190>
Update the sample_path
function to use the extend
function.
def sample_path(weights, start=(0, 0)):
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[<matplotlib.lines.Line2D at 0x7f46b1381ae0>]
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.
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
<matplotlib.collections.PathCollection at 0x7f46b167f490>
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
def stack_automaton(weights, start=(0, 0), min_len=0, max_len=None, verbose=False):
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
return sequence
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]
[<matplotlib.lines.Line2D at 0x7f46b0d6a0b0>]
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
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)
- use
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
- start by creating a
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]
# NCC
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# with music21
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
/lib/x86_64-linux-gnu/libjack.so.0 /lib/x86_64-linux-gnu/libnss3.so
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.
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)
[(1.0, False), (1.0, True), (1.0, True), (0.5, False), (0.25, True), (0.25, True), (1, True)]
def rhythm_and_melody(rhythm, melody, show=True, play=True, use_m21=False):
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
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)]
/lib/x86_64-linux-gnu/libjack.so.0 /lib/x86_64-linux-gnu/libnss3.so
Random MelodyΒΆ
Generate a random melody and compare its character to the melody generated using the Tonnetz.
# vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
# Put your code here!
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
rhythm = get_rhythm(4)
rhythm_and_melody(rhythm, melody)
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).