CONTRAPUNK

Electrical Tone and the Harmonic Idea

Chapter contract

Prerequisites. Arithmetic with fractions, comfort reading simple graphs, and willingness to listen at a fixed safe level. No previous synthesis or trigonometry is assumed.

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

The problem: why can one pitch have many colours?

Sing a steady vowel, then change the vowel without changing the note. Play the same written pitch on a flute and a bowed string. The perceived pitch can remain approximately stable while the identity and colour of the sound change dramatically.

That observation gives us the first design problem of synthesis:

If pitch does not uniquely determine sound, what else must an instrument control?

Nineteenth-century acoustics gave a testable answer. A stable tone can often be modeled as simpler components. Their frequencies are related. Their relative strengths change tone colour. Helmholtz centered his account of tone quality on this relation. Rudolph Koenig built apparatus that made it audible (Helmholtz 1885; Rees 2010; “Helmholtz Synthesizer (Koenig)” n.d.).

Sound-synthesis apparatus after Helmholtz, made by Rudolph Koenig; individual tuning-fork and resonator paths could be combined.

Image credit and licence. Photograph by Jane023, 2012, showing an apparatus dated 1865 in the Teylers Museum instrument room. Creative Commons Attribution–ShareAlike 3.0 (Jane023 2012).

Long description. A museum case contains a horizontal row of electrically maintained tuning forks, each paired with a brass spherical resonator. Mechanical controls and a keyboard allow selected resonators to contribute to the combined sound. The apparatus makes an abstract synthesis equation physical: one path produces one component, a level or shutter controls whether it is heard, and the air adds all active components.

Its layout gives us a complete signal model:

component generator → component level → summing medium → compound tone

One ideal component: the sinusoid

A sinusoid is the simplest ideal periodic component used throughout synthesis:

s(t)=Asin(2πft+ϕ).(1.1) s(t)=A\sin(2\pi f t+\phi). \qquad\text{(1.1)}

Symbol Meaning Unit
tt time seconds
AA peak amplitude normalized linear amplitude
ff frequency hertz
ϕ\phi initial phase radians
Equation 1.1 plotted with A=0.8, f=2 Hz, and \phi=\pi/4; amplitude, period, and phase shift are marked.

Long description. Time runs from 0 to 1.25 seconds. A solid sinusoid reaches peak amplitudes of ±0.8 and repeats every 0.5 seconds. A dashed zero-phase reference shows how ϕ=π/4\phi=\pi/4 shifts the cycle. A horizontal arrow marks one period.

The argument of the sine function is an angle. The term 2πft2\pi f t advances by 2π2\pi radians for every completed cycle. Phase ϕ\phi chooses where in that cycle the signal starts. The same component can also be written with angular frequency ω=2πf\omega=2\pi f:

s(t)=Asin(ωt+ϕ).(1.2) s(t)=A\sin(\omega t+\phi). \qquad\text{(1.2)}

Equation 1.2 plotted with A=0.8, \omega=4\pi rad/s, and \phi=\pi/4; phase advance appears above the resulting sinusoid.

Long description. The upper panel shows phase increasing linearly at two cycles per second. The lower panel applies sine to that angle and produces the same shifted 2 Hz waveform as Equation 1.1.

This model is ideal. Real tuning forks contain other components, resonators lose energy, and loudspeakers add their own response. Start with the sinusoid because it isolates one frequency and provides a reference for more complicated tones (Smith 2011; Puckette 2007).

Period and frequency

The period TT is the amount of time required for one repetition. A sinusoid repeats after its phase advances by 2π2\pi:

2πfT=2π. 2\pi fT=2\pi.

Cancel 2π2\pi:

T=1f,f=1T.(1.3) T=\frac{1}{f},\qquad f=\frac{1}{T}. \qquad\text{(1.3)}

Equation 1.3 plotted as frequency against period; 10 ms maps to 100 Hz and 2.27 ms maps to 440 Hz.

Long description. The reciprocal curve falls steeply at short periods and flattens at long periods. Markers identify the 100 Hz and 440 Hz examples. Moving right to a longer period always moves down to a lower frequency.

For a 100 Hz component,

T=1100s=0.01s=10ms. T=\frac{1}{100}\ \mathrm{s}=0.01\ \mathrm{s}=10\ \mathrm{ms}.

For A4 at 440 Hz, one cycle lasts approximately

T=1440s2.273ms. T=\frac{1}{440}\ \mathrm{s}\approx 2.273\ \mathrm{ms}.

Sanity rule. Higher frequency means a shorter period. If your calculation gives a longer period for a higher frequency, the ratio has been inverted.

From one component to a harmonic family

A stable pitched tone often contains a fundamental frequency f0f_0 plus components at integer multiples of that fundamental:

fk=kf0,k=1,2,3,(1.4) f_k=kf_0,\qquad k=1,2,3,\ldots \qquad\text{(1.4)}

Equation 1.4 plotted for f_0=110 Hz; harmonics 1 through 6 appear at 110 through 660 Hz.

Long description. Six stems rise at harmonic numbers 1 through 6. Their labeled frequencies are 110, 220, 330, 440, 550, and 660 Hz. Equal horizontal steps in harmonic number produce equal 110 Hz vertical steps.

For f0=110f_0=110 Hz, the first six harmonic frequencies are:

110, 220, 330, 440, 550, 660 Hz

The vocabulary is easy to misuse:

This distinction matters later. Wavetable frames for a strictly periodic oscillator naturally organize energy at harmonic bins. Bells, stiff strings, noisy attacks, and modal resonators may contain important inharmonic partials.

Finite additive synthesis

A modern finite additive model writes a periodic tone as a sum of sinusoidal components:

xK(t)=k=1KAksin(2πkf0t+ϕk).(1.5) x_K(t)=\sum_{k=1}^{K} A_k\sin(2\pi kf_0t+\phi_k). \qquad\text{(1.5)}

Symbol Meaning
KK number of modeled harmonics
AkA_k amplitude of harmonic kk
kf0kf_0 frequency of harmonic kk
ϕk\phi_k phase of harmonic kk

Every term completes an integer number of cycles during the fundamental period T0=1/f0T_0=1/f_0. Their sum therefore repeats after T0T_0, unless a particular selection of terms implies an even shorter fundamental period.

Harmonically related sinusoidal components shown faintly and their progressively richer summed waveform shown in black.

Figure provenance. Author-generated by assets/figures/src/ch01_harmonics.py from Equation 1.5. The plotted zero-phase amplitudes are [1,0.5,0.33,0.25,0.2][1,0.5,0.33,0.25,0.2]. No measured or third-party data are used.

Long description. Three panels show two cycles of a fundamental. The first contains only harmonic 1 and is sinusoidal. The second combines harmonics 1 through 3, and the black sum departs from a sine shape. The third combines harmonics 1 through 5 and has sharper features. The component frequencies remain integer multiples of one fundamental throughout.

