CONTRAPUNK

When the Studio Became the Instrument

Chapter contract

Prerequisites. Chapters 1 and 2, especially frequency, phase accumulation, heterodyne multiplication, and amplitude trajectories.

By the end of this chapter, you should be able to:

Four copies of one synthetic strike

Listen first to the Chapter 3 transformation study. Begin at a low level. Stop if the repeated transient becomes uncomfortable.

The first sound is a 250 ms project-authored synthetic strike. It is a teaching surrogate, not a field recording and not a historical studio recording. The next segment repeats the same fixed samples four times. Repetition creates a pulse and exposes details that a single event can hide.

A deterministic synthetic strike shown as one fixed 250 ms trace.

Figure provenance. Author-generated by assets/figures/src/ch03_studio.py at 48 kHz from sinusoids at 317, 701, and 1193 Hz with exponential decay constant 18 s⁻¹ and a 2 ms onset ramp.

Long description. Time runs from 0 to 250 milliseconds. A fast onset leads to an irregular oscillation whose amplitude decays toward zero. The plot is a deterministic synthetic teaching surrogate.

That observation asks a new question:

What changes when sound becomes a trace that can be repeated, cut, reordered, and played at another rate?

Schaeffer: fixation before tape

Pierre Schaeffer’s 1948 experiments at French radio used phonograph discs, including closed grooves. They did not begin on magnetic tape. Fixation and repetition separated a trace from the ordinary flow of its source and supported attention to the sound itself (INA grm n.d.a; IRCAM 1948). The word concrete describes beginning with fixed audible material rather than only an abstract score. It does not restrict the material to natural sounds.

Fixed sound becomes selected, transformed, spliced, mixed, and fixed again.

Diagram provenance. Author-generated Mermaid summary from INA-GRM’s account of fixation and support manipulation (INA grm n.d.a, n.d.b).

Long description. Recorded event flows to disc or tape fixation, selection and cutting, looping or reversal or rate change or filtering, splicing and mixing, then a fixed composition.

Tape later made cutting, splicing, loops, reversal, filtering, mixing, and repeated copying more practical. A loop is physical memory. A splice changes order. Reversal changes every local trajectory. Speed changes time and pitch together.

Cologne: compose a signal chain

The WDR studio founded in Cologne in 1951 became a foundational early purpose-built electronic studio. Its mature practice centered on generated material, but it should not be called the world’s first without qualification. Sources describe pure-tone, pulse, and noise generators, filters, ring modulators, tape machines, meters, amplifiers, and mixing controls (Westdeutscher Rundfunk n.d.).

Generated sources pass through shaping, transformation, mixing, tape, and editing.

Diagram provenance. Author-generated from WDR’s equipment and workflow account (Westdeutscher Rundfunk n.d.).

Long description. Pure tone, pulse, or noise enters a gate or envelope, filter or ring modulator, mixer, and tape recorder. Cut, loop, reverse, or speed operations return edited tape to the mixer.

The useful model is a patched production chain, not a modern all-in-one synthesizer and not a DAW. Humans set generators, routed boxes, measured levels, recorded results, and assembled tape.

Mathematical concepts 3: source, shape, filter, and modulation

A generated sampled source

Let nn be sample index, AA peak amplitude, ff frequency in hertz, fsf_s sample rate in samples per second, and ϕ\phi phase in radians:

x[n]=Asin(2πfnfs+ϕ).(3.1) x[n]=A\sin\!\left(2\pi f\frac{n}{f_s}+\phi\right). \qquad\text{(3.1)}

Equation 3.1 sampled with A 0.8, f 500 Hz, sample rate 8000 Hz, and phase π/6.

Long description. Thirty-two stems show two cycles of the sampled sinusoid. The first sample begins above zero because phase is π/6. Eight samples represent each 500 Hz cycle at 8000 samples per second.

Tape speed couples pitch and duration

Let playback rate ratio rr compare new speed with recorded speed. Let ff be an original frequency in hertz, TT an original duration in seconds, and Δp\Delta p pitch displacement in semitones:

f=rf,T=Tr,Δp=12log2(r).(3.2) f'=rf,\qquad T'=\frac{T}{r},\qquad \Delta p=12\log_2(r). \qquad\text{(3.2)}

Equation 3.2 plotted for rates 0.5 through 2, an original 440 Hz tone, and an original two-second duration.

Long description. The left panel shows frequency rising with rate and duration falling reciprocally. The right panel shows pitch shift from minus twelve semitones at half speed through zero at normal speed to plus twelve at double speed.

Half speed produces half the frequency and twice the duration. Double speed produces twice the frequency and half the duration. Historical tape speed could not preserve one while changing the other (INA grm n.d.b).

The same synthetic trace shown at half, normal, and double playback rates.

Long description. Three panels show the same samples read at rates 0.5, 1, and 2. Half speed lasts 500 ms and lowers pitch by an octave. Normal speed lasts 250 ms. Double speed lasts 125 ms and raises pitch by an octave.

Montage is indexed selection and order

A fixed trace is a sequence of samples. Let xi[ai:bi]x_i[a_i{:}b_i] select samples from start index aia_i up to end index bib_i in source ii. The concatenation symbol \Vert joins selected slices:

y=x1[a1:b1]x2[a2:b2]xK[aK:bK].(3.3) y=x_1[a_1{:}b_1]\Vert x_2[a_2{:}b_2]\Vert\cdots\Vert x_K[a_K{:}b_K]. \qquad\text{(3.3)}

Equation 3.3 shown as three selected slices concatenated into one output timeline.

Long description. Three colored blocks labeled slice 1, slice 2, and slice 3 occupy consecutive output sample ranges. Their source identities and lengths remain visible after their order becomes one timeline.

A hard cut can click when adjacent boundary samples disagree. A short crossfade replaces that jump with complementary gains. This is a repair for the splice, not a claim that every historical edit used the same fade.

A decaying trajectory beside its sample-reversed form.

Long description. The forward curve starts high and decays. The reversed curve approaches from low amplitude and ends high. Sample reversal reverses the whole trajectory, not only note order.

