14.0 Probability in music

import matplotlib
if not hasattr(matplotlib.RcParams, "_get"):
    matplotlib.RcParams._get = dict.get

14.0 Probability in music#

The algorithmic composition techniques in this chapter require an initial leap of faith: that composing music can be viewed as a probabilistic process. Before we take this leap, we need a few concepts from probability.

At the core of probability is the random variable, a variable whose value is the outcome of a random process. In this chapter, we will primarily study random variables that have a finite set of possible outcomes, such as the six faces of a die, or a set of musical pitches. A categorical distribution describes the probability of each outcome:

Definition 42 (Categorical distribution)

Let \(X\) be a random variable whose possible outcomes form a finite set \(\mathcal{X}\), called its alphabet. A categorical distribution assigns a probability \(P(X = x) \in [0, 1]\) to each outcome \(x \in \mathcal{X}\), such that the probabilities sum to one:

\[\sum_{x \in \mathcal{X}} P(X = x) = 1.\]

When it is clear which random variable we are referring to, we will often abbreviate \(P(X = x)\) as \(P(x)\).

Example 2 (A fair die)

The roll of a fair, six-sided die is a random variable with the alphabet \(\mathcal{X} = \{1, 2, 3, 4, 5, 6\}\). Every outcome is equally likely, so \(P(x) = \frac{1}{|\mathcal{X}|} = \frac{1}{6}\) for every \(x \in \mathcal{X}\), where \(|\mathcal{X}|\) is the size of the alphabet (the number of outcomes).

The code below simulates rolls of this die with NumPy’s np.random.choice, which takes an alphabet and its probabilities. Ten rolls look fairly arbitrary, but over six thousand rolls, each face comes up about one thousand times:

# hide
import numpy as np
alphabet = [1, 2, 3, 4, 5, 6]
weights = np.ones(6)
weights /= weights.sum()                        # a fair die
print(np.random.choice(alphabet, size=10, p=weights))

rolls = np.random.choice(alphabet, size=6000, p=weights)
for face in alphabet:
    print(f"{face}: {np.sum(rolls == face)} rolls")
[2 4 4 5 2 2 6 3 3 6]
1: 1057 rolls
2: 981 rolls
3: 940 rolls
4: 1050 rolls
5: 995 rolls
6: 977 rolls

Try loading the die, by setting weights[-1] = 5.0 before the division. How often does a \(6\) come up now?

Stochastic processes#

A categorical distribution models a single outcome. But a musical score contains a sequence of notes, requiring a more sophisticated abstraction:

Definition 43 (Stochastic process)

A stochastic process is a sequence of random variables, \(X_1, X_2, \ldots, X_N\). Each random variable \(X_i\) has its own alphabet \(\mathcal{X}_i\) and distribution, which may depend on its position \(i\) in the sequence.

For a stochastic process, we abbreviate \(P(X_i = x_i)\) as \(P(x_i)\).

Example 3 (A pentatonic melody)

Here is a basic stochastic process that composes a melody of \(N\) notes. Every position uses the same alphabet of five pitches, \(\mathcal{X}_i = \{60, 62, 64, 67, 69\}\) (the notes C, D, E, G, and A, which form a pentatonic scale), and every pitch is equally likely:

\[P(X_i = x) = \frac{1}{|\mathcal{X}_i|} = \frac{1}{5} \quad \text{for all } x \in \mathcal{X}_i.\]

A distribution like this one, in which every outcome is equally likely, is called uniform.

How do we generate a melody from this process? We draw a sample of each random variable according to its distribution:

\[x_1 \sim P(X_1), \quad x_2 \sim P(X_2), \quad \ldots, \quad x_N \sim P(X_N),\]

where \(x_i \sim P(X_i)\) means that the outcome \(x_i\) is drawn at random with the probabilities of \(X_i\). In this simple process, each \(X_i\) is drawn independently, without regard to the outcomes of the others, so we could just as well draw them in any order, or all at once. Later in this chapter, we will study processes in which each outcome depends on the outcomes before it.

