2.4 Pyquist: a thin computer music layer over NumPy

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

2.4 Pyquist: a thin computer music layer over NumPy#

For the rest of this book we will use a small library called pyquist that was created specifically for this book. It is not a high-level computer music framework like Nyquist or Max MSP: there are no built-in instruments, effects, or sequencers. Instead, Pyquist is a thin wrapper around NumPy that gives us:

  • A single pq.Audio class that bundles a sample array with its sample rate.

  • Convenient audio I/O: load and save audio files, play through your speakers, or record with your microphone.

    • Pyquist even supports audio playback in the browser! See the examples below.

  • Basic visualization: plot waveforms and spectra.

  • Some additional infrastructure (a musical pq.Score object, other helpers) that we’ll introduce later.

Everything Pyquist does, you could do yourself with NumPy plus other libraries like soundfile plus sounddevice. The point of Pyquist is to avoid continuously redefining those boilerplate pieces.

The Audio object#

The core of Pyquist is the pq.Audio class. A pq.Audio bundles a float32 array of samples (shape (num_samples, num_channels)) with a sample rate.

import numpy as np
import pyquist as pq

f_s = 44100
N = f_s
n = np.arange(N)
samples = 0.5 * np.sin(2 * np.pi * 440 * n / f_s)    # shape (N,)

audio = pq.Audio(samples, f_s)
pq.play(audio)   # play audio out of speakers
print(audio)
# Audio(num_samples=44100, num_channels=1, sample_rate=44100)
Audio(num_samples=44100, num_channels=1, sample_rate=44100)

A 1D input array is automatically reshaped to mono (N, 1). The two key attributes are:

  • audio.samples: the underlying NumPy array, shape (num_samples, num_channels).

  • audio.sample_rate: the sample rate in Hz.

Plus a few useful derived properties and helpers (audio.num_samples, audio.num_channels, audio.duration, audio.peak_amplitude), see the full documentation of pq.Audio for details.

Three takes on the same sine#

The same one-second 440 Hz sine, in three styles:

# 1. Plain Python loop (slow, verbose)
import math
samples_loop = [0.0] * N
for n in range(N):
    samples_loop[n] = 0.5 * math.sin(2 * math.pi * 440 * n / f_s)

# 2. Vectorized NumPy (fast, concise)
import numpy as np
n_arr = np.arange(N)
samples_np = 0.5 * np.sin(2 * np.pi * 440 * n_arr / f_s)

# 3. Pyquist Audio object (samples + sample rate together)
import pyquist as pq
audio = pq.Audio(samples_np, sample_rate=f_s)
audio.write("sine-440.wav")

All three describe the same signal, but each is a step up the abstraction ladder. The Pyquist version is the one we’ll use most: it carries the sample rate along with the samples, knows how to write itself to disk, and can be passed to pq.play(audio) or pq.plot(audio).

Mixing audio#

Adding two pq.Audio objects element-wise produces a new pq.Audio containing their sum:

# hide
import numpy as np
import pyquist as pq
f_s = 44100
n_arr = np.arange(f_s)
sine_c = pq.Audio(np.sin(2 * np.pi * 261.63 * n_arr / f_s), f_s)
sine_e = pq.Audio(np.sin(2 * np.pi * 329.63 * n_arr / f_s), f_s)
sine_g = pq.Audio(np.sin(2 * np.pi * 392.00 * n_arr / f_s), f_s)

chord = sine_c + sine_e + sine_g
pq.play(chord / 3)

Pyquist validates shapes and sample rates for you: adding two pq.Audio objects with different sample rates raises a clear error, instead of silently producing an unintended result.

An eagle-eyed reader will remember clipping. By default, the pq.play function will clip any samples outside of [-1, 1]. Hence, in the example above we manually normalize the chord via the line chord / 3 before playback (accounting for the max [-3, 3] range of adding three sinusoids in [-1, 1]). Alternatively, you can normalize with chord.normalize() or pq.play(..., normalize=True). Try out both above!

Scalar multiplication scales the amplitude, and addition or subtraction with plain NumPy arrays also works:

chord_norm = chord / 3.0         # manually normalized
inverted = -chord                # phase-inverted copy

Slicing vs segment#

You can index into a pq.Audio exactly like a NumPy array. The result is itself a new pq.Audio (carrying the same sample rate along), not a raw ndarray:

audio[1000:2000]        # Audio of shape (1000, num_channels)
audio[:, 0]             # Audio of shape (N, 1) - the first channel only
audio[1000:2000, 0]     # Audio of shape (1000, 1) - first channel, samples 1000-1999

Pyquist rejects index patterns that would collapse the sample axis to a scalar (e.g. audio[1000] or audio[1000, 0]), since the result would no longer have the canonical (num_samples, num_channels) layout. If you need to read the value of a single sample, use audio.samples[i, j]; if you need a length-1 pq.Audio, use audio[i:i+1].

pq.Audio.segment does the same kind of carving, but takes its arguments in seconds rather than samples:

first_half = chord.segment(duration=0.5)              # first 0.5 s
middle     = chord.segment(offset=0.25, duration=0.5) # the middle 0.5 s

The middle 0.5 s of the C major chord above, extracted via chord.segment(offset=0.25, duration=0.5).

The rule of thumb: use array indexing when you want to think in sample indices, use pq.Audio.segment when you want to think in seconds.