Amplitude shaping

Let e[n]e[n] be a unitless envelope between zero and one:

y[n]=e[n]x[n],0e[n]1.(3.4) y[n]=e[n]x[n],\qquad 0\le e[n]\le1. \qquad\text{(3.4)}

Equation 3.4 with a 20-sample attack and 25-sample release applied to a sinusoid.

Long description. A gray sinusoid continues at constant amplitude. A teal envelope rises, holds, and falls. The red output follows the sinusoid inside that boundary and reaches zero at both endpoints.

An envelope can create onset, sustain, accent, and release. Do not project one modern ADSR circuit onto every historical studio. The common idea is multiplication by a changing control.

A finite linear filter

Let h[k]h[k] contain MM filter coefficients. Each output sample is a weighted sum of the current and delayed input samples:

y[n]=k=0M1h[k]x[nk].(3.5) y[n]=\sum_{k=0}^{M-1}h[k]x[n-k]. \qquad\text{(3.5)}

Equation 3.5 showing the impulse response of h equals 0.25, 0.5, 0.25.

Long description. An input impulse produces three output stems with amplitudes 0.25, 0.5, and 0.25. Later samples are zero. The response exposes the exact three weighted delays.

This three-point average reduces rapid sample-to-sample change. Historical studio filters could be much more selective. The small filter keeps the arithmetic visible.

Ring modulation

Multiplication of carrier frequency fcf_c and modulator frequency fmf_m creates sum and difference components:

cos(2πfct)cos(2πfmt)=12cos2π(fcfm)t+12cos2π(fc+fm)t.(3.6) \cos(2\pi f_ct)\cos(2\pi f_mt)=\tfrac12\cos 2\pi(f_c-f_m)t+\tfrac12\cos 2\pi(f_c+f_m)t. \qquad\text{(3.6)}

Equation 3.6 for a 440 Hz carrier and 110 Hz modulator, producing 330 and 550 Hz components.

Long description. Two equal spectrum stems appear at 330 and 550 Hz. No stem remains at the original 440 or 110 Hz in the ideal multiplication model.

This extends Chapter 2’s heterodyne identity. The studio uses the same mathematics as a timbre transformation.

Mathematical practice 3

  1. A 440 Hz, two-second tape plays at r=0.5r=0.5. Find frequency, duration, and semitone shift.
  2. A 12,000-sample clip at 48 kHz is played at r=2r=2. Find output samples and duration.
  3. Slices contain 4000, 2500, and 6000 samples. Find concatenated length before crossfades.
  4. For h=[0.25,0.5,0.25]h=[0.25,0.5,0.25] and input [1,0,0][1,0,0], calculate the first three output samples.
  5. Multiply 700 and 130 Hz cosines. Name the two output frequencies.
  6. Explain why sample reversal changes an attack differently from reversing event order.
  7. For every Equation 3.1 through 3.6 visual, identify axes, fixed parameters, and one visible prediction.

RCA Mark II: store parameter choices

Olson and Belar’s system read coded paper that selected tone, amplitude, spectrum, rise, duration, decay, vibrato, portamento, and changes during an event. The punched control was discrete and programmatic. The audio path remained electronic and analog (Olson and Belar 1958, 1955). The Mark II was built in the late 1950s and installed at the Columbia-Princeton center around 1959. Institutional sources attach different 1957 to 1959 dates to construction, funding, and installation, so false precision would mislead (Columbia University Computer Music Center n.d.; Columbia University Libraries n.d.).

The RCA Mark II Sound Synthesizer at Columbia University.

Image credit and licence. Finnianhughes101, own photograph, CC0 1.0 via Wikimedia Commons (Finnianhughes101 2024).

Long description. A large room-sized electronic system fills racks along a wall. Panels contain controls, meters, patch points, and paper-control machinery. The scale makes clear that stored instructions controlled an analog production system rather than software audio.

Stored parameter choices control oscillators, envelopes, spectrum, and recording.

Diagram provenance. Author-generated from Olson and Belar’s patent and paper (Olson and Belar 1958, 1955).

Long description. Composer parameter plan leads to punched-paper code and a reader. The reader controls oscillators plus envelope and spectrum stages, whose result is recorded.

Do not describe the machine as understanding music. People encoded choices. The reader executed them. The output was recorded.

Song study 3: a project-authored paper-roll étude

Five stored event lanes beside their integer sample starts and durations.

Figure provenance. Author-generated from the same five-event schedule stored in examples/wavetable-synthesis/data/ch03-paper-roll.csv.

Long description. Five horizontal lanes show events beginning at exact sample positions. Each block states its oscillator frequency. Some events meet while others overlap, so the final render depends on both event parameters and schedule.

The study uses five original events. No melody or historical recording is copied. Read the roll before listening:

  1. predict the total duration from the final start plus duration;
  2. identify the two overlapping boundaries;
  3. mark which events use ring modulation;
  4. compare the forward render with its reversed splice;
  5. describe which information belongs to source, transformation, and schedule.

LAB 03

Read one trace like tape

Change rate, repetition, and direction. The source remains a deterministic synthetic teaching surrogate.

Pitch ratio: 1×Trace duration: 250 ms

A text description follows.

Four forward repeats at normal rate.

Ready

Try these checks

  1. Choose half speed. Predict pitch and duration before reading the values.
  2. Reverse one trace, then eight. Listen for internal direction versus event order.
  3. Change repeats without changing rate. Name what stays invariant.
Read the exact source running this lab

This TypeScript implements the synthetic trace and Equation 3.2.

export const SAMPLE_RATE = 48_000;
const TAU = 2 * Math.PI;

export function tapeRate(originalHz: number, originalSeconds: number, rate: number) {
  if (!Number.isFinite(rate) || rate <= 0) throw new RangeError('Rate must be positive');
  return { frequencyHz: originalHz * rate, durationSeconds: originalSeconds / rate, semitones: 12 * Math.log2(rate) };
}