These \(N\) pitches do not yet make a score, though. We also need to decide when to play each note, and for how long. The inter-onset interval (IOI) is the amount of time between the onsets of consecutive notes, and the duration is the amount of time that each note lasts. When the duration exceeds the IOI, consecutive notes overlap.

The code below implements this example process, with an IOI of a quarter of a beat at \(120\) BPM, and prints the first four events of the score that it generates:

# hide
import numpy as np
import pyquist as pq
from scipy.signal import lfilter
# hide
def pluck(pitch: float, duration: float, f_s: int = 44100, **kwargs) -> pq.Audio:
    """A Karplus-Strong plucked string (Chapter 12) that decays by 60 dB in `duration`
    seconds. An all-pass filter (Chapter 13) in the loop tunes it precisely."""
    P = f_s / pq.helper.pitch_to_frequency(pitch)   # the period, in (fractional) samples
    M = int(P - 0.6)                                 # the delay line (the low-pass adds 1/2 sample)
    D = P - 0.5 - M                                  # the all-pass adds the remaining D samples
    c = (1 - D) / (1 + D)                            # the all-pass gain for a delay of D samples
    g = 10 ** (-3 * P / (f_s * duration))            # decay by 60 dB over the duration
    x = np.zeros(int(duration * f_s))
    x[:M] = np.random.uniform(-1.0, 1.0, M)          # excitation: a burst of white noise
    # The whole loop is one recursive filter (Chapter 9), which SciPy runs quickly:
    # y[n] = x[n] + c x[n-1] - c y[n-1] + (g/2) (c y[n-M] + (1 + c) y[n-M-1] + y[n-M-2])
    a = np.zeros(M + 3)
    a[:2] = [1.0, c]
    a[M:] -= 0.5 * g * np.array([c, 1 + c, 1.0])
    return pq.Audio(lfilter([1.0, c], a, x), f_s)
def uniform(N: int, pitches: list[int] = [60, 62, 64, 67, 69], ioi: float = 0.25,
            duration: float = 0.5) -> pq.Score:
    """A melody of N notes, whose pitches are drawn independently and uniformly at random.
    Onsets are in beats, and durations (decay times) are in seconds."""
    choices = np.random.choice(pitches, size=N)
    return pq.Score([(i * ioi, {"pitch": int(pitch), "duration": duration})
                     for i, pitch in enumerate(choices)])


score = uniform(64)
for event in score[:4]:
    print(event)
pq.play(score.render(pluck, metronome=pq.BasicMetronome(bpm=120)), normalize=True)
Event(time=0.0, kwargs={'pitch': 69, 'duration': 0.5})
Event(time=0.25, kwargs={'pitch': 60, 'duration': 0.5})
Event(time=0.5, kwargs={'pitch': 60, 'duration': 0.5})
Event(time=0.75, kwargs={'pitch': 64, 'duration': 0.5})

Musical dice games#

One of the earliest examples of algorithmic composition is the musical dice game (in German, Musikalisches Würfelspiel), which was popular in 18th-century Europe. A composer would write many interchangeable snippets of a score, usually one measure (a short span with a fixed number of beats) each. Players would then roll dice to choose which snippet to play at each position in a piece.

The most famous of these games (K. 516f) is often attributed to Mozart, though the attribution is unconfirmed. Published in the 1790s, it contains \(176\) one-measure snippets for a short waltz in \(16\) measures, along with a table that assigns \(11\) of the snippets to each of the \(16\) measures. For each measure, the player rolls two dice, and looks up the snippet for their sum, from \(2\) to \(12\) (\(11\) possible outcomes):

