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

11.1 Sequencing#

What can we do with samples once we have them? A primary use case is sequencing: playing samples back in different orders, or with different timing, than in their original recordings. Sequencing is a cornerstone of rhythm production in genres like hip-hop and electronic dance music, where producers build new melodies and beats out of fragments of old ones.

Pyquist’s pq.Score objects are a natural fit for sequencing. Recall from Chapter 4 that rendering a score calls an instrument function once per event, shifts each result to the event’s onset time, and sums them all up. There is no requirement that this function synthesize its output audio from scratch. A function that simply returns a sample as an pq.Audio object is a perfectly good “instrument”. Below, we highlight a couple examples of sequencing using pyquist scores.

Example: Chopping breaks#

Our first sequencer rearranges the beats of the Amen break, a practice producers call chopping, which forms the basis of entire genres of music like jungle and drum and bass.

First, let’s formally introduce a unit of time that we have glossed over so far. A beat is a symbolic unit of time in music, referring to the underlying pulse: what you might tap your foot to. The rate of beats, called the tempo, is usually expressed in \(\frac{\text{beats}}{\text{minute}}\) (BPM). The Amen break is approximately \(138.0\) BPM, so each of its beats lasts

\[\frac{1}{138.0} \left[\frac{\text{minutes}}{\text{beat}}\right] \cdot 60 \left[\frac{\text{seconds}}{\text{minute}}\right] \approx 0.435 \left[\frac{\text{seconds}}{\text{beat}}\right].\]

In the code below, we store the tempo as amen_bpm and compute amen_seconds_per_beat from it. The whole break lasts \(16\) beats. We start with an instrument, amen_sample(offset, duration), that extracts a sample of the break with pq.Audio.segment, allowing us to control samples with score Events of the form (2.0, {"offset": 0.0, "duration": 1.0}). Cutting a sample out of the middle of a recording can leave abrupt edges, which we hear as clicks. So the instrument also applies a very short piecewise-linear envelope, as in Chapter 4, that fades in over \(1\) ms and fades out over \(1\) ms.

As a sanity check, a score with one event per beat, each playing that same beat, should reproduce the original break. Then amen_chop(hits, speed, seed) builds a new score that plays randomly chosen beats instead, one every \(\frac{0.435}{\text{speed}}\) seconds:

# hide
import numpy as np
import pyquist as pq

amen = pq.Audio.from_file("./assets/amen.wav")
amen_bpm = 138.0                              # measured from the times of the break's drum hits
amen_seconds_per_beat = 60 / amen_bpm         # about 0.435 seconds


def amen_sample(offset: float, duration: float) -> pq.Audio:
    """An instrument that plays one sample of the Amen break."""
    sample = amen.segment(offset=offset, duration=duration)
    t = np.arange(len(sample)) / sample.sample_rate
    T = sample.duration                       # (the last beat may run past the end)
    env = np.interp(t, [0.0, 0.001, T - 0.001, T], [0.0, 1.0, 1.0, 0.0])
    return sample * env[:, np.newaxis]        # 1 ms fades smooth the cut points


# One event per beat, each playing that same beat: the original break
amen_orig = pq.Score([(i * amen_seconds_per_beat,
                       {"offset": i * amen_seconds_per_beat, "duration": amen_seconds_per_beat})
                      for i in range(16)])
for event in amen_orig[:4]:
    print(event)
pq.play(amen_orig.render(amen_sample))
Event(time=0.0, kwargs={'offset': 0.0, 'duration': 0.43478260869565216})
Event(time=0.43478260869565216, kwargs={'offset': 0.43478260869565216, 'duration': 0.43478260869565216})
Event(time=0.8695652173913043, kwargs={'offset': 0.8695652173913043, 'duration': 0.43478260869565216})
Event(time=1.3043478260869565, kwargs={'offset': 1.3043478260869565, 'duration': 0.43478260869565216})
def amen_chop(hits: int, speed: float = 1.0, seed: int | None = None) -> pq.Score:
    """A random re-sequencing of the break's 16 beats, one every amen_seconds_per_beat / speed seconds."""
    rng = np.random.default_rng(seed)
    step = amen_seconds_per_beat / speed
    beats = rng.integers(0, 16, size=hits)    # which beat of the break plays at each step
    print(beats)
    return pq.Score([(i * step, {"offset": float(b * amen_seconds_per_beat), "duration": float(step)})
                     for i, b in enumerate(beats)])