The Koenig-built apparatus maps directly onto Equation 1.5. One fork/resonator path supplies one component; the shutter or level determines whether and how strongly it is heard; the air performs the sum.

One physical or digital path per harmonic, followed by independently controlled levels and summation.

Long description. Fundamental, second harmonic, third harmonic, and higher-harmonic sources each pass through an individual level control. All paths meet at one summing node, which produces one periodic tone. Labels and line paths, rather than colour alone, identify the relations.

From waveform to amplitude spectrum

Equation 1.5 can be viewed in two complementary ways:

  1. Time domain: how amplitude changes over time.
  2. Frequency domain: which component frequencies are present and how strong they are.

For the finite harmonic model, a one-sided amplitude spectrum places a line at each kf0kf_0, with height AkA_k. A magnitude-only display does not preserve ϕk\phi_k, and it does not describe the attack, decay, or later variation of AkA_k. Those omissions are why a spectrum is powerful without being a complete description of musical timbre (Wolfe n.d.; McAdams 2019).

Four tones share one fundamental frequency while their waveforms and harmonic-amplitude recipes differ.

Figure provenance. Author-generated by assets/figures/src/ch01_harmonics.py. All four examples use the same fundamental period. The displayed harmonic recipes are deterministic.

Long description. Four rows compare a waveform on the left with harmonic amplitudes on the right. The sine row has only harmonic 1. The odd-only row contains odd harmonics. The saw-like row contains every harmonic with amplitude decreasing as 1/k1/k. The dark row contains every harmonic with the faster decrease 1/k21/k^2. All waveforms repeat at the same fundamental rate, but their shapes and brightness differ.

A static spectrum is not complete timbre

Changing AkA_k changes steady-state colour, but a complete account of timbre also includes:

The historical apparatus gives us the harmonic idea. It does not finish the theory of timbre. Later chapters will add time, modulation, noise, performance, and perception.

The Telharmonium: make tone electrical, then distribute it

Helmholtz and Koenig treated synthesis primarily as a scientific demonstration in a room. Thaddeus Cahill pursued a much larger engineering question:

Can musical tone be generated electrically, controlled from a keyboard, and distributed from one central plant to remote listeners?

Cahill’s U.S. Patent 580,035, granted on 6 April 1897, describes an electromechanical system for producing and combining periodic electrical currents, controlling expression, and sending the result over line circuits to remote translating instruments (Cahill 1897b).

Thaddeus Cahill and an assistant at the console of the 1906 Telharmonium installation in Holyoke, Massachusetts.

Image credit and licence. Unknown photographer, originally published in The World’s Work, June 1906. Public domain (Unknown photographer 1906).

Long description. Two operators sit at a large organ-like console with several manuals, ranks of controls, and electrical equipment behind them. The familiar keyboard interface conceals an industrial generator system elsewhere in the plant. The photograph emphasizes a recurring synthesizer pattern: a human-scale control surface may command machinery much larger or more complex than the interface suggests.

The patented sources were rotating rheotomes: conducting and insulating sections repeatedly made and broke electrical circuits. Related generators were organized in harmonic ratios. Key-controlled circuits combined component currents, and inductive stages reduced the harsh abruptness of the raw interrupted currents before line distribution (Cahill 1897b; Smith 2011).

Selected drawing from Cahill’s US580035A patent, documenting electromechanical generator and circuit relationships.

Image credit and licence. Thaddeus Cahill, patent drawing, 1897. Public domain (Cahill 1897a, 1897b).

Long description. A vertically oriented patent sheet contains numbered mechanical shafts, electromagnetic structures, circuit paths, and callouts. It records an engineered system rather than a modern block diagram. The simplified diagram below extracts only the causal stages needed for this chapter.

Historical Telharmonium functions reduced to performer control, electromechanical generation, combining, line distribution, and remote sound.

Long description. A left-to-right flow begins with keyboard and stops, proceeds through rotating rheotomes, electrical combining and level control, telephone-line distribution, a remote receiver or horn, and finally audible tone.

Was the Telharmonium an additive synthesizer?

A careful answer has two parts.

Broad historical sense: yes. Cahill explicitly described synthesizing composite electrical vibrations from elemental vibrations and organized generators in harmonic relationships (Cahill 1897b).

Modern sinusoidal sense: not cleanly. A rheotome’s interrupted current was not an independent ideal sine oscillator. Cahill used inductive stages to smooth currents he described as abrupt and harsh. The system therefore combined partial-related electrical sources, but its sources and controls did not provide the arbitrary independent sinusoidal amplitudes, frequencies, phases, and time envelopes implied by Equation 1.5. Smith accordingly describes it as only “a kind of” additive synthesis and relates it closely to electromechanical organ practice (Smith 2011).

Use the mechanism, not the label, when comparing historical and modern synthesis.

First-principles lessons for wavetable synthesis

This chapter established four foundations:

  1. A periodic tone can be represented as a repeatable cycle.
  2. A complicated cycle can be analyzed or constructed through components.
  3. Component balance and control are central to timbre.
  4. Tone generation, performer control, and distribution are separate engineering layers.

Later, a wavetable will store one sampled period instead of maintaining one physical oscillator per harmonic. The historical harmonic model will still explain what that stored cycle contains.

Mathematical concepts 1: cycles, harmonics, and finite sums

Cycle landmarks and radians

Draw one cycle. Mark its start, quarter, half, three-quarter, and end points. One cycle is 2π2\pi radians:

Cycle position Angle sin(θ)\sin(\theta)
start 00 00
one quarter π/2\pi/2 11
one half π\pi 00
three quarters 3π/23\pi/2 1-1
one complete cycle 2π2\pi 00

For s(t)=Asin(2πft+ϕ)s(t)=A\sin(2\pi ft+\phi), the quantity 2πft+ϕ2\pi ft+\phi tells us the current angle. Frequency says how many complete 2π2\pi journeys occur each second; phase says where the first journey begins.

Unit reasoning

A hertz is one cycle per second. Keep units visible:

T=5ms=0.005sf=10.005s=200Hz. T=5\ \mathrm{ms}=0.005\ \mathrm{s} \quad\Rightarrow\quad f=\frac{1}{0.005\ \mathrm{s}}=200\ \mathrm{Hz}.

Use 1kHz=1000Hz1\ \mathrm{kHz}=1000\ \mathrm{Hz} and 1s=1000ms1\ \mathrm{s}=1000\ \mathrm{ms}. Convert before taking a reciprocal.

Coefficient lists as equations and spectra

A program often stores amplitudes as a list. For

𝐀=[1.0,0.0,0.5], \mathbf{A}=[1.0,\ 0.0,\ 0.5],

list position 1 controls harmonic 1, position 2 controls harmonic 2, and position 3 controls harmonic 3. With zero phases,

