CONTRAPUNK

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:

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.).

Lev Termen demonstrating the Termenvox in 1927; one hand controls pitch near the vertical antenna and the other controls amplitude near the loop.

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:

  1. a resonant electronic oscillator;
  2. a nonlinear frequency converter;
  3. 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:

fLC=12πLC.(2.1) f_{\mathrm{LC}}=\frac{1}{2\pi\sqrt{LC}}. \qquad\text{(2.1)}

Symbol Meaning Unit
LL inductance henries
CC effective capacitance farads
fLCf_{\mathrm{LC}} resonant frequency hertz
Equation 2.1 plotted with L=1 mH and capacitance from 50 to 500 pF; frequency falls as capacitance rises.

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 LL fixed and increase CC. Frequency falls:

CfLC. C\uparrow\quad\Rightarrow\quad f_{\mathrm{LC}}\downarrow.

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

x1(t)=cos(2πf1t),x2(t)=cos(2πf2t). x_1(t)=\cos(2\pi f_1t),\qquad x_2(t)=\cos(2\pi f_2t).

The mixer multiplies them. The product-to-sum identity gives

cos(2πf1t)cos(2πf2t)=12cos(2π(f1f2)t)+12cos(2π(f1+f2)t).(2.2) \cos(2\pi f_1t)\cos(2\pi f_2t) =\frac{1}{2}\cos\!\left(2\pi(f_1-f_2)t\right) +\frac{1}{2}\cos\!\left(2\pi(f_1+f_2)t\right). \qquad\text{(2.2)}

Equation 2.2 plotted with scaled frequencies 10 and 11.5; multiplication produces 1.5 and 21.5 components.

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,

fdifference=|f1f2|,fsum=f1+f2.(2.3) f_{\mathrm{difference}}=|f_1-f_2|, \qquad f_{\mathrm{sum}}=f_1+f_2. \qquad\text{(2.3)}

Equation 2.3 plotted with a fixed 500 kHz oscillator and a variable oscillator from 498 to 502 kHz.

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.).

Add the same two sinusoids instead of multiplying them:

cos(2πf1t)+cos(2πf2t)=2cos(π(f2f1)t)cos(2πf1+f22t).(2.4) \cos(2\pi f_1t)+\cos(2\pi f_2t) =2\cos\!\left(\pi(f_2-f_1)t\right) \cos\!\left(2\pi\frac{f_1+f_2}{2}t\right). \qquad\text{(2.4)}

Equation 2.4 plotted with scaled frequencies 10 and 11.5; the time waveform beats while the spectrum retains only 10 and 11.5.

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 f1f_1 and f2f_2. A nonlinear mixer is required to place actual spectral energy at |f1f2||f_1-f_2|. This distinction will return when we study ring modulation, intermodulation, and spectral distortion.

The theremin signal path

Pitch-hand capacitance controls a variable RF oscillator; nonlinear mixing and low-pass filtering recover an audible difference, while the other hand shapes amplitude.

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:

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.

Idealized progression from hand distance to capacitance, the magnitude of a downward variable-RF shift, and logarithmic musical pitch.

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:

A convenient frequency model for vibrato measured in cents is:

f(t)=fc2c(t)/1200,(2.5) f(t)=f_c\,2^{c(t)/1200}, \qquad\text{(2.5)}

Equation 2.5 plotted around f_c=440 Hz; −50, 0, and +50 cents map to 427.47, 440, and 452.89 Hz.

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 fcf_c is the center frequency and c(t)c(t) is the time-varying cent offset. For sinusoidal vibrato,

c(t)=Dsin(2πfvt),(2.6) c(t)=D\sin(2\pi f_vt), \qquad\text{(2.6)}

Equation 2.6 plotted with depth D=25 cents and rate f_v=5.5 Hz for one second.

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 DD cents and vibrato rate fvf_v 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).

A later keyboard-and-ring form of the Ondes Martenot.

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:

  1. Mobile keyboard. Discrete visual and tactile landmarks support intonation, while lateral key motion permits pitch inflection and vibrato.
  2. 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:

  1. An oscillator and its controller are different systems.
  2. Raw sensor space is not automatically musical parameter space.
  3. Pitch and amplitude need independent trajectories.
  4. Smoothing, calibration, and feedback determine playability.
  5. The output resonator or filter participates in timbre.
  6. 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,