chop = amen_chop(64, speed=2.5, seed=1)
for event in chop[:4]:
    print(event)
pq.play(chop.render(amen_sample))
[ 7  8 12 15  0  2 13 15  3  4 13  6  4 13  4  6 10  8  1  0 13 12 13  8
 13  5  7 12  1  4  1  7 15  2  6  6 14  3  8  4  0 12  0  4  7  7  1 15
 11 15  1 11  4  8 14  4 11  2  5 15  6  8  4  1]
Event(time=0.0, kwargs={'offset': 3.043478260869565, 'duration': 0.17391304347826086})
Event(time=0.17391304347826086, kwargs={'offset': 3.4782608695652173, 'duration': 0.17391304347826086})
Event(time=0.34782608695652173, kwargs={'offset': 5.217391304347826, 'duration': 0.17391304347826086})
Event(time=0.5217391304347826, kwargs={'offset': 6.521739130434782, 'duration': 0.17391304347826086})

Try other values of hits, speed, and seed.

Example: ASCII drum machine#

Our second sequencer is a drum machine. Its interface is a grid of steps: each row is a drum sound, each column is a moment in time corresponding to a subdivision of a beat, and the musician switches individual cells on or off.

In the Python example below, we crudely mimic a typical drum machine interface with a few lines of text, where an X switches a cell on:

X-X-X-X-X-X-X-X-
----X-------X---
X-------X-------

Each column of our grid is a quarter of a beat (a sixteenth note, referring to sixteen of them being present in groupings of \(4\) beats). So the function ascii_to_score turns the grid into a pq.Score with an event at time \(\frac{j}{4}\) beats for every X in column \(j\), whose only parameter is the path to that row’s sound file. Our instrument, drum(path), simply loads and returns that sound. Finally, we render the score with a pq.BasicMetronome, which converts times in beats to times in seconds at a given tempo. At \(120\) BPM, each column lasts \(\frac{1}{4} \left[\frac{\text{beats}}{\text{column}}\right] \cdot \frac{60}{120} \left[\frac{\text{seconds}}{\text{beat}}\right] = 0.125 \left[\frac{\text{seconds}}{\text{column}}\right]\):

# hide
# no-output
import pyquist as pq
def drum(path: str) -> pq.Audio:
    """An instrument that plays the drum sound in the audio file at `path`."""
    return pq.Audio.from_file(path)


def ascii_to_score(ascii: str,
                   row_paths: tuple[str, ...] = ("./assets/hihat.wav",
                                                 "./assets/snare.wav",
                                                 "./assets/kick.wav")) -> pq.Score:
    """One row per drum, one column per step: an X plays that row's sound at that step."""
    rows = ascii.strip().splitlines()
    score = pq.Score()
    num_steps = max(len(row) for row in rows)
    for step in range(num_steps):
        for row, path in zip(rows, row_paths):
            if len(row) > step and row[step] == "X":
                score.append((step / 4, {"path": path}))
    return score


pattern = """
X-X-X-X-X-X-X-X-X-X-X-X-X-X-X-X-
----X-------X-------X-------X---
X-------X-------X-X-----X-------
"""                                           # rows: hi-hat, snare, kick

score = ascii_to_score(pattern)
for event in score[:8]:
    print(event)

# Each step is a sixteenth note: four steps per beat at 120 BPM
metronome = pq.BasicMetronome(bpm=120)
pq.play(score.render(drum, metronome), normalize=True)
Event(time=0.0, kwargs={'path': './assets/hihat.wav'})
Event(time=0.0, kwargs={'path': './assets/kick.wav'})
Event(time=0.5, kwargs={'path': './assets/hihat.wav'})
Event(time=1.0, kwargs={'path': './assets/hihat.wav'})
Event(time=1.0, kwargs={'path': './assets/snare.wav'})
Event(time=1.5, kwargs={'path': './assets/hihat.wav'})
Event(time=2.0, kwargs={'path': './assets/hihat.wav'})
Event(time=2.0, kwargs={'path': './assets/kick.wav'})

Try editing the pattern: move the snare drum, add more kick drums, or change the tempo.

Drum sounds from Freesound: hi-hat 399897 by TheFlakesMaster, License: CC0. Snare drum 46709 by boney-man, License: Attribution 3.0. Kick drum 673512 by TheEndOfACycle, License: CC0.