export function renderSyntheticStrike(repeats = 1, rate = 1, reverse = false) {
  const sourceFrames = Math.round(0.25 * SAMPLE_RATE);
  const readFrames = Math.floor((sourceFrames - 1) / rate) + 1;
  const output = new Float32Array(readFrames * repeats);
  for (let repeat = 0; repeat < repeats; repeat++) for (let frame = 0; frame < readFrames; frame++) {
    const read = reverse ? sourceFrames - 1 - frame * rate : frame * rate;
    const position = Math.max(0, Math.min(sourceFrames - 1, read));
    const time = position / SAMPLE_RATE;
    const strike = (Math.sin(TAU * 317 * time) + .55 * Math.sin(TAU * 701 * time) + .25 * Math.sin(TAU * 1193 * time)) * Math.exp(-18 * time);
    const onset = Math.min(1, time / .002);
    output[repeat * readFrames + frame] = .2 * onset * strike / 1.8;
  }
  return output;
}

export function ringModulationComponents(carrierHz: number, modulatorHz: number) {
  return { differenceHz: Math.abs(carrierHz - modulatorHz), sumHz: carrierHz + modulatorHz };
}

Rust lab 3: make edits and stored control audible

The cumulative Rust program reuses Chapter 1’s oscillator and WAV writer and Chapter 2’s multiplication model. It adds only offline rate conversion, envelope shaping, a bounded crossfade, one small FIR, ring modulation, and integer-sample event scheduling.

  1. Run the Chapter 3 tests and renderer.
  2. Verify half and double rate lengths by hand.
  3. Trace the first three FIR outputs for an impulse.
  4. Change one CSV start time and predict total length.
  5. Remove one ring-modulation flag and predict which sidebands disappear.
  6. Change crossfade length and inspect the splice boundary.

Fault station 3

A student writes:

“Musique concrète began when tape was invented in 1948. Half-speed changes pitch but preserves duration. Cologne’s studio was a DAW. An RCA paper roll contained digital audio.”

Correct every claim. Your answer must distinguish disc from tape, rate coupling, manual signal chains, stored control, and analog audio.

Chapter 3 readiness gate

  1. Why can fixation and repetition change how an event is heard?
  2. What happens to frequency and duration at double speed?
  3. What produces a splice click, and what does a crossfade change?
  4. How do envelope multiplication and FIR filtering differ?
  5. Which frequencies result from ring-modulating 440 Hz by 110 Hz?
  6. Contrast Schaeffer, Cologne, and RCA by material and control.

Chapter 3 invariants

Chapter 3 glossary additions

Term Working definition
Automation Stored or driven change of parameters over time.
Envelope A control trajectory multiplied with a signal’s amplitude.
Fixed trace Sound preserved on a support so it can be replayed and edited.
Impulse response Output produced by a unit impulse, exposing a linear filter’s coefficients.
Loop A fixed segment repeated cyclically.
Montage Selection and ordering of fixed segments into a new timeline.
Musique concrète Practice beginning with fixed audible material and transformations of its trace.
Punched-paper control Coded holes that select stored event and sound parameters.
Ring modulation Multiplication that creates sum and difference spectral components.
Splice Boundary joining two fixed segments.

IMPLEMENTATION NOTEBOOK

Chapter 3 source and generated output

Each complete source appears beside the output it generated. Hashes are recorded in the implementation manifest.

SOURCE AND OUTPUT

Synthetic trace, tape transforms, figures, and audio

Project-authored surrogate only. No historical audio is embedded.

Output

Half, normal, and double-rate traces
Deterministic rate comparison.

Source

ch03_studio.py

assets/figures/src/ch03_studio.pyPython

#!/usr/bin/env python3
"""Generate Chapter 3 figures and synthetic teaching-surrogate audio."""
from pathlib import Path
import wave
import numpy as np
import matplotlib.pyplot as plt

ROOT = Path(__file__).resolve().parents[3]
FIG = ROOT / "assets/figures/svg"
AUDIO = ROOT / "assets/audio/ch03"
FIG.mkdir(parents=True, exist_ok=True)
AUDIO.mkdir(parents=True, exist_ok=True)
FS = 48_000


def save(name):
    plt.tight_layout(); plt.savefig(FIG / name, format="svg", metadata={"Date": None}); plt.close()


def synthetic_strike(seconds=.25):
    t=np.arange(round(seconds*FS))/FS
    # Project-authored deterministic surrogate, not a historical or field recording.
    x=(np.sin(2*np.pi*317*t)+.55*np.sin(2*np.pi*701*t)+.25*np.sin(2*np.pi*1193*t))*np.exp(-18*t)
    x*=np.minimum(1,t/.002)
    return .65*x/np.max(np.abs(x))

x=synthetic_strike(); t=np.arange(len(x))/FS*1000
fig,ax=plt.subplots(figsize=(9,4)); ax.plot(t,x,color="#7f1d1d"); ax.set(xlabel="Time (ms)",ylabel="Amplitude",title="Synthetic strike teaching surrogate: one fixed trace"); ax.grid(alpha=.25); save("ch03-loop-to-object.svg")

fig,axes=plt.subplots(3,1,figsize=(9,7))
for ax,r in zip(axes,[.5,1,2]):
    idx=np.minimum((np.arange(max(1,int(len(x)/r)))*r).astype(int),len(x)-1)
    y=x[idx]; ax.plot(np.arange(len(y))/FS*1000,y,color="#0f6f70"); ax.set_title(f"rate r={r:g}: duration {len(y)/FS*1000:.0f} ms, pitch ratio {r:g}"); ax.set_ylabel("Amplitude")
axes[-1].set_xlabel("Time (ms)"); save("ch03-rate-triptych.svg")