f2f1=L1C1L2C2.(2.7) \frac{f_2}{f_1}=\sqrt{\frac{L_1C_1}{L_2C_2}}. \qquad\text{(2.7)}

Equation 2.7 plotted with fixed inductance and capacitance ratio C_2/C_1 from 0.25 to 4.

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 f2/f1=C1/C2f_2/f_1=\sqrt{C_1/C_2}. 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,

260.000259.560=0.440kHz=440Hz. 260.000-259.560=0.440\ \mathrm{kHz}=440\ \mathrm{Hz}.

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 cc uses

ffc=2c/1200.(2.8) \frac{f}{f_c}=2^{c/1200}. \qquad\text{(2.8)}

Equation 2.8 plotted from −1200 to +1200 cents; the frequency ratios are 0.5, 1, and 2 at octave landmarks.

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

f(u)=fa(fbfa)u,0u1.(2.9) f(u)=f_a\left(\frac{f_b}{f_a}\right)^u, \qquad 0\le u\le1. \qquad\text{(2.9)}

Equation 2.9 plotted from 220 to 880 Hz; logarithmic interpolation reaches 440 Hz halfway while linear-hertz interpolation reaches 550 Hz.

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 u=1/2u=1/2 to verify the 440 Hz midpoint.

Discrete phase accumulation

At 48 kHz, a 440 Hz oscillator advances by 2π(440/48,000)0.05762\pi(440/48{,}000)\approx0.0576 radians per sample. A changing frequency requires a new increment at every sample:

θ[n+1]=θ[n]+2πf[n]Fs,x[n]=sin(θ[n]).(2.10) \theta[n+1]=\theta[n]+2\pi\frac{f[n]}{F_s}, \qquad x[n]=\sin(\theta[n]). \qquad\text{(2.10)}

Equation 2.10 plotted at 48 kHz for 1920 samples; f[0]=220 Hz and f[1919]=880 Hz.

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 θ[0]=0\theta[0]=0, plots x[n]=sin(θ[n])x[n]=\sin(\theta[n]), and applies increment nn to obtain phase n+1n+1. Cycles compress as frequency rises.

Substituting a changing f(t)f(t) directly into sin(2πf(t)t)\sin(2\pi f(t)t) generally adds an unintended tf(t)t f'(t) contribution to instantaneous frequency. The Rust oscillator therefore accepts a new frequency every sample while preserving phase.

Mathematical practice 2

  1. With fixed inductance, capacitance becomes four times larger. Find f2/f1f_2/f_1.
  2. Find the difference and sum of 260.000 kHz and 259.560 kHz in hertz.
  3. Explain why adding those two RF sinusoids does not create a 440 Hz spectrum line.
  4. Calculate frequencies 50 cents below and above A4 = 440 Hz.
  5. Find the logarithmic midpoint of a glide from 220 to 880 Hz and compare it with the linear-hertz midpoint.
  6. At Fs=48,000F_s=48{,}000 Hz and constant f[n]=440f[n]=440 Hz, find the phase increment per sample in radians. Explain how Equation 2.10 changes for a glide.
  7. A fixed oscillator is 300.000 kHz. Find the below-fixed variable frequency for D5 = 587.33 Hz.
  8. 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:

|260.000259.560|kHz=0.440kHz=440Hz. |260.000-259.560|\ \mathrm{kHz}=0.440\ \mathrm{kHz}=440\ \mathrm{Hz}.

The hand increases effective capacitance so that the variable RF frequency falls to 259.340 kHz:

|260.000259.340|kHz=0.660kHz=660Hz. |260.000-259.340|\ \mathrm{kHz}=0.660\ \mathrm{kHz}=660\ \mathrm{Hz}.

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:

  1. steady 330 Hz reference;
  2. logarithmic glide from 220 to 660 Hz;
  3. 5.5 Hz vibrato around 440 Hz with a depth of ±25 cents;
  4. steady 392 Hz with independent amplitude articulation.