x(t)=sin(2πf0t)+0.5sin(2π3f0t). x(t)=\sin(2\pi f_0t)+0.5\sin(2\pi 3f_0t).

The zero coefficient removes the line at 2f02f_0; it does not shift later list positions. The one-sided magnitude spectrum has lines at f0f_0 and 3f03f_0 with relative heights 1.0 and 0.5.

When the declared fundamental is not the lowest repetition

Let SS be the set of active harmonic numbers and let g=gcd(S)g=\gcd(S), the largest integer that divides every member of SS. The lowest repetition frequency of the selected sum is

frepeat=gf0.(1.6) f_{\mathrm{repeat}}=g f_0. \qquad\text{(1.6)}

Equation 1.6 plotted for f_0=100 Hz with active harmonics 2, 4, and 6; the sum repeats every 5 ms.

Long description. The upper panel shows the summed waveform with vertical markers every 5 ms. The lower spectrum has lines at 200, 400, and 600 Hz with amplitudes 1, 0.5, and 0.25. The active indices have greatest common divisor 2, so the lowest repetition frequency is 200 Hz.

If harmonics 2, 4, and 6 are active, then g=2g=2: the waveform repeats at 2f02f_0, even if the recipe was indexed relative to a declared f0f_0. If harmonic 1 is active, the greatest common divisor is necessarily 1.

Mathematical practice 1

Show units and intermediate steps.

  1. A cycle lasts 5 ms. Find its frequency in hertz.
  2. Evaluate sin(θ)\sin(\theta) at θ=π/2,π,3π/2,\theta=\pi/2,\pi,3\pi/2, and 2π2\pi.
  3. List the first five harmonics of f0=82.5f_0=82.5 Hz.
  4. Convert [1.0,0.0,0.5][1.0,0.0,0.5] into a zero-phase equation and a two-line spectrum description.
  5. Compare recipes [1,1/2,1/3,1/4][1,1/2,1/3,1/4] and [1,0,1/3,0][1,0,1/3,0]. Which harmonic lines differ, and which do you predict will sound brighter after level matching?
  6. A recipe relative to f0=100f_0=100 Hz contains only harmonics 2, 4, and 6. Find its lowest repetition frequency and period.
  7. At a 48 kHz sample rate, what is the largest harmonic number of a 440 Hz fundamental that remains strictly below the 24 kHz Nyquist frequency?
  8. For each plot from Equations 1.1 through 1.7, name the axes or panels, the fixed parameters, and one prediction visible before calculation.

Worked example: design a three-harmonic tone

Design a tone with fundamental f0=110f_0=110 Hz, amplitudes

A1=1.0,A2=0.50,A3=0.25, A_1=1.0,\qquad A_2=0.50,\qquad A_3=0.25,

and zero starting phase.

The component frequencies are:

f1=110Hz,f2=220Hz,f3=330Hz. f_1=110\ \mathrm{Hz},\quad f_2=220\ \mathrm{Hz},\quad f_3=330\ \mathrm{Hz}.

The signal is:

x(t)=sin(2π110t)+0.5sin(2π220t)+0.25sin(2π330t).(1.7) x(t)=\sin(2\pi110t)+0.5\sin(2\pi220t)+0.25\sin(2\pi330t). \qquad\text{(1.7)}

Equation 1.7 plotted with its exact three-harmonic waveform and one-sided spectrum.

Long description. The left panel shows about three cycles of the summed waveform. The right panel has spectrum lines at 110, 220, and 330 Hz with relative amplitudes 1, 0.5, and 0.25.

Its fundamental period is:

T0=1110s9.091ms. T_0=\frac{1}{110}\ \mathrm{s}\approx9.091\ \mathrm{ms}.

The spectrum contains lines at 110, 220, and 330 Hz with relative heights 1.0, 0.5, and 0.25. A magnitude-only spectrum does not state the phases, although this example declares all phases zero.

Listening station 1: one pitch, four harmonic recipes

Open the Chapter 1 listening file:

assets/audio/ch01/ch01-same-pitch-different-timbres.wav

The four two-second tones are separated by silence and appear in this order:

  1. sine;
  2. equal-amplitude odd harmonics;
  3. saw-like 1/k1/k harmonic roll-off;
  4. darker 1/k21/k^2 roll-off.

All use f0=220f_0=220 Hz and were normalized to the same RMS target before short fades were applied.

Procedure

  1. Keep the playback level fixed.
  2. For each tone, write three words describing the steady-state colour.
  3. Rank them from least to most bright.
  4. State whether the perceived pitch changes.
  5. Inspect the same-pitch spectrum figure and connect your brightness ranking to high-harmonic energy.
  6. Repeat at a lower level. Note which distinctions remain obvious.

Expected observation, not a required preference

More high-harmonic energy often supports a brighter or buzzier description, but the words are not physical units and listener descriptions can differ. The invariant is the construction: the fundamental remains fixed while the harmonic recipe changes.

Song study 1: one melody, two spectra

LAB 01

Make the harmonic sum audible

Move one coefficient, predict the waveform and spectrum, then listen.

Relative harmonic amplitudes
The current waveform and spectrum are described in the text below the plot.

The current recipe contains H1 at 220 Hz with amplitude 1.00, H2 at 440 Hz with amplitude 0.50, and H3 at 660 Hz with amplitude 0.25.

Ready

Try these checks

  1. Set harmonic 5 to 0.50. Name the only spectrum line that must change.
  2. Choose Sine, then Odd only. Keep the fundamental fixed and compare pitch with colour.
  3. Play the melody with two recipes. Identify what remains invariant.
  4. Raise the fundamental. Watch every harmonic frequency rise by the same integer rule.
Read the exact source running this lab

This TypeScript calculates the sound and plots above. The coefficient list is both the equation and the spectrum.

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

export function harmonicFrequency(fundamentalHz: number, harmonic: number) {
  return fundamentalHz * harmonic;
}

function energy(amplitudes: number[]) {
  return Math.sqrt(amplitudes.reduce((sum, amplitude) => sum + amplitude ** 2, 0)) || 1;
}

export function waveform(amplitudes: number[], points = 480) {
  const level = energy(amplitudes);
  return Array.from({ length: points }, (_, index) => {
    const cycles = 2 * index / (points - 1);
    return amplitudes.reduce(
      (sample, amplitude, harmonic) => sample + amplitude * Math.sin(TAU * (harmonic + 1) * cycles),
      0
    ) / level;
  });
}