A grid of nine snippets of music notation, each one measure long, with a treble staff and a bass staff. The three columns are measures 1, 2, and 3 of the waltz, and the three rows are the dice sums S = 2, 3, and 4, labeled with their probabilities, 3%, 6%, and 8%, and with pictures of the rolls of two dice that give each sum: one roll for 2, two for 3, and three for 4. Ellipses show that the grid continues to the right and downward. Red boxes highlight one snippet in each column, for the sums 4, 2, and 3, connected by dashed red arrows.

Fig. 111 The first three measures of the dice game attributed to Mozart, for the dice sums \(S = 2\), \(3\), and \(4\) (out of \(2\) through \(12\)). Each roll of the dice picks one snippet for its measure. Here, the rolls are \(4\), then \(2\), then \(3\).#

Example 4 (The sum of two dice)

The sum of two dice, \(S\), is itself a categorical random variable, but its distribution is not uniform. Each die has \(6\) equally likely faces, so a pair of dice has \(6 \cdot 6 = 36\) equally likely outcomes. Of these, only one, \((1, 1)\), sums to \(2\), while six sum to \(7\). So \(P(S = 2) = \frac{1}{36} \approx 3\%\), but \(P(S = 7) = \frac{6}{36} \approx 17\%\).

A bar chart of the probability of each sum of two dice, from 2 to 12. The bars rise in a straight line from 1/36 at a sum of 2 to a peak of 6/36 at a sum of 7, and then fall in a straight line back to 1/36 at a sum of 12.

Fig. 112 The distribution of the sum \(S\) of two fair dice.#

In stochastic processes, some outcomes can be much more likely than others. Because each roll of the dice has no effect on the others, the probability of a particular sequence of rolls is the product of the probabilities of its \(16\) rolls. For example, rolling a \(7\) for every measure has probability \(\left(\frac{1}{6}\right)^{16} \approx 3.5 \times 10^{-13}\), while rolling a \(2\) for every measure has probability \(\left(\frac{1}{36}\right)^{16} \approx 1.3 \times 10^{-25}\). With \(11^{16} \approx 4.6 \times 10^{16}\) possible sequences of rolls, both are astronomically unlikely. But rolling a \(2\) for every measure is about three trillion (\(6^{16} \approx 2.8 \times 10^{12}\)) times less likely than rolling a \(7\) for every measure.

The widget below, inspired by an online version of the game, lets you play it. Each column is one of the \(16\) measures (M1 through M16), and each row is one possible sum of the dice. Click a snippet to choose it, or roll the dice for every measure at once, and then listen to your waltz:

# hide
# no-output
from IPython.utils.capture import capture_output
with capture_output():
    %pip install -q plotly anywidget

import asyncio
import json
import os
import numpy as np
import plotly.graph_objects as go
import ipywidgets as widgets
from IPython.display import Audio
import pyquist as pq
from scipy.signal import lfilter
import icm_plotly
from icm_plotly import RED, BLUE, IRON, STEEL
# hide
# autorun
with open("./assets/mozart-dice.json") as file:
    DICE = json.load(file)
TABLE = {int(s): bars for s, bars in DICE["table"].items()}   # sum of the dice -> 16 bars
BARS = {int(b): notes for b, notes in DICE["bars"].items()}   # bar number -> its notes
SUMS = list(range(2, 13))
P_SUM = {s: (6 - abs(s - 7)) / 36 for s in SUMS}              # P(S = s) for two dice
SR = 44100
EIGHTH = 0.3                                                  # seconds per eighth note
HOP = int(3 * EIGHTH * SR)                                    # samples per 3/8 bar
TAIL = int(1.5 * SR)                                          # room for the strings to ring
ROLLS0 = [7, 5, 8, 6, 9, 7, 4, 7, 6, 10, 7, 8, 3, 7, 8, 7]    # the waltz on the page at first
PITCHES = [n["pitch"] for notes in BARS.values() for n in notes]
P_LO, P_HI = min(PITCHES) - 2, max(PITCHES) + 2