env=np.exp(-8*np.arange(FS//2)/FS); rev=env[::-1]
fig,ax=plt.subplots(figsize=(9,4)); ax.plot(env,label="decay",color="#7f1d1d"); ax.plot(rev,label="reversed",color="#0f6f70"); ax.set(xlabel="Sample",ylabel="Amplitude",title="Reversal turns decay into approach"); ax.legend(); ax.grid(alpha=.25); save("ch03-reverse-envelope.svg")

events=[(0,24000,220),(24000,12000,330),(36000,24000,247),(60000,12000,440),(72000,36000,294)]
fig,ax=plt.subplots(figsize=(9,4));
for i,(start,duration,f) in enumerate(events): ax.broken_barh([(start/FS,duration/FS)],(i-.35,.7),facecolors="#0f6f70"); ax.text((start+duration/2)/FS,i,str(f)+" Hz",ha="center",va="center",color="white",fontsize=8)
ax.set(xlabel="Time (s)",ylabel="Event lane",yticks=range(5),title="Stored event controls: integer sample starts and durations"); save("ch03-paper-roll.svg")

# Concatenated audible comparison: single, four repeats, reverse, half and double rate.
clips=[x,np.tile(x,4),x[::-1]]
for r in [.5,2]:
    pos=np.arange(max(1,int(len(x)/r)))*r; left=np.floor(pos).astype(int); right=np.minimum(left+1,len(x)-1); frac=pos-left; clips.append(x[left]*(1-frac)+x[right]*frac)
silence=np.zeros(round(.25*FS)); out=np.concatenate([v for pair in zip(clips,[silence]*len(clips)) for v in pair])
with wave.open(str(AUDIO/"ch03-studio-transformations.wav"),"wb") as w:
    w.setparams((1,2,FS,len(out),"NONE","not compressed")); w.writeframes((np.clip(out,-1,1)*32767).astype("<i2").tobytes())
print("Generated Chapter 3 studio figures and synthetic-surrogate audio.")

SOURCE AND OUTPUT

Six equation visuals

One chapter-specific generator produces Equations 3.1 through 3.6.

Output

Source

formula_visuals_ch03.py

assets/figures/src/formula_visuals_ch03.pyPython

#!/usr/bin/env python3
"""Generate deterministic visuals for Chapter 3 equations."""
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
ROOT=Path(__file__).resolve().parents[3]; OUT=ROOT/"assets/figures/svg"; OUT.mkdir(parents=True,exist_ok=True)
RED="#7f1d1d"; TEAL="#0f6f70"
def save(name): plt.tight_layout(); plt.savefig(OUT/name,format="svg",metadata={"Date":None}); plt.close()
# 3.1 sampled source
fs=8000; f=500; n=np.arange(32); x=.8*np.sin(2*np.pi*f*n/fs+np.pi/6)
plt.figure(figsize=(9,4)); plt.stem(n,x,linefmt=RED,markerfmt="o",basefmt="k-"); plt.title("Eq. 3.1: A=.8, f=500 Hz, fs=8000 Hz, phase=π/6"); plt.xlabel("Sample n"); plt.ylabel("x[n]"); save("eq-3-1-sampled-source.svg")
# 3.2 rate law
r=np.linspace(.5,2,200); fig,ax=plt.subplots(1,2,figsize=(9,4)); ax[0].plot(r,r*440,color=RED); ax[0].plot(r,2/r,color=TEAL); ax[0].set(xlabel="Rate r",title="Frequency (Hz) and duration (s)"); ax[1].plot(r,12*np.log2(r),color=RED); ax[1].set(xlabel="Rate r",ylabel="Semitones",title="Pitch shift"); save("eq-3-2-tape-rate.svg")
# 3.3 montage
fig,ax=plt.subplots(figsize=(9,3)); colors=[RED,TEAL,"#b07d21"]; start=0
for i,(length,c) in enumerate(zip([4,3,5],colors),1): ax.broken_barh([(start,length)],(.25,.5),facecolor=c); ax.text(start+length/2,.5,f"slice {i}",ha="center",va="center",color="white"); start+=length
ax.set(xlim=(0,12),ylim=(0,1),yticks=[],xlabel="Output sample index",title="Eq. 3.3: selected slices concatenate in declared order"); save("eq-3-3-montage.svg")
# 3.4 envelope
n=np.arange(100); e=np.minimum(np.minimum(1,n/20),(99-n)/25).clip(0,1); x=np.sin(2*np.pi*n/20)
fig,ax=plt.subplots(figsize=(9,4)); ax.plot(n,x,color="#aaa",label="x[n]"); ax.plot(n,e*x,color=RED,label="e[n]x[n]"); ax.plot(n,e,color=TEAL,label="e[n]"); ax.legend(); ax.set(xlabel="Sample n",ylabel="Amplitude",title="Eq. 3.4: 20-sample attack, 25-sample release"); save("eq-3-4-envelope.svg")
# 3.5 FIR impulse
h=np.array([.25,.5,.25]); inp=np.r_[1.,np.zeros(7)]; out=np.convolve(inp,h)[:len(inp)]; fig,ax=plt.subplots(figsize=(9,4)); ax.stem(range(len(out)),out,linefmt=RED,markerfmt="o",basefmt="k-"); ax.set(xlabel="Sample n",ylabel="y[n]",title="Eq. 3.5: impulse through h=[.25,.5,.25]"); save("eq-3-5-fir.svg")
# 3.6 ring modulation
fig,ax=plt.subplots(figsize=(9,4)); ax.stem([330,550],[.5,.5],linefmt=RED,markerfmt="o",basefmt="k-"); ax.set(xlim=(250,630),xlabel="Frequency (Hz)",ylabel="Relative amplitude",title="Eq. 3.6: 440 Hz × 110 Hz gives 330 and 550 Hz"); save("eq-3-6-ring-modulation.svg")
print("Generated Chapter 3 formula visuals.")

SOURCE AND OUTPUT

Historical signal chains

Three tested Mermaid sources produce the diagrams shown here.

Output

Source

ch03-concrete-montage.mmd

assets/diagrams/src/ch03-concrete-montage.mmdMermaid

flowchart LR
  A["Recorded event"] --> B["Disc or tape<br/>fixed trace"]
  B --> C["Select and cut"]
  C --> D["Loop, reverse,<br/>rate, filter"]
  D --> E["Splice and mix"]
  E --> F["Fixed composition"]
ch03-cologne-studio-chain.mmd

assets/diagrams/src/ch03-cologne-studio-chain.mmdMermaid

flowchart LR
  A["Pure tone, pulse,<br/>or noise generator"] --> B["Gate or envelope"]
  B --> C["Filter or<br/>ring modulator"]
  C --> D["Mixer"]
  D --> E["Tape record"]
  E --> F["Cut, loop, reverse,<br/>or change speed"]
  F --> D
ch03-rca-punched-control.mmd

assets/diagrams/src/ch03-rca-punched-control.mmdMermaid

flowchart LR
  A["Composer parameter plan"] --> B["Punched-paper code"]
  B --> C["Reader and<br/>control selection"]
  C --> D["Oscillators"]
  C --> E["Envelope and<br/>spectrum controls"]
  D --> E
  E --> F["Recorded output"]

SOURCE AND OUTPUT

Cumulative Rust paper-roll renderer

The renderer starts from the merged Chapter 1 and 2 crate and adds only offline studio operations.

Output

events=5, frames=239520, peak=0.749787
wrote ch03-rust-paper-roll-study.wav

Source

Cargo.toml

assets/rust/ch03/Cargo.tomlTOML

[package]
name = "wavetable-synthesis-exercises"
version = "0.1.0"
edition = "2021"
publish = false

[dependencies]
hound = "3.5"
ch03-paper-roll.csv

assets/rust/ch03/data/ch03-paper-roll.csvCSV

start_frames,duration_frames,frequency_hz,level,attack_frames,release_frames,filter,modulation_hz
0,24000,220,0.70,480,1440,dark,0
24000,12000,330,0.55,240,960,bright,0
36000,24000,247,0.75,480,1440,dark,73
60000,12000,440,0.50,240,960,bright,0
72000,36000,294,0.72,480,1920,dark,91
lib.rs

assets/rust/ch03/src/lib.rsRust

//! Small cumulative DSP helpers for Chapters 1–3 of the wavetable workbook.
//!
//! The examples favor visible mathematics over production abstractions. They
//! write offline WAV files; they are not an audio-callback implementation.

use std::path::Path;

pub const SAMPLE_RATE: u32 = 48_000;

/// Equal-tempered MIDI note to frequency. Kept here so the workbook bundle is
/// runnable on its own; production Contrapunk code uses `contrapunk_dsp::pitch`.
pub fn midi_to_freq(note: u8) -> f32 {
    440.0 * 2.0_f32.powf((note as f32 - 69.0) / 12.0)
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Note {
    pub midi: u8,
    pub beats: f32,
}

impl Note {
    pub const fn new(midi: u8, beats: f32) -> Self {
        Self { midi, beats }
    }
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Connection {
    Detached { gate: f32 },
    Glide { final_portion: f32 },
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PhraseStyle {
    pub connection: Connection,
    pub accent_note: Option<usize>,
    pub final_vibrato_cents: f32,
}

impl PhraseStyle {
    pub const fn detached(gate: f32) -> Self {
        Self {
            connection: Connection::Detached { gate },
            accent_note: None,
            final_vibrato_cents: 0.0,
        }
    }

    pub const fn legato(final_portion: f32) -> Self {
        Self {
            connection: Connection::Glide { final_portion },
            accent_note: None,
            final_vibrato_cents: 0.0,
        }
    }
}

#[derive(Clone, Copy, Debug, Default)]
pub struct SineOscillator {
    phase: f32,
}

impl SineOscillator {
    pub const fn new() -> Self {
        Self { phase: 0.0 }
    }

    /// Advance phase once. Passing a new frequency each sample correctly
    /// integrates a glide or vibrato trajectory.
    pub fn tick(&mut self, frequency_hz: f32, sample_rate: f32) -> f32 {
        let sample = self.phase.sin();
        self.phase = (self.phase + std::f32::consts::TAU * frequency_hz / sample_rate)
            .rem_euclid(std::f32::consts::TAU);
        sample
    }
}

pub fn period_seconds(frequency_hz: f32) -> Option<f32> {
    (frequency_hz.is_finite() && frequency_hz > 0.0).then_some(1.0 / frequency_hz)
}

pub fn harmonic_frequency(fundamental_hz: f32, harmonic: usize) -> Option<f32> {
    (fundamental_hz.is_finite() && fundamental_hz > 0.0 && harmonic > 0)
        .then_some(fundamental_hz * harmonic as f32)
}

pub fn heterodyne_components(a_hz: f32, b_hz: f32) -> (f32, f32) {
    ((a_hz - b_hz).abs(), a_hz + b_hz)
}

pub fn cents_ratio(cents: f32) -> f32 {
    2.0_f32.powf(cents / 1_200.0)
}

pub fn log_frequency_lerp(start_hz: f32, end_hz: f32, t: f32) -> f32 {
    start_hz * (end_hz / start_hz).powf(t.clamp(0.0, 1.0))
}

/// Sum one oscillator per harmonic, skip components at or above Nyquist, and
/// normalize coefficient energy so recipes have comparable steady-state RMS.
pub fn additive_sample(
    oscillators: &mut [SineOscillator],
    amplitudes: &[f32],
    fundamental_hz: f32,
    sample_rate: f32,
) -> f32 {
    let mut sample = 0.0;
    let mut coefficient_energy = 0.0;

    for (index, (oscillator, amplitude)) in oscillators
        .iter_mut()
        .zip(amplitudes.iter().copied())
        .enumerate()
    {
        let frequency_hz = fundamental_hz * (index + 1) as f32;
        let component = oscillator.tick(frequency_hz, sample_rate);
        if frequency_hz < sample_rate / 2.0 {
            sample += amplitude * component;
            coefficient_energy += amplitude * amplitude;
        }
    }

    if coefficient_energy > 0.0 {
        sample / coefficient_energy.sqrt()
    } else {
        0.0
    }
}

pub fn render_phrase(notes: &[Note], amplitudes: &[f32], bpm: f32, style: PhraseStyle) -> Vec<f32> {
    let sample_rate = SAMPLE_RATE as f32;
    let seconds_per_beat = 60.0 / bpm;
    let mut oscillators = vec![SineOscillator::new(); amplitudes.len()];
    let mut output = Vec::new();

    for (note_index, note) in notes.iter().enumerate() {
        let frames = (note.beats * seconds_per_beat * sample_rate).round() as usize;
        let current_hz = midi_to_freq(note.midi);
        let next_hz = notes
            .get(note_index + 1)
            .map_or(current_hz, |next| midi_to_freq(next.midi));

        for frame in 0..frames {
            let position = frame as f32 / frames.max(1) as f32;
            let (frequency_hz, envelope) = match style.connection {
                Connection::Detached { gate } => {
                    let gate = gate.clamp(0.05, 1.0);
                    let active = (frames as f32 * gate) as usize;
                    let fade = (sample_rate * 0.005).min(active as f32 / 2.0) as usize;
                    let envelope = if frame >= active {
                        0.0
                    } else if frame < fade {
                        frame as f32 / fade.max(1) as f32
                    } else if frame + fade >= active {
                        (active - frame) as f32 / fade.max(1) as f32
                    } else {
                        1.0
                    };
                    (current_hz, envelope)
                }
                Connection::Glide { final_portion } => {
                    let portion = final_portion.clamp(0.01, 1.0);
                    let glide_start = 1.0 - portion;
                    let glide_t = ((position - glide_start) / portion).clamp(0.0, 1.0);
                    let mut envelope = 1.0;
                    let edge = (sample_rate * 0.005) as usize;
                    if note_index == 0 && frame < edge {
                        envelope *= frame as f32 / edge as f32;
                    }
                    if note_index + 1 == notes.len() && frame + edge >= frames {
                        envelope *= (frames - frame) as f32 / edge as f32;
                    }
                    (log_frequency_lerp(current_hz, next_hz, glide_t), envelope)
                }
            };

            let vibrato = if note_index + 1 == notes.len() && style.final_vibrato_cents != 0.0 {
                let time = output.len() as f32 / sample_rate;
                cents_ratio(style.final_vibrato_cents * (std::f32::consts::TAU * 5.0 * time).sin())
            } else {
                1.0
            };
            let accent = if style.accent_note == Some(note_index) {
                1.25
            } else {
                1.0
            };
            output.push(
                envelope
                    * accent
                    * additive_sample(
                        &mut oscillators,
                        amplitudes,
                        frequency_hz * vibrato,
                        sample_rate,
                    ),
            );
        }
    }

    output
}

/// Change playback rate with linear interpolation. Like tape speed, this
/// couples pitch and duration; it is not independent time stretching.
pub fn resample_linear(samples: &[f32], rate: f32) -> Option<Vec<f32>> {
    if samples.is_empty() || !rate.is_finite() || rate <= 0.0 {
        return None;
    }
    let length = ((samples.len() - 1) as f32 / rate).floor() as usize + 1;
    Some(
        (0..length)
            .map(|index| {
                let position = index as f32 * rate;
                let left = position.floor() as usize;
                let right = (left + 1).min(samples.len() - 1);
                let fraction = position - left as f32;
                samples[left] + fraction * (samples[right] - samples[left])
            })
            .collect(),
    )
}

pub fn apply_envelope(samples: &mut [f32], attack_frames: usize, release_frames: usize) {
    let length = samples.len();
    for (index, sample) in samples.iter_mut().enumerate() {
        let attack = if attack_frames == 0 {
            1.0
        } else {
            index as f32 / attack_frames as f32
        };
        let release = if release_frames == 0 {
            1.0
        } else {
            (length - 1 - index) as f32 / release_frames as f32
        };
        *sample *= attack.min(release).clamp(0.0, 1.0);
    }
}

/// Join two clips with complementary linear gains across the overlap.
pub fn splice_crossfade(left: &[f32], right: &[f32], overlap: usize) -> Vec<f32> {
    let overlap = overlap.min(left.len()).min(right.len());
    let mut output = left[..left.len() - overlap].to_vec();
    for index in 0..overlap {
        let right_gain = (index + 1) as f32 / (overlap + 1) as f32;
        output.push(
            left[left.len() - overlap + index] * (1.0 - right_gain) + right[index] * right_gain,
        );
    }
    output.extend_from_slice(&right[overlap..]);
    output
}

pub fn fir_filter(samples: &[f32], coefficients: &[f32]) -> Vec<f32> {
    (0..samples.len())
        .map(|index| {
            coefficients
                .iter()
                .enumerate()
                .take(index + 1)
                .map(|(delay, coefficient)| coefficient * samples[index - delay])
                .sum()
        })
        .collect()
}

pub fn ring_modulate(samples: &mut [f32], frequency_hz: f32) {
    let mut oscillator = SineOscillator::new();
    for sample in samples {
        *sample *= oscillator.tick(frequency_hz, SAMPLE_RATE as f32);
    }
}

pub fn append_silence(samples: &mut Vec<f32>, seconds: f32) {
    samples.resize(
        samples.len() + (seconds * SAMPLE_RATE as f32).round() as usize,
        0.0,
    );
}

pub fn write_wav(path: impl AsRef<Path>, samples: &[f32]) -> Result<(), hound::Error> {
    let spec = hound::WavSpec {
        channels: 1,
        sample_rate: SAMPLE_RATE,
        bits_per_sample: 16,
        sample_format: hound::SampleFormat::Int,
    };
    let mut writer = hound::WavWriter::create(path, spec)?;
    for sample in samples {
        writer.write_sample(((sample * 0.25).clamp(-1.0, 1.0) * i16::MAX as f32) as i16)?;
    }
    writer.finalize()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn chapter_one_math_is_executable() {
        assert_eq!(midi_to_freq(69), 440.0);
        assert_eq!(period_seconds(250.0), Some(0.004));
        assert_eq!(harmonic_frequency(110.0, 5), Some(550.0));
        assert_eq!(harmonic_frequency(110.0, 0), None);
    }

    #[test]
    fn oscillator_stays_bounded_during_retuning() {
        let mut oscillator = SineOscillator::new();
        for frequency_hz in 220..880 {
            assert!(
                oscillator
                    .tick(frequency_hz as f32, SAMPLE_RATE as f32)
                    .abs()
                    <= 1.0
            );
        }
    }

    #[test]
    fn nyquist_components_are_silent_and_recipes_are_rms_matched() {
        let mut nyquist_oscillators = [SineOscillator::new(); 2];
        for _ in 0..128 {
            assert_eq!(
                additive_sample(
                    &mut nyquist_oscillators,
                    &[0.0, 1.0],
                    12_000.0,
                    SAMPLE_RATE as f32,
                ),
                0.0
            );
        }

        fn recipe_rms(amplitudes: &[f32]) -> f32 {
            let mut oscillators = vec![SineOscillator::new(); amplitudes.len()];
            let square_sum: f32 = (0..SAMPLE_RATE)
                .map(|_| {
                    additive_sample(&mut oscillators, amplitudes, 220.0, SAMPLE_RATE as f32).powi(2)
                })
                .sum();
            (square_sum / SAMPLE_RATE as f32).sqrt()
        }

        assert!((recipe_rms(&[1.0]) - recipe_rms(&[1.0, 0.5, 0.25, 0.125])).abs() < 1.0e-4);
    }

    #[test]
    fn wav_writer_preserves_accent_headroom_and_limits_extremes(
    ) -> Result<(), Box<dyn std::error::Error>> {
        let path = std::env::temp_dir().join(format!(
            "wavetable-synthesis-exercises-{}.wav",
            std::process::id()
        ));
        write_wav(&path, &[1.25, -1.25, 4.0])?;
        let samples: Vec<i16> = hound::WavReader::open(&path)?
            .into_samples::<i16>()
            .collect::<Result<_, _>>()?;
        std::fs::remove_file(path)?;

        let accented = (1.25 * 0.25 * i16::MAX as f32) as i16;
        assert_eq!(samples, vec![accented, -accented, i16::MAX]);
        Ok(())
    }

    #[test]
    fn chapter_two_math_is_executable() {
        assert_eq!(
            heterodyne_components(260_000.0, 259_560.0),
            (440.0, 519_560.0)
        );
        assert!((cents_ratio(1_200.0) - 2.0).abs() < 1.0e-6);
        assert!((log_frequency_lerp(220.0, 880.0, 0.5) - 440.0).abs() < 1.0e-3);
    }

    #[test]
    fn chapter_three_tape_operations_are_deterministic() {
        assert!(resample_linear(&[0.0, 1.0], 0.0).is_none());
        assert_eq!(
            resample_linear(&[0.0, 1.0, 0.0], 0.5).unwrap(),
            vec![0.0, 0.5, 1.0, 0.5, 0.0]
        );
        assert_eq!(
            resample_linear(&[0.0, 1.0, 0.0], 2.0).unwrap(),
            vec![0.0, 0.0]
        );

        let mut shaped = vec![1.0; 5];
        apply_envelope(&mut shaped, 2, 2);
        assert_eq!(shaped, vec![0.0, 0.5, 1.0, 0.5, 0.0]);

        let spliced = splice_crossfade(&[0.0, 1.0, 1.0], &[0.0, 0.0, 1.0], 2);
        assert_eq!(spliced.len(), 4);
        assert!(spliced.iter().all(|sample| sample.abs() <= 1.0));
        assert_eq!(
            fir_filter(&[1.0, 0.0, 0.0], &[0.5, 0.25]),
            vec![0.5, 0.25, 0.0]
        );
    }

    #[test]
    fn ring_modulation_creates_sum_and_difference_components() {
        let length = SAMPLE_RATE as usize;
        let mut source: Vec<f32> = (0..length)
            .map(|frame| (std::f32::consts::TAU * 440.0 * frame as f32 / SAMPLE_RATE as f32).sin())
            .collect();
        ring_modulate(&mut source, 110.0);
        let correlate = |frequency_hz: f32| {
            source
                .iter()
                .enumerate()
                .map(|(frame, sample)| {
                    sample
                        * (std::f32::consts::TAU * frequency_hz * frame as f32 / SAMPLE_RATE as f32)
                            .cos()
                })
                .sum::<f32>()
                .abs()
                / length as f32
        };
        assert!(correlate(330.0) > 0.2);
        assert!(correlate(550.0) > 0.2);
        assert!(correlate(440.0) < 1.0e-3);
    }

    #[test]
    fn phrase_styles_keep_duration_but_change_samples() {
        let notes = [Note::new(69, 1.0), Note::new(72, 1.0)];
        let detached = render_phrase(&notes, &[1.0], 120.0, PhraseStyle::detached(0.8));
        let legato = render_phrase(&notes, &[1.0], 120.0, PhraseStyle::legato(0.25));
        assert_eq!(detached.len(), SAMPLE_RATE as usize);
        assert_eq!(detached.len(), legato.len());
        assert_ne!(detached, legato);
    }
}
ch03_studio.rs

assets/rust/ch03/src/bin/ch03_studio.rsRust

use std::{error::Error, path::PathBuf};

use wavetable_synthesis_exercises::{
    append_silence, apply_envelope, fir_filter, ring_modulate, splice_crossfade, write_wav,
    SineOscillator, SAMPLE_RATE,
};

#[derive(Debug)]
struct Event {
    start: usize,
    duration: usize,
    frequency_hz: f32,
    level: f32,
    attack: usize,
    release: usize,
    bright: bool,
    modulation_hz: f32,
}

fn parse_events(source: &str) -> Result<Vec<Event>, Box<dyn Error>> {
    source
        .lines()
        .skip(1)
        .map(|line| {
            let columns: Vec<_> = line.split(',').collect();
            if columns.len() != 8 {
                return Err(format!("invalid event: {line}").into());
            }
            Ok(Event {
                start: columns[0].parse()?,
                duration: columns[1].parse()?,
                frequency_hz: columns[2].parse()?,
                level: columns[3].parse()?,
                attack: columns[4].parse()?,
                release: columns[5].parse()?,
                bright: columns[6] == "bright",
                modulation_hz: columns[7].parse()?,
            })
        })
        .collect()
}

fn render(events: &[Event]) -> Vec<f32> {
    let frames = events
        .iter()
        .map(|event| event.start + event.duration)
        .max()
        .unwrap_or(0);
    let mut output = vec![0.0; frames];
    for event in events {
        let mut oscillator = SineOscillator::new();
        let mut clip: Vec<f32> = (0..event.duration)
            .map(|_| event.level * oscillator.tick(event.frequency_hz, SAMPLE_RATE as f32))
            .collect();
        apply_envelope(&mut clip, event.attack, event.release);
        if event.modulation_hz > 0.0 {
            ring_modulate(&mut clip, event.modulation_hz);
        }
        let coefficients: &[f32] = if event.bright {
            &[0.75, 0.25]
        } else {
            &[0.25, 0.5, 0.25]
        };
        for (target, sample) in output[event.start..]
            .iter_mut()
            .zip(fir_filter(&clip, coefficients))
        {
            *target += sample;
        }
    }
    output
}

fn main() -> Result<(), Box<dyn Error>> {
    let output_path = std::env::args_os()
        .nth(1)
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("ch03-paper-roll-study.wav"));
    if let Some(parent) = output_path
        .parent()
        .filter(|path| !path.as_os_str().is_empty())
    {
        std::fs::create_dir_all(parent)?;
    }
    let events = parse_events(include_str!("../../data/ch03-paper-roll.csv"))?;
    let rendered = render(&events);
    let reversed: Vec<_> = rendered.iter().rev().copied().collect();
    let mut study = splice_crossfade(&rendered, &reversed, 480);
    append_silence(&mut study, 0.5);
    let peak = study.iter().copied().map(f32::abs).fold(0.0, f32::max);
    write_wav(&output_path, &study)?;
    println!(
        "events={}, frames={}, peak={peak:.6}",
        events.len(),
        study.len()
    );
    println!("wrote {}", output_path.display());
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn event_roll_has_integer_schedule_and_bounded_output() {
        let events = parse_events(include_str!("../../data/ch03-paper-roll.csv")).unwrap();
        let output = render(&events);
        assert_eq!(events.len(), 5);
        assert_eq!(output.len(), 108_000);
        assert!(output
            .iter()
            .all(|sample| sample.is_finite() && sample.abs() < 1.0));
    }
}

Chapter 3 Answers and Fault Invariants

Chapter 3 mathematical-practice answers

  1. f=220f'=220 Hz, T=4T'=4 seconds, and Δp=12\Delta p=-12 semitones.
  2. Output length is 12,000/2=600012{,}000/2=6000 samples and duration is 0.125 seconds.
  3. 4000+2500+6000=12,5004000+2500+6000=12{,}500 samples.
  4. The first outputs are 0.25, 0.5, and 0.25.
  5. Difference 570 Hz and sum 830 Hz.
  6. Sample reversal reverses each sound’s internal envelope and oscillation. Event-order reversal preserves each event internally but changes sequence.
  7. A valid answer records all stated parameters and links a visible feature to the equation, such as duration falling as rate rises or two ring-modulation stems appearing at sum and difference.

Chapter 3 readiness answers

  1. Fixation permits isolation and exact repetition. Repetition weakens the event’s one-time causal flow and exposes internal rhythm and color.
  2. Frequency doubles, duration halves, and pitch rises twelve semitones.
  3. A discontinuity between boundary samples can click. A crossfade replaces the abrupt jump with overlapping complementary gains.
  4. An envelope multiplies each sample by a time trajectory. An FIR adds weighted current and delayed samples.
  5. 330 and 550 Hz.
  6. Schaeffer manipulated fixed recorded traces, first on disc. Cologne built and transformed generated signals through manually patched equipment and tape. RCA stored event and sound parameters on punched paper while an analog electronic path produced recorded output.

Chapter 3 fault invariant

  • Schaeffer’s 1948 experiments used phonograph discs; tape entered later.
  • Half speed halves pitch frequency and doubles duration.
  • Cologne required manual generation, patching, measurement, recording, and editing; it was not a DAW.
  • RCA’s punched paper stored control codes, not digital audio samples. Its audio path was analog.

Bibliography

Columbia University Computer Music Center. n.d. “History.” Accessed August 12, 2026. https://cmc.music.columbia.edu/content/history.
Columbia University Libraries. n.d. “Electronic Music Center.” Accessed August 12, 2026. https://exhibitions.library.columbia.edu/exhibits/show/music-centennial/electronic-and-computer-music/electronic-music-center.
Finnianhughes101. 2024. “RCA Mark II Sound Synthesizer.” Wikimedia Commons. 2024. https://commons.wikimedia.org/wiki/File:RCA_Mark_II_Sound_Synthesizer.jpg.
INA grm. n.d.a. “La Boucle, Révélateur Sonore.” Accessed August 12, 2026. https://sites.inagrm.com/parcours/co/Boucle_revelation_exploration.html.
———. n.d.b. “Les Premières Manipulations Du Support.” Accessed August 12, 2026. https://sites.inagrm.com/grmstudio/activities/Historique_Manipulations.xhtml.
IRCAM. 1948. “Étude Aux Chemins de Fer.” 1948. https://ressources.ircam.fr/en/work/etude-n-2-imposee-ou-etude-aux-chemins-de-fer.
Olson, Harry F., and Herbert Belar. 1955. “Electronic Music Synthesizer.” The Journal of the Acoustical Society of America 27 (3): 595–612. https://doi.org/10.1121/1.1907975.
———. 1958. Music synthesizer. US2855816A, issued 1958. https://patents.google.com/patent/US2855816.
Westdeutscher Rundfunk. n.d. “Sounds of Cologne.” Accessed August 12, 2026. https://artsandculture.google.com/story/sounds-of-cologne-wdr/gQWRzsVclcCYPA?hl=en.