Procedure

  1. Draw pitch versus time for each segment without using a spectrum analyzer.
  2. Draw amplitude versus time separately.
  3. Identify which segment changes pitch but not intended amplitude.
  4. Identify which changes amplitude but not intended pitch.
  5. Describe the musical function of the silence between segments.
  6. 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

The current pitch trajectory is described in the text below the plot.

Pitch moves logarithmically from 220 to 880 Hz with 18-cent vibrato at 5.0 Hz. Articulation is continuous.

Ready

Try these checks

  1. Set the variable oscillator to 780 Hz. Predict the difference before reading it.
  2. Compare addition with multiplication. Listen for the new difference component.
  3. Set vibrato depth to zero. The glide remains continuous because phase still accumulates.
  4. 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.

  1. Continuous pass: portamento near each boundary, nearly continuous amplitude, final-note vibrato.
  2. Articulated pass: the same pitch centers and durations, amplitude closure between events, an accent on the fifth event, and final-note vibrato.

Listening questions

  1. Which pass makes the number of note events easiest to count?
  2. Which pass makes the phrase feel like one continuous gesture?
  3. Does the accent change pitch, amplitude, or both?
  4. Can you hear the final vibrato as motion around a center rather than travel to a new note?
  5. Draw separate pitch and amplitude trajectories for both passes.
  6. 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.py

Verify 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:

  1. change vibrato depth while holding rate fixed;
  2. restore depth and change rate;
  3. 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.wav

Then work in order:

  1. verify the 440 Hz difference and 599.560 kHz sum printed by the program;
  2. explain why the audio demonstration uses 1000 and 560 Hz instead of real RF values;
  3. change final_portion in the legato style and predict where each glide begins;
  4. move accent_note while leaving the pitch list unchanged;
  5. double vibrato depth, then restore depth and double rate in render_phrase;
  6. replace logarithmic interpolation with linear hertz temporarily, compare, and restore the correct version;
  7. 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
  1. Calculate all three variable RF frequencies.
  2. Explain why the variable oscillator moves downward as the audible note rises in this arrangement.
  3. State what the low-pass filter must reject.
  4. Check one answer by reversing the subtraction.

Musical application 2: separate pitch from articulation

Draw or perform a four-note phrase twice.

  1. In version A, connect every pitch with continuous portamento and keep amplitude nearly constant.
  2. In version B, use the same pitch centers but close the amplitude between notes and accent the third note.
  3. Add a small vibrato only to the final sustained note in both versions.
  4. Record separate pitch-versus-time and amplitude-versus-time sketches.
  5. 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.

  1. If effective capacitance rises while inductance remains fixed, what happens to the variable RF oscillator, and why may audible theremin pitch nevertheless rise?
  2. Use Equation 2.2 for 500 kHz and 501 kHz. What mixer components result, and which survives an audio low-pass filter?
  3. Why can linear addition sound like beating without containing a difference-frequency spectral line?
  4. Describe the standard two-hand theremin mapping and explain how amplitude control turns continuous pitch into articulated musical events.
  5. Distinguish vibrato, portamento, and articulation using one gesture example for each.
  6. Name the two mature Ondes pitch interfaces and state what the intensity key and a resonant diffuser add.

Chapter 2 invariants

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 CC lowers LC frequency.
Cent Logarithmic pitch unit; 1200 cents equal one octave.
Difference frequency |f1f2||f_1-f_2|, 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 LL and measured in henries.
LC oscillator Feedback oscillator whose resonant frequency depends on LL and CC.
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 f1+f2f_1+f_2, 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

Nearby oscillators, their nonlinear product, and sum and difference spectrum
Generated heterodyne-principle figure.
Hand distance mapped through capacitance and RF shift to pitch
Generated gesture-to-pitch figure.

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

Equation 2.1 LC frequency
Equation 2.1 LC frequency
Equation 2.3 sum and difference
Equation 2.3 sum and difference
Equation 2.4 linear beating
Equation 2.4 linear beating
Equation 2.5 cents to frequency
Equation 2.5 cents to frequency
Equation 2.6 vibrato in cents
Equation 2.6 vibrato in cents
Equation 2.7 LC ratio
Equation 2.7 LC ratio
Equation 2.8 cents ratio
Equation 2.8 cents ratio
Equation 2.9 logarithmic interpolation
Equation 2.9 logarithmic interpolation
Equation 2.10 phase accumulation
Equation 2.10 phase accumulation

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