def pluck(pitch, duration, f_s=SR):
    """The tuned Karplus-Strong plucked string from this chapter's other examples."""
    P = f_s / pq.helper.pitch_to_frequency(pitch)
    M = int(P - 0.6)
    D = P - 0.5 - M
    c = (1 - D) / (1 + D)
    g = 10 ** (-3 * P / (f_s * duration))
    x = np.zeros(int(duration * f_s))
    x[:M] = np.random.uniform(-1.0, 1.0, M)
    a = np.zeros(M + 3)
    a[:2] = [1.0, c]
    a[M:] -= 0.5 * g * np.array([c, 1 + c, 1.0])
    return lfilter([1.0, c], a, x)


def render_bar(bar, cache={}):
    """The audio of one bar, including its ringing tail (cached: each bar renders once)."""
    if bar not in cache:
        x = np.zeros(HOP + TAIL)
        for note in BARS[bar]:
            y = 0.5 * pluck(note["pitch"], 0.6 + EIGHTH * note["duration"])
            start = int(note["onset"] * EIGHTH * SR)
            x[start:start + len(y)] += y[:len(x) - start]
        cache[bar] = x
    return cache[bar]


def piano_roll(rolls):
    """Line segments for the notes of the treble and the bass, in measures."""
    lines = {"treble": ([], []), "bass": ([], [])}
    for k, s in enumerate(rolls):
        for note in BARS[TABLE[s][k]]:
            x0 = k + note["onset"] / 3
            xs, ys = lines.get(note["staff"], lines["treble"])
            xs += [x0, x0 + 0.95 * note["duration"] / 3, None]
            ys += [note["pitch"], note["pitch"], None]
    return lines


def figure():
    lines = piano_roll(ROLLS0)
    fig = go.Figure()
    bar_x = [x for k in range(17) for x in (k, k, None)]
    bar_y = [y for k in range(17) for y in (P_LO, P_HI, None)]
    fig.add_scatter(x=bar_x, y=bar_y, mode="lines", line=dict(color=STEEL, width=1),
                    hoverinfo="skip")
    fig.add_scatter(x=lines["treble"][0], y=lines["treble"][1], mode="lines",
                    line=dict(color=RED, width=6), hoverinfo="skip")
    fig.add_scatter(x=lines["bass"][0], y=lines["bass"][1], mode="lines",
                    line=dict(color=BLUE, width=6), hoverinfo="skip")
    fig.update_xaxes(range=[0, 16], title_text="Measure", tickvals=np.arange(16) + 0.5,
                     ticktext=[f"M{k + 1}" for k in range(16)], fixedrange=True)
    fig.update_yaxes(range=[P_LO, P_HI], title_text="MIDI pitch", fixedrange=True)
    fig.update_layout(showlegend=False, height=320)
    return fig