export function renderAdditiveTone(
  fundamentalHz: number,
  amplitudes: number[],
  seconds = 1.4
) {
  const frameCount = Math.round(seconds * SAMPLE_RATE);
  const output = new Float32Array(frameCount);
  const phases = new Float64Array(amplitudes.length);
  const level = energy(amplitudes);
  const fadeFrames = Math.round(0.012 * SAMPLE_RATE);

  for (let frame = 0; frame < frameCount; frame++) {
    let sample = 0;
    for (let index = 0; index < amplitudes.length; index++) {
      const frequencyHz = harmonicFrequency(fundamentalHz, index + 1);
      if (frequencyHz >= SAMPLE_RATE / 2) continue;
      sample += amplitudes[index] * Math.sin(phases[index]);
      phases[index] = (phases[index] + TAU * frequencyHz / SAMPLE_RATE) % TAU;
    }
    const envelope = Math.min(1, frame / fadeFrames, (frameCount - 1 - frame) / fadeFrames);
    output[frame] = 0.2 * envelope * sample / level;
  }
  return output;
}

export function renderMelody(amplitudes: number[]) {
  const melody = [60, 60, 67, 67, 69, 69, 67, 65, 65, 64, 64, 62, 62, 60];
  const beats =  [ 1,  1,  1,  1,  1,  1,  2,  1,  1,  1,  1,  1,  1,  2];
  const framesPerBeat = SAMPLE_RATE * 60 / 108;
  const frameCounts = beats.map((beat) => Math.round(beat * framesPerBeat));
  const output = new Float32Array(frameCounts.reduce((sum, frames) => sum + frames, 0));
  const phases = new Float64Array(amplitudes.length);
  const level = energy(amplitudes);
  const fadeFrames = Math.round(0.005 * SAMPLE_RATE);
  let write = 0;

  melody.forEach((midi, noteIndex) => {
    const frames = frameCounts[noteIndex];
    const active = Math.floor(frames * 0.92);
    const fundamentalHz = 440 * 2 ** ((midi - 69) / 12);
    for (let frame = 0; frame < frames; frame++, write++) {
      let sample = 0;
      for (let index = 0; index < amplitudes.length; index++) {
        const frequencyHz = harmonicFrequency(fundamentalHz, index + 1);
        if (frequencyHz >= SAMPLE_RATE / 2) continue;
        sample += amplitudes[index] * Math.sin(phases[index]);
        phases[index] = (phases[index] + TAU * frequencyHz / SAMPLE_RATE) % TAU;
      }
      const envelope = frame >= active
        ? 0
        : Math.min(1, frame / fadeFrames, (active - frame) / fadeFrames);
      output[write] = 0.18 * envelope * sample / level;
    }
  });
  return output;
}

Open the Rust Chapter 1 melody study. It contains:

  1. the opening of the traditional French melody Ah! vous dirai-je, maman as a sine;
  2. the same notes, rhythm, tempo, and articulation with amplitudes [1,1/2,1/4,1/8][1,1/2,1/4,1/8];
  3. an original eight-note A/B ostinato on A3 that alternates two harmonic recipes.

The melody begins C4 C4 G4 G4 A4 A4 G4 and continues F4 F4 E4 E4 D4 D4 C4. It predates Mozart; his public-domain variations provide a historical score reference, but this book does not credit him as composer of the tune (Mozart 1778). The audio is a new monophonic synthesis, not a borrowed recording or modern arrangement. The two recipes are normalized by coefficient energy so their steady-state RMS levels are comparable; short note envelopes can still produce small measured differences.

Listening questions

  1. Which information makes the two melody passes recognizably the same?
  2. Which pass makes repeated notes easier to separate, and is that caused by spectrum or articulation?
  3. Does the four-harmonic recipe change melodic contour, pitch center, or only tone colour?
  4. In the final ostinato, can alternating timbre alone create a call and response?
  5. Hum the melody after stopping playback. Which properties survive without the synthesizer timbre?

Figure lab 1: reproduce the plots

Run:

python3 assets/figures/src/ch01_harmonics.py

Verify that it regenerates:

assets/figures/svg/ch01-harmonic-construction.svg
assets/figures/svg/ch01-same-pitch-different-timbres.svg
assets/audio/ch01/ch01-same-pitch-different-timbres.wav

Then change only the amplitude of harmonic 5 in one recipe. Before listening, predict how the time waveform and spectrum must change. Regenerate, listen, and record whether the perceptual change was as large as expected.

Rust lab 1: make the mathematics audible

From a Contrapunk checkout, run:

cargo test -p wavetable-synthesis-exercises
cargo run -p wavetable-synthesis-exercises \
  --bin ch01_harmonics -- /tmp/ch01.wav

The bundled source is also independently runnable from source/rust-exercises/. Read src/lib.rs and src/bin/ch01_harmonics.rs, then complete this cumulative ladder:

  1. verify period_seconds(250.0) and harmonic_frequency(110.0, 5) by hand;
  2. trace one phase increment 2πf/fs2\pi f/f_s through SineOscillator::tick;
  3. change one amplitude coefficient and predict the spectrum before rendering;
  4. add a sixth harmonic, then state when it would cross Nyquist as pitch rises;
  5. transpose the melody while preserving its rhythm and coefficient lists;
  6. design a third four-coefficient recipe that is darker than [1,1/2,1/4,1/8][1,1/2,1/4,1/8] and justify the prediction.

The reference code skips harmonics at or above Nyquist. It is an offline teaching renderer, not yet the production wavetable oscillator proposed by Contrapunk issue #188.

Faded station 1: complete a harmonic recipe

A tone has fundamental f0=220f_0=220 Hz and the partially specified recipe:

harmonic:   1      2      3      4
frequency:  ?      ?      ?      ?
amplitude:  1.0    0.4    0.2    0.1
phase:      0      0      0      0
  1. Complete the frequencies without returning to Equation 1.4.
  2. Write the four-term signal, leaving the common 2π2\pi factor visible.
  3. Sketch its one-sided amplitude spectrum.
  4. Predict how removing harmonic 2 changes the spectrum before listening.

Musical application 1: harmonic call and response

Use the Chapter 1 Python script, an additive synthesizer, or any instrument that lets you control harmonic balance.

  1. Make Tone A from harmonics 1, 2, and 3.
  2. Make Tone B from harmonics 1, 3, and 5.
  3. Match their fundamental, duration, and approximate RMS level.
  4. Perform or render an eight-event phrase alternating A and B while keeping pitch fixed.
  5. Repeat with the same rhythm but let pitch move while timbre remains fixed.
  6. Write which dimension (pitch, rhythm, or harmonic colour) carried the call-and-response identity in each version.

The goal is not to discover a universally superior spectrum. It is to experience timbre as a compositional variable independent enough to organize a phrase.

Fault station 1: diagnose the explanation

A student writes:

“The first overtone is the first harmonic. Every partial is harmonic. The Telharmonium generated six perfect sine waves and therefore worked exactly like modern additive synthesis.”

Mark each incorrect clause. For every correction, identify whether the mistake is vocabulary, historical evidence, or DSP modeling.

Chapter 1 readiness gate

