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:
- identify amplitude, frequency, phase, and period in a sinusoid;
- calculate harmonic frequencies from a fundamental;
- distinguish partial, harmonic, fundamental, and overtone;
- construct and read a finite additive-synthesis recipe;
- connect harmonic balance to steady-state timbre without reducing all timbre to a static spectrum;
- explain the Helmholtz/Koenig apparatus and Telharmonium as causal signal systems;
- qualify rather than repeat the claim that the Telharmonium was additive synthesis;
- solve cycle, harmonic-index, finite-sum, and Nyquist problems;
- explain every numbered formula from its adjacent plot;
- render a short melody and timbre ostinato with the cumulative Rust exercises.
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.).
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:
| Symbol | Meaning | Unit |
|---|---|---|
| time | seconds | |
| peak amplitude | normalized linear amplitude | |
| frequency | hertz | |
| initial phase | radians |
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 shifts the cycle. A horizontal arrow marks one period.
The argument of the sine function is an angle. The term advances by radians for every completed cycle. Phase chooses where in that cycle the signal starts. The same component can also be written with angular frequency :
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 is the amount of time required for one repetition. A sinusoid repeats after its phase advances by :
Cancel :
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,
For A4 at 440 Hz, one cycle lasts approximately
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 plus components at integer multiples of that fundamental:
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 Hz, the first six harmonic frequencies are:
110, 220, 330, 440, 550, 660 Hz
The vocabulary is easy to misuse:
- A partial is any component of a tone.
- A harmonic partial lies at an integer multiple of .
- The fundamental is the first harmonic.
- The first overtone is normally the component above the fundamental, therefore the second harmonic.
- An overtone need not be harmonic in every physical instrument.
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:
| Symbol | Meaning |
|---|---|
| number of modeled harmonics | |
| amplitude of harmonic | |
| frequency of harmonic | |
| phase of harmonic |
Every term completes an integer number of cycles during the fundamental period . Their sum therefore repeats after , unless a particular selection of terms implies an even shorter fundamental period.
Figure provenance. Author-generated by
assets/figures/src/ch01_harmonics.py from Equation 1.5. The
plotted zero-phase amplitudes are
.
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.
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:
- Time domain: how amplitude changes over time.
- 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 , with height . A magnitude-only display does not preserve , and it does not describe the attack, decay, or later variation of . Those omissions are why a spectrum is powerful without being a complete description of musical timbre (Wolfe n.d.; McAdams 2019).
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 . The dark row contains every harmonic with the faster decrease . All waveforms repeat at the same fundamental rate, but their shapes and brightness differ.
A static spectrum is not complete timbre
Changing changes steady-state colour, but a complete account of timbre also includes:
- the attack and release;
- how partial amplitudes evolve;
- noise and inharmonic components;
- phase relations when they affect waveform shape or transients;
- register, loudness, articulation, and musical context.
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).
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).
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.
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:
- A periodic tone can be represented as a repeatable cycle.
- A complicated cycle can be analyzed or constructed through components.
- Component balance and control are central to timbre.
- 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 radians:
| Cycle position | Angle | |
|---|---|---|
| start | ||
| one quarter | ||
| one half | ||
| three quarters | ||
| one complete cycle |
For , the quantity tells us the current angle. Frequency says how many complete journeys occur each second; phase says where the first journey begins.
Unit reasoning
A hertz is one cycle per second. Keep units visible:
Use and . Convert before taking a reciprocal.
Coefficient lists as equations and spectra
A program often stores amplitudes as a list. For
list position 1 controls harmonic 1, position 2 controls harmonic 2, and position 3 controls harmonic 3. With zero phases,
The zero coefficient removes the line at ; it does not shift later list positions. The one-sided magnitude spectrum has lines at and with relative heights 1.0 and 0.5.
When the declared fundamental is not the lowest repetition
Let be the set of active harmonic numbers and let , the largest integer that divides every member of . The lowest repetition frequency of the selected sum is
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 : the waveform repeats at , even if the recipe was indexed relative to a declared . If harmonic 1 is active, the greatest common divisor is necessarily 1.
Mathematical practice 1
Show units and intermediate steps.
- A cycle lasts 5 ms. Find its frequency in hertz.
- Evaluate at and .
- List the first five harmonics of Hz.
- Convert into a zero-phase equation and a two-line spectrum description.
- Compare recipes and . Which harmonic lines differ, and which do you predict will sound brighter after level matching?
- A recipe relative to Hz contains only harmonics 2, 4, and 6. Find its lowest repetition frequency and period.
- 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?
- 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 Hz, amplitudes
and zero starting phase.
The component frequencies are:
The signal is:
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:
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:
- sine;
- equal-amplitude odd harmonics;
- saw-like harmonic roll-off;
- darker roll-off.
All use Hz and were normalized to the same RMS target before short fades were applied.
Procedure
- Keep the playback level fixed.
- For each tone, write three words describing the steady-state colour.
- Rank them from least to most bright.
- State whether the perceived pitch changes.
- Inspect the same-pitch spectrum figure and connect your brightness ranking to high-harmonic energy.
- 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.
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.
Try these checks
- Set harmonic 5 to 0.50. Name the only spectrum line that must change.
- Choose Sine, then Odd only. Keep the fundamental fixed and compare pitch with colour.
- Play the melody with two recipes. Identify what remains invariant.
- 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:
- the opening of the traditional French melody Ah! vous dirai-je, maman as a sine;
- the same notes, rhythm, tempo, and articulation with amplitudes ;
- 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
- Which information makes the two melody passes recognizably the same?
- Which pass makes repeated notes easier to separate, and is that caused by spectrum or articulation?
- Does the four-harmonic recipe change melodic contour, pitch center, or only tone colour?
- In the final ostinato, can alternating timbre alone create a call and response?
- 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.pyVerify 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.wavThe 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:
- verify
period_seconds(250.0)andharmonic_frequency(110.0, 5)by hand; - trace one phase increment
through
SineOscillator::tick; - change one amplitude coefficient and predict the spectrum before rendering;
- add a sixth harmonic, then state when it would cross Nyquist as pitch rises;
- transpose the melody while preserving its rhythm and coefficient lists;
- design a third four-coefficient recipe that is darker than 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 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
- Complete the frequencies without returning to Equation 1.4.
- Write the four-term signal, leaving the common factor visible.
- Sketch its one-sided amplitude spectrum.
- 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.
- Make Tone A from harmonics 1, 2, and 3.
- Make Tone B from harmonics 1, 3, and 5.
- Match their fundamental, duration, and approximate RMS level.
- Perform or render an eight-event phrase alternating A and B while keeping pitch fixed.
- Repeat with the same rhythm but let pitch move while timbre remains fixed.
- 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.
- For , identify amplitude, frequency, phase, period, and angular frequency.
- A periodic tone has Hz. List its first six harmonics and identify the first overtone.
- Write a finite three-harmonic signal with amplitudes 1, 0.5, and 0.25 and zero phases.
- What does each line in a one-sided amplitude spectrum encode, and what does a magnitude-only plot omit?
- Explain the Koenig-built Helmholtz apparatus as a signal-flow system.
- Is the Telharmonium modern additive synthesis? Give the two-part answer supported by historical evidence.
Chapter 1 invariants
- Frequency is the reciprocal of period.
- Harmonic lies at .
- The fundamental is harmonic 1; the first overtone is normally harmonic 2.
- A finite additive model sums controlled sinusoidal components.
- A magnitude spectrum omits phase and temporal development.
- Harmonic balance strongly affects steady-state timbre but does not exhaust timbre.
- Historical terminology must be qualified against the actual mechanism.
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; . |
| 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 . |
| 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; . |
| 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
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
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
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.wavSource
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
//! 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(¬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);
}
}
Cargo.toml
[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
- , so Hz.
- The values are respectively.
- 82.5, 165, 247.5, 330, and 412.5 Hz.
- ; spectrum lines occur at with height 1 and with height 0.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.
- , so the lowest repetition is 200 Hz and its period is 5 ms.
- Require , so and the largest integer harmonic is 54.
- 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 , and the 2/4/6 sum repeating at .
Chapter 1 readiness answers
- ; Hz; radians; s = 4 ms; rad/s.
- 110, 220, 330, 440, 550, and 660 Hz. The first overtone is 220 Hz, the second harmonic.
- One valid answer is .
- Each line gives a component frequency and magnitude under the declared normalization. A magnitude-only plot omits phase and temporal envelopes.
- Sustained tuning forks generate components; matched resonators and keyboard-operated shutters control audibility; sound pressure in the room sums the active components.
- 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
- The frequencies are 220, 440, 660, and 880 Hz.
- One valid signal is .
- Removing harmonic 2 deletes only the 440 Hz spectrum line in this model; it does not change the declared fundamental.
- Acceptable musical proof includes two level-matched same-pitch recipes, an eight-event render or performance, and a written comparison of which dimension carries phrase identity.
Chapter 1 fault invariant
- The first overtone is not the first harmonic under normal counting.
- Not every partial is harmonic.
- The patent does not describe six independently arbitrary perfect sine oscillators.
- The historical and modern meanings of additive synthesis must remain distinct.