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:
- explain how a fixed trace turns an event into editable material;
- calculate the linked pitch and duration change caused by playback rate;
- model selection, reversal, looping, splicing, envelope shaping, filtering, and ring modulation;
- distinguish Schaeffer’s disc practice, Cologne’s generated-signal studio, and RCA’s stored control;
- read a small event roll and render it with the cumulative Rust exercises;
- diagnose clicks, wrong duration, unintended sidebands, and scheduling errors.
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.
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.
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.).
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 be sample index, peak amplitude, frequency in hertz, sample rate in samples per second, and phase in radians:
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 compare new speed with recorded speed. Let be an original frequency in hertz, an original duration in seconds, and pitch displacement in semitones:
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).
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 select samples from start index up to end index in source . The concatenation symbol joins selected slices:
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.
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 be a unitless envelope between zero and one:
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 contain filter coefficients. Each output sample is a weighted sum of the current and delayed input samples:
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 and modulator frequency creates sum and difference 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
- A 440 Hz, two-second tape plays at . Find frequency, duration, and semitone shift.
- A 12,000-sample clip at 48 kHz is played at . Find output samples and duration.
- Slices contain 4000, 2500, and 6000 samples. Find concatenated length before crossfades.
- For and input , calculate the first three output samples.
- Multiply 700 and 130 Hz cosines. Name the two output frequencies.
- Explain why sample reversal changes an attack differently from reversing event order.
- 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.).
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.
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
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:
- predict the total duration from the final start plus duration;
- identify the two overlapping boundaries;
- mark which events use ring modulation;
- compare the forward render with its reversed splice;
- 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
Four forward repeats at normal rate.
Try these checks
- Choose half speed. Predict pitch and duration before reading the values.
- Reverse one trace, then eight. Listen for internal direction versus event order.
- 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.
- Run the Chapter 3 tests and renderer.
- Verify half and double rate lengths by hand.
- Trace the first three FIR outputs for an impulse.
- Change one CSV start time and predict total length.
- Remove one ring-modulation flag and predict which sidebands disappear.
- 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
- Why can fixation and repetition change how an event is heard?
- What happens to frequency and duration at double speed?
- What produces a splice click, and what does a crossfade change?
- How do envelope multiplication and FIR filtering differ?
- Which frequencies result from ring-modulating 440 Hz by 110 Hz?
- Contrast Schaeffer, Cologne, and RCA by material and control.
Chapter 3 invariants
- Schaeffer’s 1948 fixed-sound practice began on disc, not tape.
- Tape speed couples pitch and duration.
- Montage selects, orders, joins, and mixes fixed traces.
- An envelope multiplies amplitude over time.
- A linear filter combines weighted delayed samples.
- Ring multiplication creates sum and difference components.
- Cologne’s studio was a manually operated signal chain.
- RCA stored parameter control while its audio path remained analog.
- The bundled strike and paper-roll study are synthetic project-authored teaching surrogates.
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
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.wavSource
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(¬es, &[1.0], 120.0, PhraseStyle::detached(0.8));
let legato = render_phrase(¬es, &[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
- Hz, seconds, and semitones.
- Output length is samples and duration is 0.125 seconds.
- samples.
- The first outputs are 0.25, 0.5, and 0.25.
- Difference 570 Hz and sum 830 Hz.
- Sample reversal reverses each sound’s internal envelope and oscillation. Event-order reversal preserves each event internally but changes sequence.
- 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
- Fixation permits isolation and exact repetition. Repetition weakens the event’s one-time causal flow and exposes internal rhythm and color.
- Frequency doubles, duration halves, and pitch rises twelve semitones.
- A discontinuity between boundary samples can click. A crossfade replaces the abrupt jump with overlapping complementary gains.
- An envelope multiplies each sample by a time trajectory. An FIR adds weighted current and delayed samples.
- 330 and 550 Hz.
- 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.