Commit answers before consulting the answer invariants.

  1. For s(t)=0.4sin(2π250t+π/3)s(t)=0.4\sin(2\pi\cdot250t+\pi/3), identify amplitude, frequency, phase, period, and angular frequency.
  2. A periodic tone has f0=110f_0=110 Hz. List its first six harmonics and identify the first overtone.
  3. Write a finite three-harmonic signal with amplitudes 1, 0.5, and 0.25 and zero phases.
  4. What does each line in a one-sided amplitude spectrum encode, and what does a magnitude-only plot omit?
  5. Explain the Koenig-built Helmholtz apparatus as a signal-flow system.
  6. Is the Telharmonium modern additive synthesis? Give the two-part answer supported by historical evidence.

Chapter 1 invariants

Chapter 1 glossary

Term Working definition
Additive synthesis Construction of one signal by summing controlled components.
Amplitude Instantaneous or peak signal size under a stated convention.
Angular frequency Phase advance per second in radians; ω=2πf\omega=2\pi f.
Cycle One complete repetition of a periodic waveform.
Frequency Cycles per second, measured in hertz.
Fundamental Lowest declared harmonic reference; harmonic 1.
Harmonic A component at an integer multiple of f0f_0.
Magnitude spectrum Component frequency and magnitude without phase or time evolution.
Nyquist frequency Half the sample rate; sampled components must remain below it to avoid direct aliasing.
Overtone A component above the fundamental; the first overtone is normally harmonic 2.
Partial Any component of a tone, whether harmonic or inharmonic.
Period Time for one repetition; T=1/fT=1/f.
Phase Position within a cycle, measured as an angle.
Rheotome Cahill’s rotating make-and-break electrical source.
Sinusoid A single ideal periodic component described by amplitude, frequency, and phase.
Timbre Perceptual sound identity beyond pitch and loudness, including spectrum and time evolution.

IMPLEMENTATION NOTEBOOK

Chapter 1 source and generated output

These are the complete sources used for the chapter figures, diagrams, listening studies, and cumulative Rust renderer. Each source is shown beside the output it produces.

SOURCE AND OUTPUT

Harmonic figures and listening study

This Python program generates both harmonic figures and the four-part level-matched listening file.

Output

Progressive sums of one, three, and five harmonics
Generated harmonic-construction figure.
Four equal-pitch waveforms and their harmonic spectra
Generated same-pitch timbre comparison.

Source

ch01_harmonics.py

assets/figures/src/ch01_harmonics.pyPython

#!/usr/bin/env python3
"""Generate Chapter 1 harmonic figures and the level-matched listening file."""

from __future__ import annotations

import wave
from pathlib import Path

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

ROOT = Path(__file__).resolve().parents[3]
SVG = ROOT / "assets/figures/svg"
AUDIO = ROOT / "assets/audio/ch01"
SVG.mkdir(parents=True, exist_ok=True)
AUDIO.mkdir(parents=True, exist_ok=True)

plt.rcParams.update(
    {
        "font.family": "DejaVu Sans",
        "font.size": 9,
        "axes.spines.top": False,
        "axes.spines.right": False,
        "axes.titleweight": "bold",
        "svg.fonttype": "none",
    }
)
COLORS = ["#7F1D1D", "#0F766E", "#1D4ED8", "#7C3AED", "#B45309"]


def harmonic_sum(phase: np.ndarray, amplitudes: list[float], phases: list[float] | None = None) -> np.ndarray:
    phases = phases or [0.0] * len(amplitudes)
    result = np.zeros_like(phase, dtype=float)
    for index, (amplitude, offset) in enumerate(zip(amplitudes, phases), start=1):
        result += amplitude * np.sin(2 * np.pi * index * phase + offset)
    return result


def normalize_rms(signal: np.ndarray, target: float = 0.18) -> np.ndarray:
    signal = signal - np.mean(signal)
    rms = np.sqrt(np.mean(signal**2))
    return signal * (target / rms) if rms else signal