Theremin pitch and volume control paths through oscillators, mixer, filter, and amplifier
Theremin control and signal path.

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.wav

Source

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

assets/rust/src/lib.rsRust

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

use std::path::Path;

pub const SAMPLE_RATE: u32 = 48_000;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    output
}

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

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

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

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

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

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

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

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

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

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

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

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

assets/rust/Cargo.tomlTOML

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

[dependencies]
hound = "3.5"

Chapter 2 Answers and Fault Invariants

Chapter 2 mathematical-practice answers

  1. f2/f1=1/4=1/2f_2/f_1=1/\sqrt{4}=1/2.
  2. Difference: 440 Hz. Sum: 519,560 Hz.
  3. 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.
  4. 440250/1200427.47440\cdot2^{-50/1200}\approx427.47 Hz and 440250/1200452.89440\cdot2^{50/1200}\approx452.89 Hz.
  5. Logarithmic midpoint: 220(880/220)1/2=440220(880/220)^{1/2}=440 Hz. Linear midpoint: (220+880)/2=550(220+880)/2=550 Hz.
  6. 2π(440/48,000)0.057602\pi(440/48{,}000)\approx0.05760 radians per sample. For a glide, recompute the increment from each f[n]f[n] and add it to the previous phase.
  7. 300,000587.33=299,412.67300{,}000-587.33=299{,}412.67 Hz = 299.41267 kHz.
  8. 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

  1. The variable LC oscillator falls because f1/Cf\propto1/\sqrt{C}. Its distance from the fixed oscillator can increase, so the audible difference frequency rises.
  2. The nonlinear mixer produces 1 kHz and 1001 kHz. An audio low-pass filter retains 1 kHz.
  3. 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.
  4. 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.
  5. Vibrato is periodic pitch motion around a target; portamento is continuous travel between targets; articulation shapes onset, connection, emphasis, and release, commonly through amplitude.
  6. 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

Chapter 2 fault invariant

Bibliography

30rKs56MaE. 2006. “Ondes Martenot.” Wikimedia Commons. 2006. https://commons.wikimedia.org/wiki/File:Ondes_martenot.jpg.
Alonso-Pérez, Silvia, and Adrian Batista. n.d. “Playing with Electromagnetic Waves: The Science of the Theremin.” Physics Today. Accessed August 11, 2026. https://physicstoday.aip.org/news/playing-with-electromagnetic-waves-the-science-of-the-theremin.
Bettmann / Corbis. 1927. “Lev Termen Demonstrating the Termenvox.” Wikimedia Commons. 1927. https://commons.wikimedia.org/wiki/File:Termen_demonstrating_Termenvox.jpg.
Najnudel, Jules et al. 2023. “A Physical Model for the Electromagnetic Loudspeaker Used in Early Ondes Martenot Diffuseurs.” In Proceedings of Forum Acusticum 2023. https://dael.euracoustics.org/confs/fa2023/data/articles/000604.pdf.
Quartier, Laurent et al. 2015. “Intensity Key of the Ondes Martenot: An Early Mechanical Haptic Device.” https://hal.sorbonne-universite.fr/hal-01587642v1/document.
Skeldon, Kenneth D., Lindsay M. Reid, Viviene McInally, Brendan Dougan, and Craig Fulton. 1998. “Physics of the Theremin.” American Journal of Physics 66 (11): 945–55. https://doi.org/10.1119/1.19004.
“Timeline of the Song Amazing Grace.” n.d. Library of Congress. Accessed August 11, 2026. https://www.loc.gov/collections/amazing-grace/articles-and-essays/timeline/.
“Tune: New Britain.” n.d. Hymnary.org. Accessed August 11, 2026. https://hymnary.org/tune/new_britain.
Verhulst, Wim. n.d.a. “Ondes Martenot.” Musical Instruments Museum Brussels. Accessed August 11, 2026. https://www.mim.be/en/collection-piece/ondes-martenot-0.
———. n.d.b. “Theremin.” Musical Instruments Museum Brussels. Accessed August 11, 2026. https://www.mim.be/en/collection-piece/theremin-0.