Making Electronic Oscillation Performable
Chapter contract
Prerequisites. Chapter 1 invariants, algebraic rearrangement, and the ability to distinguish pitch from amplitude while listening.
By the end of this chapter, you should be able to:
- predict how inductance and capacitance affect an LC resonant frequency;
- separate the variable RF oscillator from the audible difference frequency;
- derive sum and difference components from nonlinear multiplication;
- distinguish linear beating from heterodyne frequency conversion;
- describe independent pitch and amplitude gesture mappings;
- distinguish vibrato, portamento, glissando, and articulation;
- compare the theremin and Ondes Martenot as different solutions to performability;
- solve LC-ratio, frequency-difference, cents, interpolation, and phase-accumulation problems;
- explain every numbered formula from its adjacent plot;
- extend the Chapter 1 Rust oscillator into a gesture and articulation renderer.
The problem: how does a body control an invisible oscillator?
Hold one hand near a pitch antenna and the other near a volume loop. Move either hand without touching the instrument. Pitch or loudness changes continuously.
How can body position control an oscillator without keys or contact?
Lev Termen developed his instrument around 1919–20. Public demonstrations followed by 1920–21, depending on which event a source dates. His instrument turned the performer’s proximity into control of electronic oscillators. It became known as the theremin. Because “first electronic instrument” depends on whether electromechanical predecessors, prototypes, and commercial availability are included, this book uses the narrower description one of the earliest fully electronic musical instruments (Verhulst n.d.b; Skeldon et al. 1998; Alonso-Pérez and Batista n.d.).
Image credit and licence. Bettmann/Corbis photograph, December 1927, listed as public domain by Wikimedia Commons (Bettmann / Corbis 1927). Recheck territory-specific status before external commercial publication; the manifest records this release gate.
Long description. Lev Termen stands beside a wooden electronic instrument. His right hand is held near a vertical rod, while his left hand hovers above a loop-shaped antenna. There is no keyboard. The spacing between hand and antenna is the performance input.
The theremin joins three models:
- a resonant electronic oscillator;
- a nonlinear frequency converter;
- a learned gesture-to-music mapping.
Sustained LC oscillation
An ideal inductor–capacitor resonator exchanges energy between the capacitor’s electric field and the inductor’s magnetic field. Its natural frequency is:
| Symbol | Meaning | Unit |
|---|---|---|
| inductance | henries | |
| effective capacitance | farads | |
| resonant frequency | hertz |
Long description. The curve falls from about 712 kHz at 50 pF toward 225 kHz at 500 pF. Markers show 503.3 kHz at 100 pF and 251.6 kHz at 400 pF. Quadrupling capacitance halves frequency when inductance stays fixed.
A real oscillator adds active feedback to replace losses. Without it, an idealized LC tank is only a resonator and a real one rings down.
Hold fixed and increase . Frequency falls:
The performer’s hand and body contribute a small distributed capacitance involving the antenna, instrument ground, room, and nearby objects. This is not literally an ideal pair of parallel plates. The approximation is useful, but the real field is more complicated. Reported changes are commonly fractions of a picofarad to a few picofarads (Skeldon et al. 1998; Alonso-Pérez and Batista n.d.).
RF frequency is not audible pitch
Approaching the pitch antenna increases effective capacitance and lowers the variable radio-frequency oscillator’s own frequency. Yet the heard pitch can rise. Why? The output is not that RF oscillator directly. The instrument produces the difference between a variable RF oscillator and a fixed RF oscillator. Depending on how the pair is tuned, lowering the variable oscillator can increase their separation.
Confusing RF frequency with audible difference frequency is a common conceptual failure.
Heterodyning: make a small difference audible
Start with 500 and 501 kHz:
difference: 1 kHz
sum: 1001 kHz
A nonlinear mixer creates both components. A low-pass filter keeps the audible 1 kHz difference and rejects the RF sum.
Let the two oscillators be
The mixer multiplies them. The product-to-sum identity gives
Figure provenance. Author-generated by
assets/figures/src/ch02_heterodyne.py. Frequencies are
scaled so individual oscillations remain visible.
Long description. The top panel overlays the two inputs. The middle panel shows their product and its slow 1.5 difference component. The bottom spectrum has lines at 1.5 and 21.5; the shaded low-pass region keeps only the difference.
Therefore,
Long description. The upper V-shaped curve shows absolute difference falling to zero when both oscillators equal 500 kHz, then rising again. The lower line shows the sum increasing from 998 to 1002 kHz.
Circuit frequencies differ among designs. The 500/501 kHz pair is a teaching example, not a universal theremin specification (Skeldon et al. 1998; Alonso-Pérez and Batista n.d.).
Beats are related but not identical
Add the same two sinusoids instead of multiplying them:
Long description. The upper panel shows a fast linear sum bounded by a slow dashed envelope. The lower spectrum contains only the two input lines at 10 and 11.5. There is no line at the 1.5 difference frequency.
The amplitude envelope rises and falls at a rate related to the frequency difference, so we hear beating. But a linear spectrum still contains only and . A nonlinear mixer is required to place actual spectral energy at . This distinction will return when we study ring modulation, intermodulation, and spectral distortion.
The theremin signal path
Long description. The pitch hand changes hand–antenna capacitance, which changes a variable RF oscillator. That oscillator and a fixed RF oscillator enter a nonlinear mixer. A low-pass filter keeps the difference component. The theremin’s separate volume-hand and loop-antenna path controls amplitude before the speaker.
The canonical two-antenna theremin separates two musical dimensions:
- Pitch hand: proximity to the vertical rod changes the difference frequency.
- Volume hand: proximity to the horizontal loop changes amplitude and can reach silence.
Circuit details vary. The design principle is independent control. Continuous pitch without independent amplitude would expose every move between notes as a glide. Amplitude control creates attacks, releases, accents, separation, and rests (Skeldon et al. 1998; Verhulst n.d.b).
Gesture is a mapping, not a natural scale
A classic theremin does not contain invisible piano keys. The relation from hand distance to pitch is continuous, nonlinear, and affected by calibration, body position, component drift, and the surrounding environment.
Figure provenance and limitation. Author-generated
by assets/figures/src/ch02_heterodyne.py. The first curve
uses a parallel-plate-like inverse-distance analogy and the remaining
curves are normalized pedagogical mappings. The rising
audible-difference curve assumes the variable oscillator is tuned below
the fixed oscillator, so a larger downward RF shift increases their
separation. This is not a calibration curve for a
specific theremin.
Long description. Three panels show that decreasing hand distance can increase capacitance nonlinearly; increased capacitance increases the magnitude of a downward shift in the variable RF oscillator; and, under the stated below-fixed tuning, the audible difference frequency rises. Equal musical intervals then require a logarithmic rather than linear frequency interpretation. The chain explains why physical uniformity does not guarantee musically uniform spacing.
The performer learns a closed feedback loop:
intend pitch → place hand → hear result → correct position
The instrument makes intonation part of continuous motor control. In the high register, equal musical intervals can occupy increasingly compressed physical distances.
Pitch as a trajectory
Continuous control makes several musical gestures possible. Keep the vocabulary distinct:
- Vibrato: periodic variation around a target pitch.
- Portamento: continuous travel between two target pitches.
- Glissando: an audible sweep through the intervening pitch region; usage overlaps with portamento but often emphasizes the sweep itself.
- Articulation: shaping the onset, connection, emphasis, and release of events, primarily through amplitude in this design.
A convenient frequency model for vibrato measured in cents is:
Long description. Frequency rises smoothly and slightly curves upward as cent offset moves from −100 to +100. Three markers show that equal positive and negative cent distances form reciprocal frequency ratios rather than equal hertz differences.
where is the center frequency and is the time-varying cent offset. For sinusoidal vibrato,
Long description. The cent trajectory completes five and a half cycles in one second. Dashed horizontal lines mark the ±25-cent limits. The curve crosses zero at the center pitch twice per vibrato cycle.
with depth cents and vibrato rate hertz.
Vibrato does not repair an unknown center pitch. It oscillates around whatever center the performer is actually holding.
The Ondes Martenot: preserve continuity, add landmarks and articulation
Maurice Martenot also used heterodyne generation, but treated performability as an evolving instrument-design problem. The Ondes Martenot premiered at the Paris Opéra on 3 May 1928. Early versions used wire/ring control; around 1930, commonly associated with model no. 4 onward, a seated keyboard became an additional pitch mechanism. Sources often group the instrument into seven principal models through 1975, but hand-built variations make that count a useful classification rather than a perfectly uniform production sequence (Verhulst n.d.a).
Image credit and licence. Photograph by 30rKs56MaE, 2006. Creative Commons Attribution–ShareAlike 3.0 (30rKs56MaE 2006).
Long description. A long wooden instrument has a piano-style keyboard, a control drawer to the performer’s left, and a wire/ring pitch-control path in front of the keys. The keyboard provides landmarks while the ring mechanism preserves continuous pitch movement.
Two pitch interfaces
The mature instrument offers two complementary approaches:
- Mobile keyboard. Discrete visual and tactile landmarks support intonation, while lateral key motion permits pitch inflection and vibrato.
- Ring and wire. A finger ring mechanically moves along a wire or cable aligned with a pitch reference, supporting continuous glissando, portamento, microtonal inflection, and vibrato.
Calling the second interface a modern ribbon controller obscures its mechanism. It is historically a ring-and-wire linkage.
The intensity key
The right hand selects pitch; the left hand shapes loudness through the spring-loaded touche d’intensité. Its pressure and travel control attack, sustain, accent, and release. This is not merely an on/off key. It is an early haptic amplitude controller (Quartier et al. 2015).
Timbre through controls and diffuseurs
The control drawer selects tone colours and outputs. Martenot also developed specialized loudspeakers or diffuseurs. Different units could provide direct sound, sympathetic-string resonance, metallic resonance, or other sustained colour. Exact first-use dates vary among prototypes, patents, and surviving objects, so this chapter avoids a false tidy chronology. The important architecture is:
pitch interface → heterodyne generator → intensity/timbre controls
→ selected output or diffuseur
The loudspeaker is part of synthesis. A resonant diffuser transforms the spectrum and decay instead of merely making an unchanged signal louder (Verhulst n.d.a; Najnudel et al. 2023).
Comparing the two instruments as interface design
| Design question | Theremin | Ondes Martenot |
|---|---|---|
| How is pitch located? | Free-space hand position | Keyboard or ring/wire |
| How is loudness shaped? | Separate volume antenna | Pressure-sensitive intensity key |
| How is intonation supported? | Auditory/proprioceptive learning | Landmarks plus auditory feedback |
| How is timbre extended? | Circuit and amplifier character | Timbre controls and diffuseurs |
Each interface solves a different problem. The theremin maximizes continuous free-space control. The Ondes adds landmarks, tactile resistance, explicit articulation, and resonant outputs.
First-principles lessons for later wavetable instruments
These instruments establish principles that will reappear in contemporary synthesis:
- An oscillator and its controller are different systems.
- Raw sensor space is not automatically musical parameter space.
- Pitch and amplitude need independent trajectories.
- Smoothing, calibration, and feedback determine playability.
- The output resonator or filter participates in timbre.
- Continuous control creates expression and instability at the same time.
A future wavetable-position control will face the same design problem as the pitch antenna: should equal physical movement produce equal frame-index movement, equal spectral change, or approximately equal perceived change?
Mathematical concepts 2: ratios, differences, and pitch trajectories
LC scaling by ratio
If capacitance quadruples while inductance stays fixed, resonant frequency halves. For two LC states,
Long description. The frequency ratio falls as capacitance ratio rises. Markers show equal capacitance giving ratio 1 and four times the capacitance giving frequency ratio 0.5.
With fixed inductance, this reduces to . Use the ratio before inserting small farad values.
Difference, sum, and units
Subtraction is meaningful only after units match. For 260.000 kHz and 259.560 kHz,
Multiplication creates both this difference and the 519.560 kHz sum. Linear addition creates neither new spectral line, even though its time-domain envelope beats.
Octaves and cents
220 Hz to 440 Hz is one octave. So is 440 Hz to 880 Hz. Each octave is a 2:1 ratio and contains 1200 cents. A cent displacement uses
Long description. The curve rises exponentially from half the center frequency at −1200 cents, through the center frequency at 0 cents, to twice the center frequency at +1200 cents.
The same cent interval covers more hertz at a higher center frequency. Musical pitch spacing is logarithmic, not linear in hertz.
Logarithmic interpolation
The musical midpoint between 220 and 880 Hz is 440 Hz, not 550 Hz. Equal progress in log-frequency space uses
Long description. Both curves start at 220 Hz and end at 880 Hz. The solid logarithmic path stays below the dashed linear path between the endpoints. At normalized time 0.5, their marked values are 440 and 550 Hz.
Substitute to verify the 440 Hz midpoint.
Discrete phase accumulation
At 48 kHz, a 440 Hz oscillator advances by radians per sample. A changing frequency requires a new increment at every sample:
Long description. The top panel shows 1920 endpoint-inclusive frequency values over sample times 0 through 39.979 ms. The middle panel shows each phase increment. The bottom uses , plots , and applies increment to obtain phase . Cycles compress as frequency rises.
Substituting a changing directly into generally adds an unintended contribution to instantaneous frequency. The Rust oscillator therefore accepts a new frequency every sample while preserving phase.
Mathematical practice 2
- With fixed inductance, capacitance becomes four times larger. Find .
- Find the difference and sum of 260.000 kHz and 259.560 kHz in hertz.
- Explain why adding those two RF sinusoids does not create a 440 Hz spectrum line.
- Calculate frequencies 50 cents below and above A4 = 440 Hz.
- Find the logarithmic midpoint of a glide from 220 to 880 Hz and compare it with the linear-hertz midpoint.
- At Hz and constant Hz, find the phase increment per sample in radians. Explain how Equation 2.10 changes for a glide.
- A fixed oscillator is 300.000 kHz. Find the below-fixed variable frequency for D5 = 587.33 Hz.
- For each plot from Equations 2.1 through 2.10, name the axes or panels, the fixed parameters, and one prediction visible before calculation.
Worked example: heterodyne pitch from oscillator frequencies
A fixed oscillator runs at 260.000 kHz. The variable oscillator is initially 259.560 kHz.
The audible difference is:
The hand increases effective capacitance so that the variable RF frequency falls to 259.340 kHz:
The variable oscillator went down by 220 Hz while the audible difference pitch went up by 220 Hz. This is the distinction between the RF state and the musical output state.
Listening station 2: steady pitch, glide, vibrato, articulation
Open the Chapter 2 listening file:
assets/audio/ch02/ch02-continuous-control-studies.wav
The studies are separated by silence:
- steady 330 Hz reference;
- logarithmic glide from 220 to 660 Hz;
- 5.5 Hz vibrato around 440 Hz with a depth of ±25 cents;
- steady 392 Hz with independent amplitude articulation.
Procedure
- Draw pitch versus time for each segment without using a spectrum analyzer.
- Draw amplitude versus time separately.
- Identify which segment changes pitch but not intended amplitude.
- Identify which changes amplitude but not intended pitch.
- Describe the musical function of the silence between segments.
- For the vibrato example, estimate whether rate or depth is more immediately noticeable.
The exercise trains separation of dimensions. A wavetable synthesizer later adds a third trajectory: timbre position. The ear must distinguish it from pitch and level.
Song study 2: one phrase, two articulations
LAB 02
Turn oscillator difference into gesture
Hear addition and multiplication, then shape a continuous pitch path.
Difference: 440 HzSum: 1560 Hz
Pitch moves logarithmically from 220 to 880 Hz with 18-cent vibrato at 5.0 Hz. Articulation is continuous.
Try these checks
- Set the variable oscillator to 780 Hz. Predict the difference before reading it.
- Compare addition with multiplication. Listen for the new difference component.
- Set vibrato depth to zero. The glide remains continuous because phase still accumulates.
- Switch only articulation. Decide whether the phrase changes pitch path, envelope, or both.
Read the exact source running this lab
This TypeScript implements Equations 2.3, 2.8, 2.9, and 2.10.
export const SAMPLE_RATE = 48_000;
const TAU = 2 * Math.PI;
export function heterodyneComponents(aHz: number, bHz: number) {
return { differenceHz: Math.abs(aHz - bHz), sumHz: aHz + bHz };
}
export function centsRatio(cents: number) {
return 2 ** (cents / 1200);
}
export function logFrequencyLerp(startHz: number, endHz: number, position: number) {
const u = Math.max(0, Math.min(1, position));
return startHz * (endHz / startHz) ** u;
}
export function renderHeterodyne(
multiply: boolean,
fixedHz = 1000,
variableHz = 560,
seconds = 1.4
) {
const frameCount = Math.round(seconds * SAMPLE_RATE);
const output = new Float32Array(frameCount);
const fadeFrames = Math.round(0.012 * SAMPLE_RATE);
for (let frame = 0; frame < frameCount; frame++) {
const time = frame / SAMPLE_RATE;
const fixed = Math.cos(TAU * fixedHz * time);
const variable = Math.cos(TAU * variableHz * time);
const signal = multiply ? fixed * variable : 0.5 * (fixed + variable);
const envelope = Math.min(1, frame / fadeFrames, (frameCount - 1 - frame) / fadeFrames);
output[frame] = 0.18 * envelope * signal;
}
return output;
}
export function frequencyTrajectory(
startHz: number,
endHz: number,
vibratoCents: number,
vibratoHz: number,
points = 480
) {
return Array.from({ length: points }, (_, index) => {
const position = index / (points - 1);
const baseHz = logFrequencyLerp(startHz, endHz, position);
return baseHz * centsRatio(vibratoCents * Math.sin(TAU * vibratoHz * 2 * position));
});
}
export function renderGesture(
startHz: number,
endHz: number,
vibratoCents: number,
vibratoHz: number,
detached: boolean
) {
const seconds = 2;
const frameCount = seconds * SAMPLE_RATE;
const output = new Float32Array(frameCount);
let phase = 0;
for (let frame = 0; frame < frameCount; frame++) {
const time = frame / SAMPLE_RATE;
const position = frame / (frameCount - 1);
const baseHz = logFrequencyLerp(startHz, endHz, position);
const frequencyHz = baseHz * centsRatio(vibratoCents * Math.sin(TAU * vibratoHz * time));
const sample = Math.sin(phase) + 0.16 * Math.sin(2 * phase);
phase = (phase + TAU * frequencyHz / SAMPLE_RATE) % TAU;
let envelope = Math.min(1, frame / (0.012 * SAMPLE_RATE), (frameCount - 1 - frame) / (0.012 * SAMPLE_RATE));
if (detached) {
const localTime = time % 0.4;
envelope *= localTime < 0.3
? Math.min(1, localTime / 0.008, (0.3 - localTime) / 0.008)
: 0;
}
output[frame] = 0.18 * envelope * sample / 1.16;
}
return output;
}
Open the Rust Chapter 2 gesture study. It begins with two one-second scaled examples separated by silence. Linear addition of 1000 and 560 Hz retains only those two spectrum lines; multiplication of the same pair produces components at 440 and 1560 Hz. A 48 kHz renderer cannot represent the historical RF oscillators directly, so these segments demonstrate the algebra rather than a literal theremin circuit.
The next two passes use the public-domain NEW BRITAIN pitch incipit
5-1-3-1-3-2-1-6-5-5, commonly associated with Amazing
Grace. The Library of Congress documents the tune’s 1835 pairing
with John Newton’s text; the exercise uses no lyrics and no modern
arrangement (“Timeline
of the Song Amazing Grace” n.d.; “Tune: New
Britain” n.d.). Durations are deliberately
simplified.
- Continuous pass: portamento near each boundary, nearly continuous amplitude, final-note vibrato.
- Articulated pass: the same pitch centers and durations, amplitude closure between events, an accent on the fifth event, and final-note vibrato.
Listening questions
- Which pass makes the number of note events easiest to count?
- Which pass makes the phrase feel like one continuous gesture?
- Does the accent change pitch, amplitude, or both?
- Can you hear the final vibrato as motion around a center rather than travel to a new note?
- Draw separate pitch and amplitude trajectories for both passes.
- Name one musical situation where you would choose each articulation.
Figure lab 2: inspect and alter continuous control
Run:
python3 assets/figures/src/ch02_heterodyne.pyVerify regeneration of:
assets/figures/svg/ch02-heterodyne-principle.svg
assets/figures/svg/ch02-gesture-to-pitch.svg
assets/audio/ch02/ch02-continuous-control-studies.wav
Then make one change at a time:
- change vibrato depth while holding rate fixed;
- restore depth and change rate;
- change the glide from logarithmic frequency interpolation to linear frequency interpolation.
Listen for how “equal progress through time” differs between linear hertz and logarithmic musical pitch.
Rust lab 2: build on the Chapter 1 oscillator
Run:
cargo run -p wavetable-synthesis-exercises \
--bin ch02_gesture -- /tmp/ch02.wavThen work in order:
- verify the 440 Hz difference and 599.560 kHz sum printed by the program;
- explain why the audio demonstration uses 1000 and 560 Hz instead of real RF values;
- change
final_portionin the legato style and predict where each glide begins; - move
accent_notewhile leaving the pitch list unchanged; - double vibrato depth, then restore depth and double rate in
render_phrase; - replace logarithmic interpolation with linear hertz temporarily, compare, and restore the correct version;
- add a fifth note to both passes without duplicating oscillator or WAV-writing code.
This lab reuses Chapter 1’s phase accumulator, additive sample function, note representation, and WAV writer. The new work is trajectory control, not a second synthesizer.
Faded station 2: choose the variable oscillator
A fixed RF oscillator runs at 300.000 kHz. Choose a variable oscillator below it to produce each audible target:
A4: 440.00 Hz
C5: 523.25 Hz
E5: 659.25 Hz
- Calculate all three variable RF frequencies.
- Explain why the variable oscillator moves downward as the audible note rises in this arrangement.
- State what the low-pass filter must reject.
- Check one answer by reversing the subtraction.
Musical application 2: separate pitch from articulation
Draw or perform a four-note phrase twice.
- In version A, connect every pitch with continuous portamento and keep amplitude nearly constant.
- In version B, use the same pitch centers but close the amplitude between notes and accent the third note.
- Add a small vibrato only to the final sustained note in both versions.
- Record separate pitch-versus-time and amplitude-versus-time sketches.
- Ask a listener which version communicates four events more clearly and which feels more continuous.
This is the interface lesson of the theremin and Ondes Martenot: musical phrasing emerges from coordinated but independently controllable trajectories.
Fault station 2: diagnose the signal model
A student writes:
“The hand blocks a radio beam. More capacitance raises the LC oscillator frequency. Adding two radio-frequency sine waves automatically creates a difference-frequency spectral line. The Ondes Martenot is just a theremin with piano keys.”
Classify and correct every error. Your answer must distinguish the physical sensor, LC relation, linear superposition, nonlinear mixing, and performance interfaces.
Chapter 2 readiness gate
Commit answers before consulting the answer invariants.
- If effective capacitance rises while inductance remains fixed, what happens to the variable RF oscillator, and why may audible theremin pitch nevertheless rise?
- Use Equation 2.2 for 500 kHz and 501 kHz. What mixer components result, and which survives an audio low-pass filter?
- Why can linear addition sound like beating without containing a difference-frequency spectral line?
- Describe the standard two-hand theremin mapping and explain how amplitude control turns continuous pitch into articulated musical events.
- Distinguish vibrato, portamento, and articulation using one gesture example for each.
- Name the two mature Ondes pitch interfaces and state what the intensity key and a resonant diffuser add.
Chapter 2 invariants
- An LC oscillator’s frequency decreases as effective capacitance increases.
- Audible theremin pitch is a difference frequency, not the variable RF frequency itself.
- Nonlinear mixing creates sum and difference components; linear addition creates beats without those new spectral lines.
- Classic theremin pitch is continuous and requires a learned feedback loop.
- Independent amplitude control creates articulation, accents, and silence.
- The Ondes Martenot combines continuous pitch, landmarks, haptic amplitude, timbre selection, and resonant output.
- A controller’s physical coordinate is not automatically a perceptually uniform musical coordinate.
Chapter 2 glossary additions
| Term | Working definition |
|---|---|
| Articulation | Shaping of onset, connection, accent, and release. |
| Beat | Slow amplitude variation caused by linear addition of nearby frequencies. |
| Capacitance | Ability to store electric charge; increased effective lowers LC frequency. |
| Cent | Logarithmic pitch unit; 1200 cents equal one octave. |
| Difference frequency | , created as a spectral component by nonlinear mixing. |
| Diffuseur | Specialized Ondes Martenot output that changes spectrum and decay. |
| Heterodyning | Nonlinear mixing used to create sum and difference frequencies. |
| Inductance | Magnetic energy-storage property denoted and measured in henries. |
| LC oscillator | Feedback oscillator whose resonant frequency depends on and . |
| Logarithmic interpolation | Movement by equal frequency ratios rather than equal hertz steps. |
| Low-pass filter | Stage that passes low frequencies and rejects higher components. |
| Mixer | Nonlinear stage that combines inputs and creates intermodulation products. |
| Phase accumulator | Sample-by-sample sum of phase increments used to generate a waveform. |
| Portamento | Continuous travel between target pitches. |
| Radio frequency (RF) | Oscillator range above the audible output used in the heterodyne system. |
| Sum frequency | , created with the difference component by nonlinear mixing. |
| Vibrato | Periodic pitch variation around a center pitch. |
IMPLEMENTATION NOTEBOOK
Chapter 2 source and generated output
These are the complete sources used for the chapter figures, diagram, listening studies, and cumulative Rust renderer. Each source is shown beside the output it produces.
SOURCE AND OUTPUT
Heterodyne and gesture studies
This Python program generates both control figures and the steady-tone, glide, vibrato, and articulation listening file.
Output
Source
ch02_heterodyne.py
assets/figures/src/ch02_heterodyne.pyPython
#!/usr/bin/env python3
"""Generate Chapter 2 heterodyne/control figures and gesture-listening audio."""
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/ch02"
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",
}
)
RED = "#7F1D1D"
TEAL = "#0F766E"
BLUE = "#1D4ED8"
PURPLE = "#7C3AED"
def normalize_rms(signal: np.ndarray, target: float = 0.17) -> 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: scaled heterodyne example. Frequencies are deliberately low enough to see.
t = np.linspace(0.0, 1.4, 6000, endpoint=False)
f_fixed = 10.0
f_variable = 11.5
fixed = np.cos(2 * np.pi * f_fixed * t)
variable = np.cos(2 * np.pi * f_variable * t)
mixed = fixed * variable
low_component = 0.5 * np.cos(2 * np.pi * (f_variable - f_fixed) * t)
high_component = 0.5 * np.cos(2 * np.pi * (f_variable + f_fixed) * t)
fig, axes = plt.subplots(3, 1, figsize=(7.2, 7.1), constrained_layout=True)
axes[0].plot(t, fixed, color=BLUE, linewidth=0.9, label="fixed oscillator: 10")
axes[0].plot(t, variable, color=TEAL, linewidth=0.9, alpha=0.85, label="variable oscillator: 11.5")
axes[0].set_xlim(0, 0.55)
axes[0].set_ylabel("amplitude")
axes[0].set_title("Two nearby oscillators")
axes[0].legend(loc="upper right", frameon=False, ncol=2)
axes[1].plot(t, mixed, color="#111827", linewidth=0.75, label="mixer output")
axes[1].plot(t, low_component, color=RED, linewidth=1.8, label="difference component: 1.5")
axes[1].set_xlim(0, 1.4)
axes[1].set_ylabel("amplitude")
axes[1].set_title("Multiplication creates a slowly varying difference component")
axes[1].legend(loc="upper right", frameon=False)
markerline, stemlines, baseline = axes[2].stem(
[f_variable - f_fixed, f_variable + f_fixed], [0.5, 0.5], basefmt=" "
)
plt.setp(markerline, color=PURPLE, markersize=5)
plt.setp(stemlines, color=PURPLE, linewidth=1.5)
axes[2].axvspan(0, 5, color="#FEE2E2", alpha=0.8, label="low-pass output region")
axes[2].set_xlim(0, 25)
axes[2].set_ylim(0, 0.62)
axes[2].set_xlabel("frequency in scaled units")
axes[2].set_ylabel("relative amplitude")
axes[2].set_title("The mixer contains difference and sum frequencies")
axes[2].legend(loc="upper right", frameon=False)
fig.suptitle("Heterodyning: the audible pitch is a frequency difference", fontsize=12, fontweight="bold")
fig.savefig(SVG / "ch02-heterodyne-principle.svg", bbox_inches="tight")
plt.close(fig)
# Figure 2: idealized control chain. The capacitance curve is a teaching analogy, not a theremin calibration.
distance = np.linspace(0.12, 1.0, 500)
capacitance = 1.0 / distance
capacitance = (capacitance - capacitance.min()) / (capacitance.max() - capacitance.min())
downward_shift_magnitude = np.sqrt(capacitance + 0.03)
downward_shift_magnitude = (
(downward_shift_magnitude - downward_shift_magnitude.min())
/ (downward_shift_magnitude.max() - downward_shift_magnitude.min())
)
pitch_octaves = np.log2(1.0 + 3.0 * downward_shift_magnitude)
fig, axes = plt.subplots(1, 3, figsize=(8.2, 3.0), constrained_layout=True)
axes[0].plot(distance, capacitance, color=TEAL, linewidth=2)
axes[0].invert_xaxis()
axes[0].set(xlabel="hand approaches antenna →", ylabel="relative capacitance", title="Distance → capacitance")
axes[1].plot(capacitance, downward_shift_magnitude, color=BLUE, linewidth=2)
axes[1].set(
xlabel="relative capacitance",
ylabel="magnitude of downward RF shift",
title="Capacitance → RF shift",
)
axes[2].plot(downward_shift_magnitude, pitch_octaves, color=RED, linewidth=2)
axes[2].set(
xlabel="relative audible difference frequency",
ylabel="pitch in octaves",
title="Difference → octaves",
)
fig.suptitle("Why a playable electronic instrument needs a calibrated gesture map", fontsize=11, fontweight="bold")
fig.savefig(SVG / "ch02-gesture-to-pitch.svg", bbox_inches="tight")
plt.close(fig)
# Listening file: steady tone, portamento, vibrato, and independent volume articulation.
sample_rate = 48_000
silence = np.zeros(int(sample_rate * 0.4))
segments = []
def expressive_tone(frequency: np.ndarray, amplitude: np.ndarray) -> np.ndarray:
phase = 2 * np.pi * np.cumsum(frequency) / sample_rate
tone = np.sin(phase) + 0.22 * np.sin(2 * phase) + 0.08 * np.sin(3 * phase)
return normalize_rms(tone * amplitude)
# Steady reference.
duration = 2.0
time = np.arange(int(sample_rate * duration)) / sample_rate
segments += [fade(expressive_tone(np.full_like(time, 330.0), np.ones_like(time)), sample_rate), silence]
# Continuous glissando: interpolation in log-frequency gives equal octave motion.
duration = 3.0
time = np.arange(int(sample_rate * duration)) / sample_rate
frequency = 220.0 * np.power(660.0 / 220.0, time / duration)
segments += [fade(expressive_tone(frequency, np.ones_like(time)), sample_rate), silence]
# Vibrato, ±25 cents around A4.
duration = 3.0
time = np.arange(int(sample_rate * duration)) / sample_rate
cents = 25.0 * np.sin(2 * np.pi * 5.5 * time)
frequency = 440.0 * np.power(2.0, cents / 1200.0)
segments += [fade(expressive_tone(frequency, np.ones_like(time)), sample_rate), silence]
# Independent amplitude articulation while pitch stays steady.
duration = 3.0
time = np.arange(int(sample_rate * duration)) / sample_rate
amplitude = np.clip(0.5 + 0.48 * np.sin(2 * np.pi * 1.2 * time - np.pi / 2), 0.02, 1.0)
segments += [fade(expressive_tone(np.full_like(time, 392.0), amplitude), sample_rate)]
write_wav(AUDIO / "ch02-continuous-control-studies.wav", np.concatenate(segments), sample_rate)
print("Generated Chapter 2 figures and audio.")
SOURCE AND OUTPUT
Equation figures
One deterministic Python program renders the exact plots paired with the numbered formulas. Equation 2.2 is rendered by the heterodyne program above.
Output
Source
formula_visuals_ch02.py
assets/figures/src/formula_visuals_ch02.pyPython
#!/usr/bin/env python3
"""Generate deterministic visuals for the numbered formulas in Chapter 2."""
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 2.1: LC resonance falls as capacitance rises.
inductance = 1e-3
capacitance_pf = np.linspace(50.0, 500.0, 900)
capacitance_f = capacitance_pf * 1e-12
lc_frequency_khz = 1.0 / (2 * np.pi * np.sqrt(inductance * capacitance_f)) / 1000
fig, axis = plt.subplots(figsize=(7.2, 3.2), constrained_layout=True)
axis.plot(capacitance_pf, lc_frequency_khz, color=BLUE, linewidth=2)
for c_pf in [100.0, 400.0]:
f_khz = 1.0 / (2 * np.pi * np.sqrt(inductance * c_pf * 1e-12)) / 1000
axis.scatter([c_pf], [f_khz], color=RED, zorder=3)
axis.annotate(f"{c_pf:.0f} pF, {f_khz:.1f} kHz", (c_pf, f_khz), xytext=(8, 6), textcoords="offset points")
axis.set(xlabel="capacitance C (pF)", ylabel="resonant frequency (kHz)", title="Equation 2.1: L = 1 mH; four times C gives half the frequency")
save(fig, "eq-2-1-lc-frequency.svg")
# Equation 2.3: difference and sum move differently as one oscillator moves.
fixed_khz = 500.0
variable_khz = np.linspace(498.0, 502.0, 900)
difference_khz = np.abs(fixed_khz - variable_khz)
sum_khz = fixed_khz + variable_khz
fig, axes = plt.subplots(2, 1, figsize=(7.2, 4.8), sharex=True, constrained_layout=True)
axes[0].plot(variable_khz, difference_khz, color=RED, linewidth=2)
axes[0].set(ylabel="difference (kHz)", title="Absolute difference reaches zero when oscillators match")
axes[1].plot(variable_khz, sum_khz, color=BLUE, linewidth=2)
axes[1].set(xlabel="variable oscillator f2 (kHz); fixed f1 = 500 kHz", ylabel="sum (kHz)", title="Sum remains near 1000 kHz")
fig.suptitle("Equation 2.3: |f1 − f2| and f1 + f2", fontweight="bold")
save(fig, "eq-2-3-sum-difference.svg")
# Equation 2.4: linear addition beats but keeps only the input spectrum lines.
f1 = 10.0
f2 = 11.5
t_beats = np.linspace(0.0, 2.0, 5000, endpoint=False)
linear_sum = np.cos(2 * np.pi * f1 * t_beats) + np.cos(2 * np.pi * f2 * t_beats)
envelope = 2 * np.cos(np.pi * (f2 - f1) * t_beats)
fig, axes = plt.subplots(2, 1, figsize=(7.2, 5.0), constrained_layout=True)
axes[0].plot(t_beats, linear_sum, color=BLACK, linewidth=0.8, label="linear sum")
axes[0].plot(t_beats, envelope, color=RED, linewidth=1.4, linestyle="--", label="± envelope")
axes[0].plot(t_beats, -envelope, color=RED, linewidth=1.4, linestyle="--")
axes[0].set(xlabel="time (s)", ylabel="amplitude", xlim=(0, 2), title="The waveform has a slow envelope")
axes[0].legend(frameon=False)
markerline, stemlines, _ = axes[1].stem([f1, f2], [1.0, 1.0], basefmt=" ")
plt.setp(markerline, color=PURPLE, markersize=6)
plt.setp(stemlines, color=PURPLE, linewidth=1.6)
axes[1].set(xlabel="frequency (scaled Hz)", ylabel="relative amplitude", xticks=[f1, f2], xlim=(0, 15), ylim=(0, 1.1), title="The linear spectrum still has only f1 and f2")
fig.suptitle("Equation 2.4: f1 = 10, f2 = 11.5; beating is not a new 1.5 line", fontweight="bold")
save(fig, "eq-2-4-linear-beating.svg")
# Equation 2.5: map cent offset to frequency around A4.
cents = np.linspace(-100.0, 100.0, 800)
center_hz = 440.0
mapped_hz = center_hz * np.power(2.0, cents / 1200.0)
fig, axis = plt.subplots(figsize=(7.2, 3.2), constrained_layout=True)
axis.plot(cents, mapped_hz, color=RED, linewidth=2)
for c in [-50.0, 0.0, 50.0]:
f = center_hz * 2 ** (c / 1200)
axis.scatter([c], [f], color=BLUE, zorder=3)
axis.annotate(f"{c:+.0f} cents = {f:.2f} Hz", (c, f), xytext=(5, 7), textcoords="offset points")
axis.set(xlabel="cent offset c", ylabel="frequency f(t) (Hz)", title="Equation 2.5: fc = 440 Hz; equal cents form frequency ratios")
save(fig, "eq-2-5-cents-frequency.svg")
# Equation 2.6: sinusoidal vibrato in cent space.
t_vibrato = np.linspace(0.0, 1.0, 1600)
depth_cents = 25.0
vibrato_rate = 5.5
cent_trajectory = depth_cents * np.sin(2 * np.pi * vibrato_rate * t_vibrato)
fig, axis = plt.subplots(figsize=(7.2, 3.2), constrained_layout=True)
axis.plot(t_vibrato, cent_trajectory, color=TEAL, linewidth=1.8)
axis.axhline(depth_cents, color=GRAY, linestyle="--", linewidth=0.8, label="±D = ±25 cents")
axis.axhline(-depth_cents, color=GRAY, linestyle="--", linewidth=0.8)
zero_line(axis)
axis.set(xlabel="time t (s)", ylabel="c(t) (cents)", ylim=(-32, 32), title="Equation 2.6: D = 25 cents, fv = 5.5 Hz")
axis.legend(frameon=False)
save(fig, "eq-2-6-vibrato-cents.svg")
# Equation 2.7: LC frequency ratio as capacitance ratio changes.
capacitance_ratio = np.linspace(0.25, 4.0, 900)
frequency_ratio = np.sqrt(1.0 / capacitance_ratio)
fig, axis = plt.subplots(figsize=(7.2, 3.2), constrained_layout=True)
axis.plot(capacitance_ratio, frequency_ratio, color=BLUE, linewidth=2)
for ratio in [1.0, 4.0]:
value = np.sqrt(1 / ratio)
axis.scatter([ratio], [value], color=RED, zorder=3)
axis.annotate(f"C2/C1 = {ratio:g}, f2/f1 = {value:g}", (ratio, value), xytext=(7, 7), textcoords="offset points")
axis.set(xlabel="capacitance ratio C2/C1", ylabel="frequency ratio f2/f1", title="Equation 2.7: fixed L; frequency scales as 1/√C")
save(fig, "eq-2-7-lc-ratio.svg")
# Equation 2.8: cents expressed directly as a frequency ratio.
cents_wide = np.linspace(-1200.0, 1200.0, 1200)
ratio = np.power(2.0, cents_wide / 1200.0)
fig, axis = plt.subplots(figsize=(7.2, 3.2), constrained_layout=True)
axis.plot(cents_wide, ratio, color=PURPLE, linewidth=2)
for c, r in [(-1200, 0.5), (0, 1.0), (1200, 2.0)]:
axis.scatter([c], [r], color=RED, zorder=3)
axis.annotate(f"{c:+d} cents → {r:g}×", (c, r), xytext=(5, 7), textcoords="offset points")
axis.set(xlabel="cent displacement c", ylabel="frequency ratio f/fc", title="Equation 2.8: 1200 cents doubles frequency")
save(fig, "eq-2-8-cents-ratio.svg")
# Equation 2.9: logarithmic and linear interpolation do not share a midpoint.
u = np.linspace(0.0, 1.0, 800)
start_hz = 220.0
end_hz = 880.0
log_path = start_hz * np.power(end_hz / start_hz, u)
linear_path = start_hz + (end_hz - start_hz) * u
fig, axis = plt.subplots(figsize=(7.2, 3.2), constrained_layout=True)
axis.plot(u, log_path, color=RED, linewidth=2, label="log-frequency path")
axis.plot(u, linear_path, color=GRAY, linewidth=1.4, linestyle="--", label="linear-hertz path")
axis.scatter([0.5, 0.5], [440, 550], color=[RED, GRAY], zorder=3)
axis.annotate("440 Hz", (0.5, 440), xytext=(-35, -18), textcoords="offset points")
axis.annotate("550 Hz", (0.5, 550), xytext=(8, 8), textcoords="offset points")
axis.set(xlabel="normalized progress u", ylabel="frequency (Hz)", title="Equation 2.9: 220 → 880 Hz")
axis.legend(frameon=False)
save(fig, "eq-2-9-log-interpolation.svg")
# Equation 2.10: frequency controls phase increment; accumulated phase controls the waveform.
sample_rate = 48_000
samples = 1_920
time = np.arange(samples) / sample_rate
progress = np.linspace(0.0, 1.0, samples)
frequency_path = 220.0 * np.power(880.0 / 220.0, progress)
increment = 2 * np.pi * frequency_path / sample_rate
phase_path = np.zeros(samples)
phase_path[1:] = np.cumsum(increment[:-1])
waveform = np.sin(phase_path)
assert frequency_path[0] == 220.0 and frequency_path[-1] == 880.0
assert phase_path[0] == 0.0
fig, axes = plt.subplots(3, 1, figsize=(7.2, 6.0), sharex=True, constrained_layout=True)
axes[0].plot(time * 1000, frequency_path, color=BLUE, linewidth=1.8)
axes[0].set(ylabel="f[n] (Hz)", title="Input frequency rises from 220 to 880 Hz")
axes[1].plot(time * 1000, increment, color=TEAL, linewidth=1.8)
axes[1].set(ylabel="phase step (rad)", title="Each sample gets a new 2πf[n]/Fs increment")
axes[2].plot(time * 1000, waveform, color=RED, linewidth=0.9)
zero_line(axes[2])
axes[2].set(xlabel="time (ms)", ylabel="x[n]", title="The sine reads the accumulated phase")
fig.suptitle("Equation 2.10: Fs = 48 kHz, N = 1920, f[0] = 220 Hz, f[1919] = 880 Hz", fontweight="bold")
save(fig, "eq-2-10-phase-accumulation.svg")
print("Generated Chapter 2 formula visuals.")
SOURCE AND OUTPUT
Theremin signal diagram
The Mermaid source produces the full control and heterodyne signal path.
Output
Source
ch02-heterodyne-instrument.mmd
assets/diagrams/src/ch02-heterodyne-instrument.mmdMermaid
flowchart LR
HP[Pitch<br/>hand] --> CAP[Hand<br/>antenna<br/>capacitance]
CAP --> VAR[Variable-RF<br/>oscillator]
FIX[Fixed-RF<br/>oscillator] --> MIX[Nonlinear<br/>mixer]
VAR --> MIX
MIX --> LP[Low-pass<br/>difference]
HV[Volume<br/>hand<br/>loop] --> AMP[Amplitude<br/>control]
LP --> AMP
AMP --> OUT[Speaker<br/>diffuseur]
SOURCE AND OUTPUT
Cumulative Rust renderer
The chapter program uses the shared oscillator library to compare addition and multiplication, then renders continuous and articulated versions of one phrase.
Output
RF model: difference=440 Hz, sum=599560 Hz
wrote ch02-gesture-study.wavSource
ch02_gesture.rs
assets/rust/src/bin/ch02_gesture.rsRust
use std::{error::Error, path::PathBuf};
use wavetable_synthesis_exercises::{
append_silence, heterodyne_components, render_phrase, write_wav, Note, PhraseStyle,
SineOscillator, SAMPLE_RATE,
};
fn render_scaled_pair(multiply: bool) -> Vec<f32> {
let mut fixed = SineOscillator::new();
let mut variable = SineOscillator::new();
(0..SAMPLE_RATE)
.map(|_| {
let a = fixed.tick(1_000.0, SAMPLE_RATE as f32);
let b = variable.tick(560.0, SAMPLE_RATE as f32);
if multiply {
a * b
} else {
0.5 * (a + b)
}
})
.collect()
}
fn main() -> Result<(), Box<dyn Error>> {
let output = std::env::args_os()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("ch02-gesture-phrase.wav"));
if let Some(parent) = output
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)?;
}
let (difference_hz, sum_hz) = heterodyne_components(300_000.0, 299_560.0);
println!("RF model: difference={difference_hz:.0} Hz, sum={sum_hz:.0} Hz");
// Linear addition retains 1000 and 560 Hz; multiplication creates 440 and 1560 Hz.
// Real theremin RF oscillators are above the 48 kHz teaching renderer's Nyquist limit.
let mut audio = render_scaled_pair(false);
append_silence(&mut audio, 0.4);
audio.extend(render_scaled_pair(true));
append_silence(&mut audio, 0.75);
// Pitch incipit 5-1-3-1-3-2-1-6-5-5 of the public-domain NEW BRITAIN
// tune. Durations are simplified for this original articulation study.
let melody = [
Note::new(67, 0.5),
Note::new(72, 1.5),
Note::new(76, 0.5),
Note::new(72, 0.5),
Note::new(76, 1.5),
Note::new(74, 0.5),
Note::new(72, 0.5),
Note::new(69, 1.5),
Note::new(67, 0.5),
Note::new(67, 1.0),
];
let mut continuous = PhraseStyle::legato(0.35);
continuous.final_vibrato_cents = 18.0;
audio.extend(render_phrase(&melody, &[1.0, 0.18], 84.0, continuous));
append_silence(&mut audio, 0.75);
let mut articulated = PhraseStyle::detached(0.76);
articulated.accent_note = Some(4);
articulated.final_vibrato_cents = 18.0;
audio.extend(render_phrase(&melody, &[1.0, 0.18], 84.0, articulated));
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 2 Answers and Fault Invariants
Chapter 2 mathematical-practice answers
- .
- Difference: 440 Hz. Sum: 519,560 Hz.
- Linear superposition retains spectral components at the two inputs. The 440 Hz envelope rate is not a new 440 Hz spectrum line; multiplication or another nonlinearity is required.
- Hz and Hz.
- Logarithmic midpoint: Hz. Linear midpoint: Hz.
- radians per sample. For a glide, recompute the increment from each and add it to the previous phase.
- Hz = 299.41267 kHz.
- A valid response identifies every axis or panel, copies the stated parameters, and links one visual feature to the formula. Examples include LC frequency falling with capacitance, multiplication creating two new lines, log interpolation reaching 440 Hz halfway, and phase steps increasing during the glide.
Chapter 2 readiness answers
- The variable LC oscillator falls because . Its distance from the fixed oscillator can increase, so the audible difference frequency rises.
- The nonlinear mixer produces 1 kHz and 1001 kHz. An audio low-pass filter retains 1 kHz.
- The trigonometric envelope of a linear sum varies, but its Fourier components remain at the original two frequencies. A nonlinear product generates cross-components at the sum and difference.
- The pitch hand controls capacitance near the vertical rod; the volume hand shapes amplitude near the loop. Amplitude control creates attack, release, separation, accent, and rest instead of exposing every pitch movement continuously.
- Vibrato is periodic pitch motion around a target; portamento is continuous travel between targets; articulation shapes onset, connection, emphasis, and release, commonly through amplitude.
- Mobile keyboard and ring/wire. The intensity key provides pressure-shaped amplitude and articulation. A resonant diffuser changes spectral colour and decay rather than only increasing level.
Chapter 2 faded and musical-station invariants
- Variable frequencies below 300.000 kHz are 299.56000 kHz for A4, 299.47675 kHz for C5, and 299.34075 kHz for E5.
- As the target audio frequency rises, the below-fixed variable oscillator moves farther downward.
- The low-pass stage must reject the RF sum and other high-frequency mixer products.
- Acceptable musical proof contains separate pitch and amplitude trajectories, two contrasting articulations of the same pitch centers, and a listener observation about event clarity versus continuity.
Chapter 2 fault invariant
- The antenna is primarily a capacitive proximity electrode, not a blocked beam.
- Increased capacitance lowers LC frequency directly.
- Linear addition is not nonlinear heterodyne mixing.
- The Ondes adds distinct pitch interfaces, haptic amplitude control, timbre selection, and specialized outputs; it is not merely a keyed theremin.