def fade(signal: np.ndarray, sample_rate: int, seconds: float = 0.03) -> np.ndarray:
    frames = min(int(sample_rate * seconds), len(signal) // 2)
    ramp = np.linspace(0.0, 1.0, frames)
    signal = signal.copy()
    signal[:frames] *= ramp
    signal[-frames:] *= ramp[::-1]
    return signal


def write_wav(path: Path, signal: np.ndarray, sample_rate: int = 48_000) -> None:
    signal = np.clip(signal, -0.98, 0.98)
    pcm = np.round(signal * 32767).astype("<i2")
    with wave.open(str(path), "wb") as output:
        output.setnchannels(1)
        output.setsampwidth(2)
        output.setframerate(sample_rate)
        output.writeframes(pcm.tobytes())


# Figure 1: progressively combine a harmonic series.
phase = np.linspace(0.0, 2.0, 1600, endpoint=False)
amplitudes = [1.0, 0.50, 0.33, 0.25, 0.20]
fig, axes = plt.subplots(3, 1, figsize=(7.2, 6.5), sharex=True, constrained_layout=True)
for count, axis in zip([1, 3, 5], axes):
    partials = [amplitudes[k - 1] * np.sin(2 * np.pi * k * phase) for k in range(1, count + 1)]
    for k, partial in enumerate(partials, start=1):
        axis.plot(phase, partial, color=COLORS[(k - 1) % len(COLORS)], alpha=0.28, linewidth=0.8)
    total = np.sum(partials, axis=0)
    axis.plot(phase, total, color="#111827", linewidth=1.8, label="sum")
    axis.axhline(0, color="#6B7280", linewidth=0.5)
    axis.set_ylabel("amplitude")
    axis.set_title(f"Fundamental plus {count - 1} overtone{'s' if count != 2 else ''}")
axes[-1].set_xlabel("time in cycles of the fundamental")
fig.suptitle("A complex periodic tone built from harmonically related sinusoids", fontsize=12, fontweight="bold")
fig.savefig(SVG / "ch01-harmonic-construction.svg", bbox_inches="tight")
plt.close(fig)

# Figure 2: same pitch, different harmonic recipes.
recipes = {
    "Sine": [1.0] + [0.0] * 11,
    "Odd-only": [1.0 if k % 2 else 0.0 for k in range(1, 13)],
    "Saw-like": [1.0 / k for k in range(1, 13)],
    "Dark roll-off": [1.0 / (k * k) for k in range(1, 13)],
}
fig, axes = plt.subplots(len(recipes), 2, figsize=(7.2, 8.0), constrained_layout=True)
short_phase = np.linspace(0.0, 2.0, 1200, endpoint=False)
for row, (name, recipe) in enumerate(recipes.items()):
    waveform = normalize_rms(harmonic_sum(short_phase, recipe), 0.45)
    axes[row, 0].plot(short_phase, waveform, color=COLORS[row], linewidth=1.4)
    axes[row, 0].axhline(0, color="#6B7280", linewidth=0.5)
    axes[row, 0].set_ylabel(name)
    axes[row, 0].set_ylim(-1.05, 1.05)
    harmonics = np.arange(1, len(recipe) + 1)
    markerline, stemlines, baseline = axes[row, 1].stem(harmonics, recipe, basefmt=" ")
    plt.setp(markerline, color=COLORS[row], markersize=4)
    plt.setp(stemlines, color=COLORS[row], linewidth=1.2)
    axes[row, 1].set_xlim(0.5, 12.5)
    axes[row, 1].set_ylim(0.0, 1.05)
    axes[row, 1].set_ylabel("relative level")
axes[0, 0].set_title("two cycles at the same fundamental")
axes[0, 1].set_title("harmonic amplitudes")
axes[-1, 0].set_xlabel("time in cycles")
axes[-1, 1].set_xlabel("harmonic number")
fig.suptitle("Pitch can remain fixed while harmonic balance changes timbre", fontsize=12, fontweight="bold")
fig.savefig(SVG / "ch01-same-pitch-different-timbres.svg", bbox_inches="tight")
plt.close(fig)

# Listening file: four two-second, level-matched tones separated by silence.
sample_rate = 48_000
segment_seconds = 2.0
silence = np.zeros(int(sample_rate * 0.45))
t = np.arange(int(sample_rate * segment_seconds)) / sample_rate
fundamental = 220.0
segments = []
for recipe in recipes.values():
    signal = np.zeros_like(t)
    for harmonic, amplitude in enumerate(recipe, start=1):
        signal += amplitude * np.sin(2 * np.pi * harmonic * fundamental * t)
    signal = fade(normalize_rms(signal), sample_rate)
    segments.extend([signal, silence])
write_wav(AUDIO / "ch01-same-pitch-different-timbres.wav", np.concatenate(segments[:-1]), sample_rate)

print("Generated Chapter 1 figures and audio.")

SOURCE AND OUTPUT

Equation figures

One deterministic Python program renders the exact plots paired with the numbered formulas.

Output

Equation 1.1 sinusoid parameters
Equation 1.1 sinusoid parameters
Equation 1.2 angular frequency
Equation 1.2 angular frequency
Equation 1.3 period and frequency
Equation 1.3 period and frequency
Equation 1.4 harmonic family
Equation 1.4 harmonic family
Equation 1.6 actual repetition
Equation 1.6 actual repetition
Equation 1.7 worked additive tone
Equation 1.7 worked additive tone

Source

formula_visuals_ch01.py

assets/figures/src/formula_visuals_ch01.pyPython

#!/usr/bin/env python3
"""Generate deterministic visuals for the numbered formulas in Chapter 1."""

from __future__ import annotations

from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

ROOT = Path(__file__).resolve().parents[3]
SVG = ROOT / "assets/figures/svg"
SVG.mkdir(parents=True, exist_ok=True)

RED = "#7F1D1D"
TEAL = "#0F766E"
BLUE = "#1D4ED8"
PURPLE = "#7C3AED"
GOLD = "#B45309"
GRAY = "#6B7280"
BLACK = "#111827"

plt.rcParams.update(
    {
        "font.family": "DejaVu Sans",
        "font.size": 9,
        "axes.spines.top": False,
        "axes.spines.right": False,
        "axes.titleweight": "bold",
        "svg.fonttype": "none",
    }
)


def save(fig: plt.Figure, filename: str) -> None:
    fig.savefig(SVG / filename, bbox_inches="tight")
    plt.close(fig)


def zero_line(axis: plt.Axes) -> None:
    axis.axhline(0.0, color=GRAY, linewidth=0.6)


# Equation 1.1: amplitude, frequency, period, and phase in one sinusoid.
t = np.linspace(0.0, 1.25, 1600)
amplitude = 0.8
frequency = 2.0
phase = np.pi / 4
signal = amplitude * np.sin(2 * np.pi * frequency * t + phase)
unshifted = amplitude * np.sin(2 * np.pi * frequency * t)
fig, axis = plt.subplots(figsize=(7.2, 3.2), constrained_layout=True)
axis.plot(t, signal, color=RED, linewidth=2.0, label="φ = π/4")
axis.plot(t, unshifted, color=GRAY, linewidth=1.0, linestyle="--", label="φ = 0 reference")
axis.axhline(amplitude, color=TEAL, linewidth=0.8, linestyle=":", label="±A = ±0.8")
axis.axhline(-amplitude, color=TEAL, linewidth=0.8, linestyle=":")
axis.annotate("T = 1/f = 0.5 s", xy=(0.25, -1.02), xytext=(0.75, -1.02), ha="center", va="center", arrowprops={"arrowstyle": "<->", "color": BLACK})
axis.set(xlabel="time t (s)", ylabel="s(t)", xlim=(0, 1.25), ylim=(-1.15, 1.15), title="Equation 1.1: A = 0.8, f = 2 Hz, φ = π/4")
axis.legend(frameon=False, ncol=3, loc="upper right")
save(fig, "eq-1-1-sinusoid.svg")

# Equation 1.2: angular phase advances linearly and the sine reads that angle.
omega = 4 * np.pi
angle = omega * t + phase
fig, axes = plt.subplots(2, 1, figsize=(7.2, 4.8), sharex=True, constrained_layout=True)
axes[0].plot(t, angle / (2 * np.pi), color=BLUE, linewidth=2)
axes[0].set(ylabel="phase θ / 2π (cycles)", title="Angle advances at ω = 4π rad/s")
axes[1].plot(t, amplitude * np.sin(angle), color=RED, linewidth=1.8)
zero_line(axes[1])
axes[1].set(xlabel="time t (s)", ylabel="s(t)", title="The sine converts angle into amplitude")
fig.suptitle("Equation 1.2: s(t) = A sin(ωt + φ), A = 0.8, ω = 4π, φ = π/4", fontweight="bold")
save(fig, "eq-1-2-angular-frequency.svg")

# Equation 1.3: frequency is the reciprocal of period.
period_ms = np.linspace(1.0, 20.0, 800)
frequency_hz = 1000.0 / period_ms
fig, axis = plt.subplots(figsize=(7.2, 3.2), constrained_layout=True)
axis.plot(period_ms, frequency_hz, color=BLUE, linewidth=2)
for x, y, label in [(10.0, 100.0, "10 ms ↔ 100 Hz"), (1000 / 440, 440.0, "2.27 ms ↔ 440 Hz")]:
    axis.scatter([x], [y], color=RED, zorder=3)
    axis.annotate(label, (x, y), xytext=(8, 8), textcoords="offset points")
axis.set(xlabel="period T (ms)", ylabel="frequency f (Hz)", xlim=(1, 20), ylim=(0, 1020), title="Equation 1.3: shorter periods produce higher frequencies")
save(fig, "eq-1-3-period-frequency.svg")

# Equation 1.4: harmonic frequency grows linearly with harmonic number.
fundamental = 110.0
harmonic = np.arange(1, 7)
harmonic_hz = harmonic * fundamental
fig, axis = plt.subplots(figsize=(7.2, 3.2), constrained_layout=True)
markerline, stemlines, _ = axis.stem(harmonic, harmonic_hz, basefmt=" ")
plt.setp(markerline, color=RED, markersize=6)
plt.setp(stemlines, color=RED, linewidth=1.6)
for k, value in zip(harmonic, harmonic_hz):
    axis.annotate(f"{value:.0f}", (k, value), xytext=(0, 7), textcoords="offset points", ha="center")
axis.set(xticks=harmonic, xlabel="harmonic number k", ylabel="frequency fk (Hz)", ylim=(0, 720), title="Equation 1.4: fk = k f0 with f0 = 110 Hz")
save(fig, "eq-1-4-harmonic-family.svg")

# Equation 1.6: active harmonic indices 2, 4, and 6 repeat at 2f0.
fundamental = 100.0
active = np.array([2, 4, 6])
amplitudes = np.array([1.0, 0.5, 0.25])
t_short = np.linspace(0.0, 0.020, 2400, endpoint=False)
components = amplitudes[:, None] * np.sin(2 * np.pi * active[:, None] * fundamental * t_short)
summed = components.sum(axis=0)
fig, axes = plt.subplots(2, 1, figsize=(7.2, 5.2), constrained_layout=True)
axes[0].plot(t_short * 1000, summed, color=BLACK, linewidth=1.6)
for boundary in np.arange(0, 20.1, 5):
    axes[0].axvline(boundary, color=RED, linewidth=0.7, linestyle="--")
axes[0].annotate("repeat period = 5 ms", xy=(0, -1.65), xytext=(5, -1.65), ha="center", arrowprops={"arrowstyle": "<->", "color": RED})
axes[0].set(xlabel="time (ms)", ylabel="sum", ylim=(-1.85, 1.85), title="Waveform repeats twice per declared 100 Hz cycle")
markerline, stemlines, _ = axes[1].stem(active * fundamental, amplitudes, basefmt=" ")
plt.setp(markerline, color=PURPLE, markersize=6)
plt.setp(stemlines, color=PURPLE, linewidth=1.6)
axes[1].set(xlabel="frequency (Hz)", ylabel="relative amplitude", xticks=active * fundamental, ylim=(0, 1.1), title="Active indices {2, 4, 6} have gcd = 2")
fig.suptitle("Equation 1.6: frepeat = gcd(2, 4, 6) f0 = 200 Hz", fontweight="bold")
save(fig, "eq-1-6-gcd-repetition.svg")

# Equation 1.7: exact worked three-harmonic signal and spectrum.
fundamental = 110.0
amplitudes = np.array([1.0, 0.5, 0.25])
t_short = np.linspace(0.0, 0.030, 2400, endpoint=False)
components = amplitudes[:, None] * np.sin(2 * np.pi * np.arange(1, 4)[:, None] * fundamental * t_short)
summed = components.sum(axis=0)
fig, axes = plt.subplots(1, 2, figsize=(7.2, 3.3), constrained_layout=True)
axes[0].plot(t_short * 1000, summed, color=BLACK, linewidth=1.5)
zero_line(axes[0])
axes[0].set(xlabel="time (ms)", ylabel="x(t)", title="Three-harmonic waveform")
markerline, stemlines, _ = axes[1].stem([110, 220, 330], amplitudes, basefmt=" ")
plt.setp(markerline, color=RED, markersize=6)
plt.setp(stemlines, color=RED, linewidth=1.6)
axes[1].set(xlabel="frequency (Hz)", ylabel="relative amplitude", xticks=[110, 220, 330], ylim=(0, 1.1), title="One-sided amplitude spectrum")
fig.suptitle("Equation 1.7: f0 = 110 Hz, A = [1, 0.5, 0.25], phase = 0", fontweight="bold")
save(fig, "eq-1-7-worked-tone.svg")

print("Generated Chapter 1 formula visuals.")

SOURCE AND OUTPUT

Signal diagrams

The Mermaid sources produce the harmonic-synthesis and Telharmonium signal diagrams.

Output

Harmonic sources pass through level controls into one summed tone
Harmonic-synthesis signal flow.
Telharmonium controls, generators, distribution, and sound output
Telharmonium signal flow.

Source

ch01-harmonic-synthesis.mmd

assets/diagrams/src/ch01-harmonic-synthesis.mmdMermaid

flowchart LR
    F1[Fundamental<br/>f₀] --> G1[Level<br/>A₁]
    F2[Second<br/>harmonic<br/>2f₀] --> G2[Level<br/>A₂]
    F3[Third<br/>harmonic<br/>3f₀] --> G3[Level<br/>A₃]
    FN[Higher<br/>harmonics<br/>kf₀] --> GN[Level<br/>Ak]
    G1 --> SUM((Sum))
    G2 --> SUM
    G3 --> SUM
    GN --> SUM
    SUM --> TONE[Periodic<br/>tone<br/>distinctive<br/>timbre]
ch01-telharmonium-flow.mmd

assets/diagrams/src/ch01-telharmonium-flow.mmdMermaid

flowchart LR
    K[Keyboard<br/>stops] --> R[Rheotomes<br/>periodic<br/>signals]
    R --> C[Combine<br/>level]
    C --> L[Telephone-line<br/>distribution]
    L --> H[Remote<br/>horn]
    H --> A[Audible<br/>tone]

SOURCE AND OUTPUT

Cumulative Rust renderer

The chapter program uses the shared oscillator library to render the melody and fixed-pitch timbre call-and-response.

Output

wrote ch01-melody-study.wav

Source

ch01_harmonics.rs

assets/rust/src/bin/ch01_harmonics.rsRust

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

use wavetable_synthesis_exercises::{append_silence, render_phrase, write_wav, Note, PhraseStyle};

fn main() -> Result<(), Box<dyn Error>> {
    let output = std::env::args_os()
        .nth(1)
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("ch01-harmonic-melody.wav"));
    if let Some(parent) = output
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
    {
        std::fs::create_dir_all(parent)?;
    }

    // Opening of the traditional French melody "Ah! vous dirai-je, maman".
    // This is a new monophonic teaching rendition, not a borrowed recording or arrangement.
    let melody = [
        Note::new(60, 1.0),
        Note::new(60, 1.0),
        Note::new(67, 1.0),
        Note::new(67, 1.0),
        Note::new(69, 1.0),
        Note::new(69, 1.0),
        Note::new(67, 2.0),
        Note::new(65, 1.0),
        Note::new(65, 1.0),
        Note::new(64, 1.0),
        Note::new(64, 1.0),
        Note::new(62, 1.0),
        Note::new(62, 1.0),
        Note::new(60, 2.0),
    ];

    let mut audio = render_phrase(&melody, &[1.0], 108.0, PhraseStyle::detached(0.92));
    append_silence(&mut audio, 0.75);
    audio.extend(render_phrase(
        &melody,
        &[1.0, 0.5, 0.25, 0.125],
        108.0,
        PhraseStyle::detached(0.92),
    ));
    append_silence(&mut audio, 0.75);

    // Original eight-note timbre call-and-response at one fixed pitch.
    for index in 0..8 {
        let recipe: &[f32] = if index % 2 == 0 {
            &[1.0, 0.5, 0.25]
        } else {
            &[1.0, 0.0, 0.33, 0.0, 0.2]
        };
        audio.extend(render_phrase(
            &[Note::new(57, 0.5)],
            recipe,
            108.0,
            PhraseStyle::detached(0.78),
        ));
    }

    write_wav(&output, &audio)?;
    println!("wrote {}", output.display());
    Ok(())
}
Shared lib.rs