def controls(fig):
    rolls = list(ROLLS0)
    buttons = {}
    cells = [widgets.HTML("")]
    cells += [widgets.HTML(f"<div style='text-align:center; font-size:0.8em'><b>M{k + 1}</b></div>")
              for k in range(16)]
    for s in SUMS:
        cells.append(widgets.HTML(f"<div style='text-align:right; padding-right:6px'>"
                                  f"<b>{s}</b></div>"))
        for k in range(16):
            button = widgets.Button(description=str(TABLE[s][k]), tooltip=f"M{k + 1}, "
                                    f"sum {s}: snippet {TABLE[s][k]}",
                                    layout=widgets.Layout(width="100%", height="24px", padding="0"))
            button.style.font_size = "11px"
            buttons[s, k] = button
            cells.append(button)
    grid = widgets.GridBox(cells, layout=widgets.Layout(
        grid_template_columns="32px repeat(16, minmax(0, 1fr))", grid_gap="4px 10px", width="100%"))
    roll = widgets.Button(description=" Roll the dice", icon="random")
    readout = widgets.HTML()
    out = widgets.Output()
    pending = []

    # the defaults snapshot the helpers; the page's notebooks share one kernel
    def paint(buttons=buttons, P_SUM=P_SUM):
        for (s, k), button in buttons.items():
            chosen = rolls[k] == s
            button.style.button_color = RED if chosen else "#f2f2f2"
            button.style.text_color = "white" if chosen else "#444444"
        p = np.prod([P_SUM[s] for s in rolls])
        readout.value = (f"<span style='font-size:0.9em; margin-left:12px'>Sums: {' '.join(map(str, rolls))} "
                         f"&nbsp;·&nbsp; probability of rolling exactly these sums: "
                         f"1 in {1 / p:,.0f}</span>")

    def redraw(piano_roll=piano_roll):
        lines = piano_roll(rolls)
        with fig.batch_update():
            fig.data[1].x, fig.data[1].y = lines["treble"]
            fig.data[2].x, fig.data[2].y = lines["bass"]

    def render(TABLE=TABLE, render_bar=render_bar, HOP=HOP, TAIL=TAIL, SR=SR):
        x = np.zeros(16 * HOP + TAIL)
        for k, s in enumerate(rolls):
            x[k * HOP:k * HOP + HOP + TAIL] += render_bar(TABLE[s][k])
        x *= 0.125 / np.abs(x).max()          # about -18 dBFS, a safe level
        audio = Audio(x.astype(np.float32), rate=SR, normalize=False)
        data, metadata = get_ipython().display_formatter.format(audio)
        # one assignment swaps the old card for the new one in place
        out.outputs = ({"output_type": "display_data", "data": data, "metadata": metadata},)

    async def settle():
        await asyncio.sleep(0.3)
        pending.clear()
        render()

    def changed():
        paint()
        redraw()
        if pending:
            pending.pop().cancel()
        pending.append(asyncio.ensure_future(settle()))

    def choose(s, k):
        rolls[k] = s
        changed()

    def roll_dice(_):
        for k in range(16):
            rolls[k] = int(np.random.randint(1, 7) + np.random.randint(1, 7))
        changed()

    for (s, k), button in buttons.items():
        button.on_click(lambda _, s=s, k=k: choose(s, k))
    roll.on_click(roll_dice)
    paint()
    if not os.environ.get("ICM_BOOK_BUILD"):   # the build bakes no card
        render()
    return widgets.VBox([grid, widgets.HBox([roll, readout]), out])


icm_plotly.show(figure, controls)
M1
M2
M3
M4
M5
M6
M7
M8
M9
M10
M11
M12
M13
M14
M15
M16
2
96
22
141
41
105
122
11
30
70
121
26
9
112
49
109
14
3
32
6
128
63
146
46
134
81
117
39
126
56
174
18
116
83
4
69
95
158
13
153
55
110
24
66
139
15
132
73
58
145
79
5
40
17
113
85
161
2
159
100
90
176
7
34
67
160
52
170
6
148
74
163
45
80
97
36
107
25
143
64
125
76
136
1
93
7
104
157
27
167
154
68
118
91
138
71
150
29
101
162
23
151
8
152
60
171
53
99
133
21
127
16
155
57
175
43
168
89
172
9
119
84
114
50
140
86
169
94
120
88
48
166
51
115
72
111
10
98
142
42
156
75
129
62
123
65
77
19
82
137
38
149
8
11
3
87
165
61
135
47
147
33
102
4
31
164
144
59
173
78
12
54
130
10
103
28
37
106
5
35
20
108
92
12
124
44
131
Roll the dice
Sums: 7 5 8 6 9 7 4 7 6 10 7 8 3 7 8 7  ·  probability of rolling exactly these sums: 1 in 189,535,253,532,865

Score data from the 1793 edition (public domain), based on the Humdrum encoding by Craig Sapp, Daniel Shanahan, and Mauricio Rodriguez.