assets/rust/src/lib.rsRust

//! Small cumulative DSP helpers for Chapters 1–2 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
}

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 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);
    }
}
Cargo.toml

assets/rust/Cargo.tomlTOML

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

[dependencies]
hound = "3.5"

Chapter 1 Answers and Fault Invariants

Chapter 1 mathematical-practice answers

  1. 5ms=0.005s5\ \mathrm{ms}=0.005\ \mathrm{s}, so f=1/0.005=200f=1/0.005=200 Hz.
  2. The values are 1,0,1,01,0,-1,0 respectively.
  3. 82.5, 165, 247.5, 330, and 412.5 Hz.
  4. x(t)=sin(2πf0t)+0.5sin(2π3f0t)x(t)=\sin(2\pi f_0t)+0.5\sin(2\pi3f_0t); spectrum lines occur at f0f_0 with height 1 and 3f03f_0 with height 0.5.
  5. The first recipe contains harmonics 1–4; the second contains only 1 and 3. After level matching, the first will usually be predicted brighter because it retains harmonics 2 and 4. Listening language may differ; the spectral difference may not.
  6. gcd(2,4,6)=2\gcd(2,4,6)=2, so the lowest repetition is 200 Hz and its period is 5 ms.
  7. Require 440k<24,000440k<24{,}000, so k<54.545k<54.545\ldots and the largest integer harmonic is 54.
  8. A valid response identifies every axis or panel, copies the stated parameter values, and links one visual feature to the formula. Examples include period shrinking as frequency rises, equal harmonic-index steps adding f0f_0, and the 2/4/6 sum repeating at 2f02f_0.

Chapter 1 readiness answers

  1. A=0.4A=0.4; f=250f=250 Hz; ϕ=π/3\phi=\pi/3 radians; T=1/250=0.004T=1/250=0.004 s = 4 ms; ω=2πf=500π\omega=2\pi f=500\pi rad/s.
  2. 110, 220, 330, 440, 550, and 660 Hz. The first overtone is 220 Hz, the second harmonic.
  3. One valid answer is x(t)=sin(2πf0t)+0.5sin(2π2f0t)+0.25sin(2π3f0t)x(t)=\sin(2\pi f_0t)+0.5\sin(2\pi 2f_0t)+0.25\sin(2\pi 3f_0t).
  4. Each line gives a component frequency and magnitude under the declared normalization. A magnitude-only plot omits phase and temporal envelopes.
  5. Sustained tuning forks generate components; matched resonators and keyboard-operated shutters control audibility; sound pressure in the room sums the active components.
  6. Broadly additive: Cahill combined harmonic-related electrical components. Not equivalent to modern sinusoidal additive synthesis: rheotome currents were nonsinusoidal, required smoothing, and did not expose the general independent sinusoidal parameter set of Equation 1.5.

Chapter 1 faded and musical-station invariants

Chapter 1 fault invariant

Bibliography

Cahill, Thaddeus. 1897a. “Telharmonium Patent US580035A, Selected Drawing.” Wikimedia Commons. 1897. https://commons.wikimedia.org/wiki/File:US580035A_Thaddeus_Cahill,_Telharmonium_patent_p.06.jpg.
———. 1897b. Art of and apparatus for generating and distributing music electrically. US 580,035. United States Patent, issued April 6, 1897. https://patents.google.com/patent/US580035A/en.
Helmholtz, Hermann L. F. von. 1885. On the Sensations of Tone as a Physiological Basis for the Theory of Music. Translated by Alexander J. Ellis. Second English edition. Longmans, Green,; Co. https://archive.org/details/onsensationsofto00helmrich.
“Helmholtz Synthesizer (Koenig).” n.d. University of Toronto Scientific Instruments Collection. Accessed August 11, 2026. https://utsic.utoronto.ca/wpm_instrument/helmholtz-synthesizer/.
Jane023. 2012. “Sound Synthesizer After Helmholtz by Koenig, 1865.” Wikimedia Commons. 2012. https://commons.wikimedia.org/wiki/File:Sound_synthesizer_after_Helmholtz_by_Koenig_1865.jpg.
McAdams, Stephen. 2019. “The Perceptual Representation of Timbre.” In Timbre: Acoustics, Perception, and Cognition, edited by Kai Siedenburg, Charalampos Saitis, Stephen McAdams, Arthur N. Popper, and Richard R. Fay, 23–57. Springer. https://doi.org/10.1007/978-3-030-14832-4_2.
Mozart, Wolfgang Amadeus. 1778. “12 Variations on ‘Ah, Vous Dirai-Je Maman,’ k.265/300e.” International Music Score Library Project. 1778. https://imslp.org/wiki/12_Variations_on_%27Ah%2C_vous_dirai-je_maman%27%2C_K.265%2F300e_(Mozart%2C_Wolfgang_Amadeus).
Puckette, Miller. 2007. The Theory and Technique of Electronic Music. World Scientific. http://msp.ucsd.edu/techniques/latest/book.pdf.
Rees, Torben. 2010. “Helmholtz’s Apparatus for the Synthesis of Sound: An Electrical ‘Talking Machine’.” Whipple Museum of the History of Science, University of Cambridge. 2010. https://www.whipplemuseum.cam.ac.uk/explore-whipple-collections/acoustics/hermann-von-helmholtz/helmholtzs-apparatus-synthesis-sound.
Smith, Julius O. III. 2011. Spectral Audio Signal Processing. W3K Publishing. https://ccrma.stanford.edu/~jos/sasp/.
Unknown photographer. 1906. “Console for the Telharmonium in the Cabot Street Music Plant.” Wikimedia Commons. 1906. https://commons.wikimedia.org/wiki/File:Console_for_the_Telharmonium_in_the_Cabot_St_Music_Plant_of_the_New_England_Electric_Music_Company,_Holyoke,_Massachusetts.jpg.
Wolfe, Joe. n.d. “Spectrum, Harmonics and Timbre.” Physclips, School of Physics, UNSW Sydney. Accessed August 11, 2026. https://www.animations.physics.unsw.edu.au/jw/timbre-spectrum.htm.