Voltage Control and Modular Thinking
Section I · Theory
Build the model
Turn changing signals into pitch, spectrum, amplitude, timing, and safe feedback.One voltage, two octaves
Listen first. Lower device volume, then play the original study. The first second is a 220 Hz saw. The second is 440 Hz. The waveform recipe stays fixed. One changing control doubles the oscillator cycle rate.
Long description. Two columns show 20 milliseconds at 220 and 440 Hz. The control changes from zero to one octave. Phase becomes twice as steep, and the saw cycles become twice as dense.
Figure and audio provenance. Project-authored deterministic output from ch04_modular.py. A five-millisecond endpoint fade prevents playback clicks without smoothing the one-second pitch change.
Now inspect a familiar patch:
Long description. Keyboard, touch, or sequencer voltage and a low-frequency oscillator (LFO) enter the pitch input of a voltage-controlled oscillator (VCO). The VCO audio output enters a voltage-controlled filter (VCF), then a voltage-controlled amplifier (VCA), then a recorder or loudspeaker. A gate or pulse triggers an envelope whose output controls both the VCF and VCA.
Diagram provenance. Project-authored SVG based on documented Moog and Buchla module functions and interfaces.
The observation is simple: the arrow into pitch is not the sound sent to the loudspeaker. It changes the behavior of the generator. The envelope is also a signal. Its function comes from where it is patched.
Question. How did one changing electrical quantity become a reusable musical instruction, and how can a browser model the same relationship safely?
Chapter contract
Prerequisites. Chapters 1 through 3, especially phase accumulation, envelopes, filtering, modulation, and stored control. Listen at a low fixed level.
By the end of this chapter, you should be able to:
- explain why voltage control made a parameter relationship patchable and performable;
- distinguish a generated signal from a signal that controls another module;
- trace VCO, VCF, VCA, envelope, LFO, gate, sequencer, and output roles through a patch;
- describe how charging current and reset can oscillate, how controlled transconductance can set gain, and how capacitor state can low-pass a signal;
- convert between linear amplitude gain and decibels;
- derive phase accumulation, exponential pitch, VCA multiplication, a one-pole teaching VCF, a linear envelope segment, control-rate holding, and delayed bounded feedback;
- compare per-sample, held, and interpolated control;
- diagnose unsafe levels, missing gates, abrupt controls, and feedback without delay.
Why modular voltage control appeared
Tape let a composer fix, reverse, repeat, and layer sound. It also made every new trajectory laborious. A swell or pitch curve might require another performance, splice, or dubbing pass. Voltage control made those relationships composable in real time. A changing electrical quantity could govern pitch, spectrum, amplitude, or event timing while the sound ran (Moog 1964; Buchla Associates 1968). Tape did not disappear. Modular systems were recorded, edited, and layered.
Robert Moog and Don Buchla developed transistorized modular systems independently and nearly concurrently in the mid-1960s. Their milestones use different clocks. Moog described voltage-controlled modules in an October 1964 AES paper. Buchla work for the San Francisco Tape Music Center began around 1963, sources attach later dates to development and completion, and the Library of Congress catalogs its Buchla 100 as circa 1966 (Moog 1964; Buchla, n.d.; Library of Congress n.d.; Pinch and Trocco 2001). A commission date, prototype, paper, completed system, public demonstration, and sale are not interchangeable claims.
Long description. A close photograph fills the frame with cream and black Moog module panels. Rows of knobs, toggle switches, labeled input and output sockets, and crossing patch cords make control relationships visible rather than hiding them inside one fixed instrument circuit.
Image credit and rights. FranckinJapan, CC0 1.0 via Wikimedia Commons. The exact 4,648 by 3,784-pixel original was uploaded at 2017-03-31T05:49:49Z. Commons description revision 239258618 contains the original CC0 declaration; revision 239331280 records the passed license review. No crop or content alteration. SHA-1 cc590484569cc2a97ca9126dec34ee1e07630e3e; SHA-256 8d7b0651e48fc98234f78435a03944864172119dfd2a27d0ee6dc5e4939d6336. Editorial use does not imply Moog endorsement.
Later writers use “East Coast” and “West Coast” as shorthand for tendencies associated with Moog and Buchla. The labels simplify overlapping instruments, techniques, and users, and they are not a neutral 1960s taxonomy. Compare documented objects instead. Moog systems used familiar keyboards as well as other controllers. Buchla systems prominently used touch voltage sources, pulse sources, and sequencers. Both included modular oscillation, filtering, control, and experimental patching (Pinch and Trocco 2001; Buchla Associates 1968). Subtractive synthesis is a patch pattern, not Moog’s exclusive philosophy.
A patch is a directed signal graph
A module either produces a signal, transforms one or more signals, or exposes a control surface. Its output may fan out. Ordinary hardware outputs must not be shorted together. Summation needs a mixer or a designated summing input.
The canonical subtractive path is:
rich VCO waveform → time-varying VCF → envelope-controlled VCA → outputA VCO generates a periodic signal. A VCF changes spectrum. A VCA multiplies a signal by a changing gain. An envelope traces a one-shot contour. An LFO is simply an oscillator used at a low rate. A sequencer selects stored control values and gates over time. Those names describe jobs. They do not identify one universal circuit.
Before the patch: three electronic actions
Start with charge, current, and multiplication. Voltage control works because a circuit turns a changing control voltage or current into a changing physical parameter. The schematics below are project-authored teaching topologies. They are not copied module schematics and do not claim that Moog, Buchla, or every later manufacturer used the same parts.
Inside one VCO
A capacitor stores charge. Driving it with current makes its voltage ramp. A comparator notices a threshold and closes a reset switch. Charge, threshold, reset, and repeat produce a cycle. More charging current reaches the threshold sooner, so frequency rises. In the simplest linear case, and frequency is proportional to . A musical volts-per-octave input adds an exponential voltage-to-current stage, which anticipates Equation 4.2.
Long description. Pitch control enters a linear or exponential voltage-to-current block. Its current charges capacitor C and creates a ramp output. A comparator detects threshold delta V. The comparator drives a reset switch back to the capacitor and also supplies a pulse output.
Inside one VCA
A voltage-controlled amplifier need not add voltage. It changes a ratio. In this teaching circuit, an operational transconductance amplifier (OTA) turns an input-voltage difference into output current. A separate control current changes transconductance . A load resistor turns the controlled current back into voltage, giving the small-signal relation .
Long description. Audio enters the positive OTA input while the negative input is referenced to ground. Control current enters below and sets transconductance. OTA output current passes through load resistor R L. The resulting output voltage has gain R L times g m.
Inside one VCF
A capacitor opposes fast voltage change because current must move charge. In this teaching OTA-C low-pass, the OTA responds to input minus output and drives capacitor C. Control current changes , so it changes cutoff: and . The feedback is what makes the capacitor settle toward the input instead of ramping without limit.
Long description. Audio enters the positive OTA input. The buffered low-pass output returns to the negative input. OTA current charges a capacitor to ground. A cutoff-control current sets transconductance, while a high-impedance buffer prevents the load from disturbing capacitor state.
Schematic provenance and boundary. Deterministic project-authored output from ch04_electronics.py, derived from the declared capacitor, OTA, comparator, and reset relations. These explain electronic actions behind the chapter models. The VCF is not a Moog ladder, whose distinct transistor topology is documented separately in U.S. Patent 3,475,623 (Moog 1969).
In analog modular practice, audio rate and control rate describe how quickly a signal varies or how it is used. They are not two fixed digital clocks. A 0.5 Hz oscillator can be an LFO. The same design at 500 Hz can be audible, and audio-rate modulation can still control another parameter. Some historical instruments used distinct connectors for audio, control voltage, and pulses, but that convention was not universal. In Web Audio, an a-rate parameter is evaluated for every sample-frame, while a k-rate parameter has one value for a processing group (Buchla Associates 1968; W3C Audio Working Group, n.d.).
Seven models for a modular patch
A VCO accumulates phase
Let be sample rate in samples per second, instantaneous frequency in hertz, and phase measured in cycles. Let map one phase cycle to a waveform:
Long description. The upper trace is a phase ramp measured in cycles. It wraps from one to zero and becomes twice as steep after 10 ms. The lower trace maps that phase through a saw function, so cycles become twice as dense after the frequency change.
Visual provenance. Deterministic output from formula_visuals_ch04.py. Parameters: 48 kHz; 20 milliseconds; 220 Hz followed by 440 Hz at 10 milliseconds; initial phase zero.
At 48 kHz and 220 Hz, each sample advances cycles. A per-sample frequency input extends the Chapter 2 oscillator without a new graph framework.
Pitch control is exponential
For this software teaching convention, let measure octaves relative to base frequency :
Long description. A logarithmic frequency axis turns the exponential curve into a straight rising line. Markers show minus one octave at 110 Hz, zero at 220 Hz, and plus one octave at 440 Hz.
Visual provenance. Deterministic output from formula_visuals_ch04.py. Parameters: Base frequency 220 Hz; control from minus two through plus two octaves.
One semitone is . Historic instruments varied in scaling, polarity, connector, gate, pulse, and level convention. Do not present this software convention as a universal voltage standard.
A VCA multiplies
Gain is output divided by input. If an input amplitude is 0.4 and its output amplitude is 0.2, linear gain is . Gain 1 is unity: output equals input. Gain below 1 attenuates. Gain above 1 amplifies and can exhaust headroom. Gain 0 mutes. A negative gain also flips polarity.
Decibels express the magnitude of an amplitude ratio logarithmically: . The factor is 20 for an amplitude ratio. The sign of is handled separately because decibels use magnitude.
| Linear gain | Amplitude result | Level |
|---|---|---|
| 0 | silence | minus infinity dB |
| 0.25 | one-quarter amplitude | −12.04 dB |
| 0.5 | half amplitude | −6.02 dB |
| 1 | unity | 0 dB |
| 2 | double amplitude | +6.02 dB |
Equation 4.3 uses a bounded teaching VCA that only attenuates. Let be input and normalized gain:
Long description. A faint constant-amplitude sine is multiplied by a steadily rising gain line. The output sine begins near zero and grows until it matches the input amplitude.
Visual provenance. Deterministic output from formula_visuals_ch04.py. Parameters: 220 Hz sine; 30 milliseconds; linear gain from zero through one.
The equation does not know whether came from an envelope, LFO, sequencer, pressure sensor, or knob. Patch destination creates the role.
A teaching VCF stores one sample of state
For cutoff and sample rate , define coefficient and output :
Long description. Three curves approach an input step of one. The 4000 Hz curve rises fastest, 900 Hz rises more slowly, and 200 Hz is slowest. Each output depends on the current input and previous output.
Visual provenance. Deterministic output from formula_visuals_ch04.py. Parameters: 48 kHz; unit step; cutoffs 200, 900, and 4,000 Hz; initial state zero.
Clamp above zero and no higher than in this exercise. This is an audible low-pass teaching VCF, not a Moog transistor-ladder emulation (Smith 2011). Robert Moog filed the ladder-filter patent on October 10, 1966. U.S. Patent 3,475,623 was granted on October 28, 1969. That patent documents one influential filter circuit. It does not make every low-pass filter a ladder model (Moog 1969).
FIXED EXPERIMENT
One control, three musical roles
Route the same 1 Hz LFO to one destination at a time. The source stays identical; destination creates vibrato, tremolo, or a timbral sweep.
Hearing safety. Lower device volume before playback. Every destination uses conservative fixed output gain.
Current connection:
The identical 1 Hz LFO changes VCO frequency around 220 Hz, creating vibrato. The view covers one complete cycle plus 0.25 second.
Read the exact source running this experiment
The disclosed TypeScript generates the control, parameter, envelope, plotted output, and audible samples.
export const MODULAR_SAMPLE_RATE = 48_000;
const TAU = 2 * Math.PI;
export type ModularPreset = 'basic' | 'adsr' | 'subtractive' | 'vibrato' | 'sequence';
export interface ModularSettings {
baseFrequency: number;
lfoRate: number;
lfoDepthSemitones: number;
attack: number;
cutoff: number;
gain: number;
}
export const modularPresets: Record<ModularPreset, { name: string; controls: readonly (keyof ModularSettings)[]; connections: readonly string[] }> = {
basic: { name: 'Basic VCO to VCA', controls: ['baseFrequency', 'gain'], connections: ['VCO audio → VCA audio', 'VCA audio → Output audio'] },
adsr: { name: 'ADSR articulation', controls: ['baseFrequency', 'attack', 'gain'], connections: ['Gate → Envelope gate', 'VCO audio → VCA audio', 'Envelope control → VCA gain', 'VCA audio → Output audio'] },
subtractive: { name: 'Subtractive contour', controls: ['baseFrequency', 'attack', 'cutoff', 'gain'], connections: ['Gate → Envelope gate', 'VCO audio → VCF audio', 'Envelope control → VCF cutoff', 'VCF audio → VCA audio', 'Envelope control → VCA gain', 'VCA audio → Output audio'] },
vibrato: { name: 'LFO vibrato', controls: ['baseFrequency', 'lfoRate', 'lfoDepthSemitones', 'gain'], connections: ['LFO control → VCO pitch', 'VCO audio → VCA audio', 'VCA audio → Output audio'] },
sequence: { name: 'Clock Garden browser patch', controls: ['cutoff', 'gain'], connections: ['Sequencer pitch → VCO pitch', 'Sequencer gate → Envelope gate', 'VCO audio → VCF audio', 'Envelope control → VCF cutoff', 'VCF audio → VCA audio', 'Envelope control → VCA gain', 'VCA audio → Output audio'] }
};
function finite(value: number, label: string) {
if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite`);
return value;
}
function positive(value: number, label: string) {
if (finite(value, label) <= 0) throw new RangeError(`${label} must be positive`);
return value;
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.max(minimum, Math.min(maximum, value));
}
export function pitchFromOctaves(baseFrequency: number, octaveControl: number) {
return positive(baseFrequency, 'Base frequency') * 2 ** finite(octaveControl, 'Octave control');
}
export function linearEnvelopeSegment(time: number, start: number, target: number, duration: number) {
finite(time, 'Time');
finite(start, 'Start level');
finite(target, 'Target level');
if (finite(duration, 'Duration') <= 0) return target;
return start + (target - start) * clamp(time / duration, 0, 1);
}
export function onePoleCoefficient(cutoff: number, sampleRate = MODULAR_SAMPLE_RATE) {
const rate = positive(sampleRate, 'Sample rate');
const safeCutoff = clamp(positive(cutoff, 'Cutoff'), Number.EPSILON, 0.45 * rate);
return Math.exp(-TAU * safeCutoff / rate);
}
export function holdControl(values: readonly number[], ratio: number) {
if (!values.length) throw new RangeError('Control values cannot be empty');
if (!Number.isInteger(ratio) || ratio < 1) throw new RangeError('Hold ratio must be a positive integer');
const output = new Float32Array(values.length * ratio);
values.forEach((value, index) => output.fill(finite(value, 'Control value'), index * ratio, (index + 1) * ratio));
return output;
}
export function renderBoundedFeedback(input: Float32Array, gain: number) {
if (Math.abs(finite(gain, 'Feedback gain')) >= 1) throw new RangeError('Feedback gain magnitude must stay below one');
const output = new Float32Array(input.length);
for (let index = 0; index < input.length; index++) {
const delayed = index ? output[index - 1] : 0;
output[index] = finite(input[index], 'Input sample') + gain * Math.tanh(delayed);
}
return output;
}
function envelopeOn(time: number, attack: number, decay = 0.08, sustain = 0.6) {
if (time < attack) return linearEnvelopeSegment(time, 0, 1, attack);
if (time < attack + decay) return linearEnvelopeSegment(time - attack, 1, sustain, decay);
return sustain;
}
function repeatedEnvelope(time: number, period: number, gateLength: number, attack: number, decay = 0.08, sustain = 0.6, release = 0.06) {
const local = time % period;
if (local < gateLength) return envelopeOn(local, attack, decay, sustain);
return linearEnvelopeSegment(local - gateLength, envelopeOn(gateLength, attack, decay, sustain), 0, Math.min(release, period - gateLength));
}
function preset(id: ModularPreset) {
if (!(id in modularPresets)) throw new RangeError('Unknown modular preset');
return modularPresets[id];
}
export function renderModularPreset(id: ModularPreset, settings: ModularSettings, seconds = 2.4) {
preset(id);
const frames = Math.round(positive(seconds, 'Duration') * MODULAR_SAMPLE_RATE);
const output = new Float32Array(frames);
const notes = [48, 55, 60, 63, 60, 55, 51, 55];
const baseFrequency = positive(settings.baseFrequency, 'Base frequency');
const lfoRate = positive(settings.lfoRate, 'LFO rate');
const lfoDepth = clamp(finite(settings.lfoDepthSemitones, 'LFO depth'), 0, 12);
const attack = clamp(finite(settings.attack, 'Attack'), 0, 1);
const cutoff = positive(settings.cutoff, 'Cutoff');
const gain = clamp(finite(settings.gain, 'Gain'), 0, 1);
let phase = 0;
let filterState = 0;
for (let index = 0; index < frames; index++) {
const time = index / MODULAR_SAMPLE_RATE;
const sequenceStep = Math.floor(time / 0.25) % notes.length;
const octaveControl = id === 'sequence'
? (notes[sequenceStep] - 69) / 12
: id === 'vibrato'
? lfoDepth * Math.sin(TAU * lfoRate * time) / 12
: 0;
const frequency = id === 'sequence' ? pitchFromOctaves(440, octaveControl) : pitchFromOctaves(baseFrequency, octaveControl);
const source = id === 'sequence' ? 1 - 4 * Math.abs(phase - 0.5) : 2 * phase - 1;
phase = (phase + frequency / MODULAR_SAMPLE_RATE) % 1;
const articulated = id === 'adsr' || id === 'subtractive' || id === 'sequence';
const period = id === 'sequence' ? 0.25 : 0.8;
const gateLength = id === 'sequence' ? 0.2 : 0.55;
const envelope = id === 'sequence'
? repeatedEnvelope(time, period, gateLength, 0.005, 0.06, 0.6, 0.04)
: articulated
? repeatedEnvelope(time, period, gateLength, attack)
: 1;
let signal = source;
if (id === 'subtractive' || id === 'sequence') {
const movingCutoff = id === 'sequence' ? 600 + cutoff * envelope : cutoff * (0.25 + 1.75 * envelope);
const coefficient = onePoleCoefficient(movingCutoff);
filterState = (1 - coefficient) * signal + coefficient * filterState;
signal = filterState;
}
const fadeFrames = Math.max(1, Math.round(0.006 * MODULAR_SAMPLE_RATE));
const edge = Math.min(1, index / fadeFrames, (frames - 1 - index) / fadeFrames);
output[index] = index === 0 || index === frames - 1 ? 0 : 0.12 * Math.tanh(signal) * gain * envelope * edge;
}
return output;
}
export function sampleModularWindow(id: ModularPreset, settings: ModularSettings, seconds = 0.08) {
const output = renderModularPreset(id, settings, seconds);
const points = Math.min(320, output.length);
const stride = Math.max(1, Math.floor(output.length / points));
return Float32Array.from({ length: points }, (_, index) => output[Math.min(output.length - 1, index * stride)]);
}
export type ModulationDestination = 'pitch' | 'gain' | 'cutoff';
export type ControlMode = 'sample' | 'held' | 'interpolated';
export const modulationConnections: Record<ModulationDestination, string> = {
pitch: 'LFO control → VCO pitch',
gain: 'LFO control → VCA gain',
cutoff: 'LFO control → teaching VCF cutoff'
};
export interface ModulationTrajectories {
duration: number;
control: Float32Array;
parameter: Float32Array;
envelope: Float32Array;
output: Float32Array;
}
export function renderModulationDestination(destination: ModulationDestination, seconds = 1.25): ModulationTrajectories {
if (!(destination in modulationConnections)) throw new RangeError('Unknown modulation destination');
const duration = positive(seconds, 'Duration');
const frames = Math.round(duration * MODULAR_SAMPLE_RATE);
const control = new Float32Array(frames);
const parameter = new Float32Array(frames);
const envelope = new Float32Array(frames);
const output = new Float32Array(frames);
let phase = 0;
let filterState = 0;
const fadeFrames = Math.round(.008 * MODULAR_SAMPLE_RATE);
for (let index = 0; index < frames; index++) {
const time = index / MODULAR_SAMPLE_RATE;
const lfo = Math.sin(TAU * time);
control[index] = lfo;
const frequency = destination === 'pitch' ? pitchFromOctaves(220, lfo / 6) : 220;
const vcaGain = destination === 'gain' ? .35 + .25 * lfo : .35;
const cutoff = destination === 'cutoff' ? 900 * 2 ** (1.5 * lfo) : 4_000;
parameter[index] = destination === 'pitch' ? frequency : destination === 'gain' ? vcaGain : cutoff;
envelope[index] = vcaGain;
const source = 2 * phase - 1;
phase = (phase + frequency / MODULAR_SAMPLE_RATE) % 1;
let signal = source;
if (destination === 'cutoff') {
const coefficient = onePoleCoefficient(cutoff);
filterState = (1 - coefficient) * signal + coefficient * filterState;
signal = filterState;
}
const edge = Math.min(1, index / fadeFrames, (frames - 1 - index) / fadeFrames);
output[index] = index === 0 || index === frames - 1 ? 0 : .1 * Math.tanh(signal) * vcaGain * edge;
}
return { duration, control, parameter, envelope, output };
}
export function interpolateControl(values: readonly number[], ratio: number) {
if (!values.length) throw new RangeError('Control values cannot be empty');
if (!Number.isInteger(ratio) || ratio < 1) throw new RangeError('Interpolation ratio must be a positive integer');
const output = new Float32Array(values.length * ratio);
for (let index = 0; index < output.length; index++) {
const start = Math.floor(index / ratio);
const fraction = (index % ratio) / ratio;
const from = finite(values[start], 'Control value');
const to = finite(values[Math.min(start + 1, values.length - 1)], 'Control value');
output[index] = from + (to - from) * fraction;
}
return output;
}
export function controlRateTrajectories(updateRate: number, seconds = 1) {
const rate = positive(updateRate, 'Control update rate');
const frames = Math.round(positive(seconds, 'Duration') * MODULAR_SAMPLE_RATE);
const ratio = Math.max(1, Math.round(MODULAR_SAMPLE_RATE / rate));
const perSample = Float32Array.from({ length: frames }, (_, index) => Math.sin(TAU * 5 * index / MODULAR_SAMPLE_RATE));
const values = Array.from({ length: Math.ceil(frames / ratio) + 1 }, (_, index) => Math.sin(TAU * 5 * index * ratio / MODULAR_SAMPLE_RATE));
return {
duration: frames / MODULAR_SAMPLE_RATE,
ratio,
perSample,
held: holdControl(values, ratio).slice(0, frames),
interpolated: interpolateControl(values, ratio).slice(0, frames)
};
}
export function renderControlRate(mode: ControlMode, updateRate: number, seconds = 1) {
const trajectories = controlRateTrajectories(updateRate, seconds);
const control = mode === 'sample' ? trajectories.perSample : trajectories[mode];
const output = new Float32Array(control.length);
let phase = 0;
const fadeFrames = Math.round(.008 * MODULAR_SAMPLE_RATE);
for (let index = 0; index < output.length; index++) {
const frequency = pitchFromOctaves(440, control[index] * .25);
phase = (phase + frequency / MODULAR_SAMPLE_RATE) % 1;
const edge = Math.min(1, index / fadeFrames, (output.length - 1 - index) / fadeFrames);
output[index] = index === 0 || index === output.length - 1 ? 0 : .035 * Math.sin(TAU * phase) * edge;
}
return output;
}
export const FEEDBACK_DELAY_SECONDS = .12;
export const MAX_FEEDBACK_GAIN = .85;
export function renderFeedbackDecay(gain: number, seconds = 2) {
const safeGain = finite(gain, 'Feedback gain');
if (safeGain < 0 || safeGain > MAX_FEEDBACK_GAIN) throw new RangeError(`Feedback gain must be between zero and ${MAX_FEEDBACK_GAIN}`);
const frames = Math.round(positive(seconds, 'Duration') * MODULAR_SAMPLE_RATE);
const delaySamples = Math.round(FEEDBACK_DELAY_SECONDS * MODULAR_SAMPLE_RATE);
const output = new Float32Array(frames);
const excitationFrames = Math.round(.012 * MODULAR_SAMPLE_RATE);
for (let index = 0; index < frames; index++) {
const excitation = index < excitationFrames ? .22 * Math.sin(TAU * 330 * index / MODULAR_SAMPLE_RATE) * Math.sin(Math.PI * index / excitationFrames) : 0;
const delayed = index >= delaySamples ? output[index - delaySamples] : 0;
output[index] = excitation + safeGain * Math.tanh(delayed);
}
return { delaySamples, model: output, samples: Float32Array.from(output, (sample, index) => {
const edge = Math.min(1, (frames - 1 - index) / Math.round(.008 * MODULAR_SAMPLE_RATE));
return index === frames - 1 ? 0 : .1 * sample * edge;
}) };
}
export function feedbackEchoRms(model: Float32Array, delaySamples = Math.round(FEEDBACK_DELAY_SECONDS * MODULAR_SAMPLE_RATE)) {
if (!Number.isInteger(delaySamples) || delaySamples < 1) throw new RangeError('Delay must be a positive sample count');
const window = Math.min(Math.round(.012 * MODULAR_SAMPLE_RATE), delaySamples);
const echoes = Math.floor(model.length / delaySamples);
return Float32Array.from({ length: echoes }, (_, echo) => {
let sum = 0;
const start = echo * delaySamples;
for (let index = 0; index < window && start + index < model.length; index++) sum += model[start + index] ** 2;
return Math.sqrt(sum / window);
});
}
An envelope joins exact line segments
Let one envelope stage begin at , target , and last positive duration seconds:
Long description. A line begins at envelope level 0.25, reaches 0.9 exactly at 0.8 seconds, and remains there. Endpoint markers identify the start and target.
Visual provenance. Deterministic output from formula_visuals_ch04.py. Parameters: Start 0.25; target 0.9; duration 0.8 second; plot through 1.2 seconds.
An attack-decay-sustain-release (ADSR) envelope applies the same rule state by state. Attack rises toward one. Decay falls toward sustain level . Sustain holds that level while the gate remains active. Release falls from the current level toward zero after the gate ends. A real-time design must state how retriggering works. This study restarts attack from its current level, releases from its current level, and makes a zero-duration segment jump to its target (Buchla Associates 1968; Moog 1964).
Long description. A gate begins at 0.1 second and ends at 1.2 seconds. The envelope attacks, decays to a 0.55 sustain level, holds while the gate remains active, then releases. The output follows the same contour.
Figure and audio provenance. Project-authored deterministic output from ch04_modular.py with a 220 Hz teaching saw; attack 0.1 second, decay 0.2 second, sustain 0.55, and release 0.35 second.
Control rate trades work for stair steps
Suppose one control sample is evaluated every audio samples and held:
Long description. Four control samples are marked at sample numbers 0, 8, 16, and 24. A staircase holds each value across the next eight audio samples.
Visual provenance. Deterministic output from formula_visuals_ch04.py. Parameters: Hold ratio eight; control values 0.1, 0.8, 0.35, and 0.65.
The comparison uses a 440 Hz sine carrier, a 5 Hz sinusoidal pitch control, and a depth of three semitones. Holding can create stair-step sidebands. Linear interpolation reduces the discontinuity without restoring information above the control update rate.
Long description. The upper panel compares instantaneous frequency under per-sample, 100 Hz held, and 100 Hz linearly interpolated control. The lower panel compares their spectra below 3 kHz.
Figure and audio provenance. Project-authored deterministic output from ch04_modular.py. Audio presents one second of each model with two ten-millisecond silences.
FIXED A/B EXPERIMENT
Hear control-rate stair steps
Compare the same 5 Hz pitch control evaluated per sample, held between updates, or linearly interpolated. Holding creates abrupt stair steps; interpolation connects updates but cannot restore missing control information.
Hearing safety. Lower device volume before playback. All three methods use the same conservative output gain.
At 50 updates per second, each held value lasts 960 audio samples. Per-sample is selected for playback.
Read the exact source running this experiment
The disclosed TypeScript generates all three plotted controls and the selected audible render.
export const MODULAR_SAMPLE_RATE = 48_000;
const TAU = 2 * Math.PI;
export type ModularPreset = 'basic' | 'adsr' | 'subtractive' | 'vibrato' | 'sequence';
export interface ModularSettings {
baseFrequency: number;
lfoRate: number;
lfoDepthSemitones: number;
attack: number;
cutoff: number;
gain: number;
}
export const modularPresets: Record<ModularPreset, { name: string; controls: readonly (keyof ModularSettings)[]; connections: readonly string[] }> = {
basic: { name: 'Basic VCO to VCA', controls: ['baseFrequency', 'gain'], connections: ['VCO audio → VCA audio', 'VCA audio → Output audio'] },
adsr: { name: 'ADSR articulation', controls: ['baseFrequency', 'attack', 'gain'], connections: ['Gate → Envelope gate', 'VCO audio → VCA audio', 'Envelope control → VCA gain', 'VCA audio → Output audio'] },
subtractive: { name: 'Subtractive contour', controls: ['baseFrequency', 'attack', 'cutoff', 'gain'], connections: ['Gate → Envelope gate', 'VCO audio → VCF audio', 'Envelope control → VCF cutoff', 'VCF audio → VCA audio', 'Envelope control → VCA gain', 'VCA audio → Output audio'] },
vibrato: { name: 'LFO vibrato', controls: ['baseFrequency', 'lfoRate', 'lfoDepthSemitones', 'gain'], connections: ['LFO control → VCO pitch', 'VCO audio → VCA audio', 'VCA audio → Output audio'] },
sequence: { name: 'Clock Garden browser patch', controls: ['cutoff', 'gain'], connections: ['Sequencer pitch → VCO pitch', 'Sequencer gate → Envelope gate', 'VCO audio → VCF audio', 'Envelope control → VCF cutoff', 'VCF audio → VCA audio', 'Envelope control → VCA gain', 'VCA audio → Output audio'] }
};
function finite(value: number, label: string) {
if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite`);
return value;
}
function positive(value: number, label: string) {
if (finite(value, label) <= 0) throw new RangeError(`${label} must be positive`);
return value;
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.max(minimum, Math.min(maximum, value));
}
export function pitchFromOctaves(baseFrequency: number, octaveControl: number) {
return positive(baseFrequency, 'Base frequency') * 2 ** finite(octaveControl, 'Octave control');
}
export function linearEnvelopeSegment(time: number, start: number, target: number, duration: number) {
finite(time, 'Time');
finite(start, 'Start level');
finite(target, 'Target level');
if (finite(duration, 'Duration') <= 0) return target;
return start + (target - start) * clamp(time / duration, 0, 1);
}
export function onePoleCoefficient(cutoff: number, sampleRate = MODULAR_SAMPLE_RATE) {
const rate = positive(sampleRate, 'Sample rate');
const safeCutoff = clamp(positive(cutoff, 'Cutoff'), Number.EPSILON, 0.45 * rate);
return Math.exp(-TAU * safeCutoff / rate);
}
export function holdControl(values: readonly number[], ratio: number) {
if (!values.length) throw new RangeError('Control values cannot be empty');
if (!Number.isInteger(ratio) || ratio < 1) throw new RangeError('Hold ratio must be a positive integer');
const output = new Float32Array(values.length * ratio);
values.forEach((value, index) => output.fill(finite(value, 'Control value'), index * ratio, (index + 1) * ratio));
return output;
}
export function renderBoundedFeedback(input: Float32Array, gain: number) {
if (Math.abs(finite(gain, 'Feedback gain')) >= 1) throw new RangeError('Feedback gain magnitude must stay below one');
const output = new Float32Array(input.length);
for (let index = 0; index < input.length; index++) {
const delayed = index ? output[index - 1] : 0;
output[index] = finite(input[index], 'Input sample') + gain * Math.tanh(delayed);
}
return output;
}
function envelopeOn(time: number, attack: number, decay = 0.08, sustain = 0.6) {
if (time < attack) return linearEnvelopeSegment(time, 0, 1, attack);
if (time < attack + decay) return linearEnvelopeSegment(time - attack, 1, sustain, decay);
return sustain;
}
function repeatedEnvelope(time: number, period: number, gateLength: number, attack: number, decay = 0.08, sustain = 0.6, release = 0.06) {
const local = time % period;
if (local < gateLength) return envelopeOn(local, attack, decay, sustain);
return linearEnvelopeSegment(local - gateLength, envelopeOn(gateLength, attack, decay, sustain), 0, Math.min(release, period - gateLength));
}
function preset(id: ModularPreset) {
if (!(id in modularPresets)) throw new RangeError('Unknown modular preset');
return modularPresets[id];
}
export function renderModularPreset(id: ModularPreset, settings: ModularSettings, seconds = 2.4) {
preset(id);
const frames = Math.round(positive(seconds, 'Duration') * MODULAR_SAMPLE_RATE);
const output = new Float32Array(frames);
const notes = [48, 55, 60, 63, 60, 55, 51, 55];
const baseFrequency = positive(settings.baseFrequency, 'Base frequency');
const lfoRate = positive(settings.lfoRate, 'LFO rate');
const lfoDepth = clamp(finite(settings.lfoDepthSemitones, 'LFO depth'), 0, 12);
const attack = clamp(finite(settings.attack, 'Attack'), 0, 1);
const cutoff = positive(settings.cutoff, 'Cutoff');
const gain = clamp(finite(settings.gain, 'Gain'), 0, 1);
let phase = 0;
let filterState = 0;
for (let index = 0; index < frames; index++) {
const time = index / MODULAR_SAMPLE_RATE;
const sequenceStep = Math.floor(time / 0.25) % notes.length;
const octaveControl = id === 'sequence'
? (notes[sequenceStep] - 69) / 12
: id === 'vibrato'
? lfoDepth * Math.sin(TAU * lfoRate * time) / 12
: 0;
const frequency = id === 'sequence' ? pitchFromOctaves(440, octaveControl) : pitchFromOctaves(baseFrequency, octaveControl);
const source = id === 'sequence' ? 1 - 4 * Math.abs(phase - 0.5) : 2 * phase - 1;
phase = (phase + frequency / MODULAR_SAMPLE_RATE) % 1;
const articulated = id === 'adsr' || id === 'subtractive' || id === 'sequence';
const period = id === 'sequence' ? 0.25 : 0.8;
const gateLength = id === 'sequence' ? 0.2 : 0.55;
const envelope = id === 'sequence'
? repeatedEnvelope(time, period, gateLength, 0.005, 0.06, 0.6, 0.04)
: articulated
? repeatedEnvelope(time, period, gateLength, attack)
: 1;
let signal = source;
if (id === 'subtractive' || id === 'sequence') {
const movingCutoff = id === 'sequence' ? 600 + cutoff * envelope : cutoff * (0.25 + 1.75 * envelope);
const coefficient = onePoleCoefficient(movingCutoff);
filterState = (1 - coefficient) * signal + coefficient * filterState;
signal = filterState;
}
const fadeFrames = Math.max(1, Math.round(0.006 * MODULAR_SAMPLE_RATE));
const edge = Math.min(1, index / fadeFrames, (frames - 1 - index) / fadeFrames);
output[index] = index === 0 || index === frames - 1 ? 0 : 0.12 * Math.tanh(signal) * gain * envelope * edge;
}
return output;
}
export function sampleModularWindow(id: ModularPreset, settings: ModularSettings, seconds = 0.08) {
const output = renderModularPreset(id, settings, seconds);
const points = Math.min(320, output.length);
const stride = Math.max(1, Math.floor(output.length / points));
return Float32Array.from({ length: points }, (_, index) => output[Math.min(output.length - 1, index * stride)]);
}
export type ModulationDestination = 'pitch' | 'gain' | 'cutoff';
export type ControlMode = 'sample' | 'held' | 'interpolated';
export const modulationConnections: Record<ModulationDestination, string> = {
pitch: 'LFO control → VCO pitch',
gain: 'LFO control → VCA gain',
cutoff: 'LFO control → teaching VCF cutoff'
};
export interface ModulationTrajectories {
duration: number;
control: Float32Array;
parameter: Float32Array;
envelope: Float32Array;
output: Float32Array;
}
export function renderModulationDestination(destination: ModulationDestination, seconds = 1.25): ModulationTrajectories {
if (!(destination in modulationConnections)) throw new RangeError('Unknown modulation destination');
const duration = positive(seconds, 'Duration');
const frames = Math.round(duration * MODULAR_SAMPLE_RATE);
const control = new Float32Array(frames);
const parameter = new Float32Array(frames);
const envelope = new Float32Array(frames);
const output = new Float32Array(frames);
let phase = 0;
let filterState = 0;
const fadeFrames = Math.round(.008 * MODULAR_SAMPLE_RATE);
for (let index = 0; index < frames; index++) {
const time = index / MODULAR_SAMPLE_RATE;
const lfo = Math.sin(TAU * time);
control[index] = lfo;
const frequency = destination === 'pitch' ? pitchFromOctaves(220, lfo / 6) : 220;
const vcaGain = destination === 'gain' ? .35 + .25 * lfo : .35;
const cutoff = destination === 'cutoff' ? 900 * 2 ** (1.5 * lfo) : 4_000;
parameter[index] = destination === 'pitch' ? frequency : destination === 'gain' ? vcaGain : cutoff;
envelope[index] = vcaGain;
const source = 2 * phase - 1;
phase = (phase + frequency / MODULAR_SAMPLE_RATE) % 1;
let signal = source;
if (destination === 'cutoff') {
const coefficient = onePoleCoefficient(cutoff);
filterState = (1 - coefficient) * signal + coefficient * filterState;
signal = filterState;
}
const edge = Math.min(1, index / fadeFrames, (frames - 1 - index) / fadeFrames);
output[index] = index === 0 || index === frames - 1 ? 0 : .1 * Math.tanh(signal) * vcaGain * edge;
}
return { duration, control, parameter, envelope, output };
}
export function interpolateControl(values: readonly number[], ratio: number) {
if (!values.length) throw new RangeError('Control values cannot be empty');
if (!Number.isInteger(ratio) || ratio < 1) throw new RangeError('Interpolation ratio must be a positive integer');
const output = new Float32Array(values.length * ratio);
for (let index = 0; index < output.length; index++) {
const start = Math.floor(index / ratio);
const fraction = (index % ratio) / ratio;
const from = finite(values[start], 'Control value');
const to = finite(values[Math.min(start + 1, values.length - 1)], 'Control value');
output[index] = from + (to - from) * fraction;
}
return output;
}
export function controlRateTrajectories(updateRate: number, seconds = 1) {
const rate = positive(updateRate, 'Control update rate');
const frames = Math.round(positive(seconds, 'Duration') * MODULAR_SAMPLE_RATE);
const ratio = Math.max(1, Math.round(MODULAR_SAMPLE_RATE / rate));
const perSample = Float32Array.from({ length: frames }, (_, index) => Math.sin(TAU * 5 * index / MODULAR_SAMPLE_RATE));
const values = Array.from({ length: Math.ceil(frames / ratio) + 1 }, (_, index) => Math.sin(TAU * 5 * index * ratio / MODULAR_SAMPLE_RATE));
return {
duration: frames / MODULAR_SAMPLE_RATE,
ratio,
perSample,
held: holdControl(values, ratio).slice(0, frames),
interpolated: interpolateControl(values, ratio).slice(0, frames)
};
}
export function renderControlRate(mode: ControlMode, updateRate: number, seconds = 1) {
const trajectories = controlRateTrajectories(updateRate, seconds);
const control = mode === 'sample' ? trajectories.perSample : trajectories[mode];
const output = new Float32Array(control.length);
let phase = 0;
const fadeFrames = Math.round(.008 * MODULAR_SAMPLE_RATE);
for (let index = 0; index < output.length; index++) {
const frequency = pitchFromOctaves(440, control[index] * .25);
phase = (phase + frequency / MODULAR_SAMPLE_RATE) % 1;
const edge = Math.min(1, index / fadeFrames, (output.length - 1 - index) / fadeFrames);
output[index] = index === 0 || index === output.length - 1 ? 0 : .035 * Math.sin(TAU * phase) * edge;
}
return output;
}
export const FEEDBACK_DELAY_SECONDS = .12;
export const MAX_FEEDBACK_GAIN = .85;
export function renderFeedbackDecay(gain: number, seconds = 2) {
const safeGain = finite(gain, 'Feedback gain');
if (safeGain < 0 || safeGain > MAX_FEEDBACK_GAIN) throw new RangeError(`Feedback gain must be between zero and ${MAX_FEEDBACK_GAIN}`);
const frames = Math.round(positive(seconds, 'Duration') * MODULAR_SAMPLE_RATE);
const delaySamples = Math.round(FEEDBACK_DELAY_SECONDS * MODULAR_SAMPLE_RATE);
const output = new Float32Array(frames);
const excitationFrames = Math.round(.012 * MODULAR_SAMPLE_RATE);
for (let index = 0; index < frames; index++) {
const excitation = index < excitationFrames ? .22 * Math.sin(TAU * 330 * index / MODULAR_SAMPLE_RATE) * Math.sin(Math.PI * index / excitationFrames) : 0;
const delayed = index >= delaySamples ? output[index - delaySamples] : 0;
output[index] = excitation + safeGain * Math.tanh(delayed);
}
return { delaySamples, model: output, samples: Float32Array.from(output, (sample, index) => {
const edge = Math.min(1, (frames - 1 - index) / Math.round(.008 * MODULAR_SAMPLE_RATE));
return index === frames - 1 ? 0 : .1 * sample * edge;
}) };
}
export function feedbackEchoRms(model: Float32Array, delaySamples = Math.round(FEEDBACK_DELAY_SECONDS * MODULAR_SAMPLE_RATE)) {
if (!Number.isInteger(delaySamples) || delaySamples < 1) throw new RangeError('Delay must be a positive sample count');
const window = Math.min(Math.round(.012 * MODULAR_SAMPLE_RATE), delaySamples);
const echoes = Math.floor(model.length / delaySamples);
return Float32Array.from({ length: echoes }, (_, echo) => {
let sum = 0;
const start = echo * delaySamples;
for (let index = 0; index < window && start + index < model.length; index++) sum += model[start + index] ** 2;
return Math.sqrt(sum / window);
});
}
Feedback needs delay and a bound
A feedback path returns an earlier output to an input. If a digital path asks for the current output before computing it, the graph has no causal order. Insert an explicit integer delay of at least one sample, keep return-gain magnitude below one, and bound the returned branch in this teaching model (Smith and Lee 2008; W3C Audio Working Group, n.d.).
| Symbol | Meaning in this declared model |
|---|---|
| external input sample arriving now | |
| processor input after external and returned paths mix | |
| processor output sample | |
| unitless feedback gain on the returned branch | |
| integer return delay in samples, at least one |
The adjacent equation visual uses the identity processor, so . The recurrence is:
The equation visual below chooses to expose sample-by-sample decay. The browser experiment chooses 5,760 samples, or 120 ms at 48 kHz, so separate returns are easy to hear. The recurrence is the same; only delay length changes.
Long description. With zero feedback, only the first impulse remains. Gains 0.7 and 0.95 produce progressively longer decays. For bounded input, the hyperbolic tangent and gain below one keep every sample finite in this teaching loop.
Visual provenance. Deterministic output from formula_visuals_ch04.py. Parameters: Half-unit impulse; gains zero, 0.7, and 0.95; 240 samples; identity processor for this adjacent plot.
Long description. A processor output reaches the main output and also travels through feedback gain, then a one-sample delay, then a mixer. The original node output enters the same mixer before returning to the processor input.
Diagram provenance. Project-authored SVG of the declared discrete-time feedback model with .
Feedback can create resonance, self-oscillation, saturation, or instability in broader systems. The result depends on gain, phase, delay, and nonlinearity. The Equation 4.7 visual caps gain at 0.95, chooses one sample of delay, and uses the identity processor. The browser experiment keeps the identity processor but uses a 120 ms delay. The fixed WAV below uses a different declared study: a 900 Hz one-pole filter follows the mixer. Its three one-second sections reset state and use , , and . The file carries an additional 12 dB attenuation.
Long description. Three one-second sections reset state before gains zero, 0.7, and 0.95. Dashed boundaries separate the sections. Higher feedback gain produces a longer filtered decay.
Figure and audio provenance. Project-authored deterministic output from ch04_modular.py. A 900 Hz one-pole filter follows the feedback mixer. The WAV receives an additional 12 dB attenuation.
BOUNDED FEEDBACK EXPERIMENT
Delay first, then decay
One bounded 12 ms excitation enters an identity processor. Its output returns through a 120 ms explicit delay, conservative feedback gain, and tanh soft bound. This is an echo-decay teaching loop, not a reverb, resonator, or analog circuit model.
Hearing safety. Lower device volume before playback. Gain is capped and playback receives an additional 0.1 scale.
Exact boundary: y[n] = x[n] + k tanh(y[n - 5760]) at 48 kHz. Gain is capped at 0.85. Playback applies an additional 0.1 scale.
Gain 0.65 produces echoes spaced 120 ms apart. The first returned echo cannot occur before the explicit delay.
Read the exact source running this experiment
The disclosed TypeScript generates the bounded model, RMS values, and conservatively scaled audible samples.
export const MODULAR_SAMPLE_RATE = 48_000;
const TAU = 2 * Math.PI;
export type ModularPreset = 'basic' | 'adsr' | 'subtractive' | 'vibrato' | 'sequence';
export interface ModularSettings {
baseFrequency: number;
lfoRate: number;
lfoDepthSemitones: number;
attack: number;
cutoff: number;
gain: number;
}
export const modularPresets: Record<ModularPreset, { name: string; controls: readonly (keyof ModularSettings)[]; connections: readonly string[] }> = {
basic: { name: 'Basic VCO to VCA', controls: ['baseFrequency', 'gain'], connections: ['VCO audio → VCA audio', 'VCA audio → Output audio'] },
adsr: { name: 'ADSR articulation', controls: ['baseFrequency', 'attack', 'gain'], connections: ['Gate → Envelope gate', 'VCO audio → VCA audio', 'Envelope control → VCA gain', 'VCA audio → Output audio'] },
subtractive: { name: 'Subtractive contour', controls: ['baseFrequency', 'attack', 'cutoff', 'gain'], connections: ['Gate → Envelope gate', 'VCO audio → VCF audio', 'Envelope control → VCF cutoff', 'VCF audio → VCA audio', 'Envelope control → VCA gain', 'VCA audio → Output audio'] },
vibrato: { name: 'LFO vibrato', controls: ['baseFrequency', 'lfoRate', 'lfoDepthSemitones', 'gain'], connections: ['LFO control → VCO pitch', 'VCO audio → VCA audio', 'VCA audio → Output audio'] },
sequence: { name: 'Clock Garden browser patch', controls: ['cutoff', 'gain'], connections: ['Sequencer pitch → VCO pitch', 'Sequencer gate → Envelope gate', 'VCO audio → VCF audio', 'Envelope control → VCF cutoff', 'VCF audio → VCA audio', 'Envelope control → VCA gain', 'VCA audio → Output audio'] }
};
function finite(value: number, label: string) {
if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite`);
return value;
}
function positive(value: number, label: string) {
if (finite(value, label) <= 0) throw new RangeError(`${label} must be positive`);
return value;
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.max(minimum, Math.min(maximum, value));
}
export function pitchFromOctaves(baseFrequency: number, octaveControl: number) {
return positive(baseFrequency, 'Base frequency') * 2 ** finite(octaveControl, 'Octave control');
}
export function linearEnvelopeSegment(time: number, start: number, target: number, duration: number) {
finite(time, 'Time');
finite(start, 'Start level');
finite(target, 'Target level');
if (finite(duration, 'Duration') <= 0) return target;
return start + (target - start) * clamp(time / duration, 0, 1);
}
export function onePoleCoefficient(cutoff: number, sampleRate = MODULAR_SAMPLE_RATE) {
const rate = positive(sampleRate, 'Sample rate');
const safeCutoff = clamp(positive(cutoff, 'Cutoff'), Number.EPSILON, 0.45 * rate);
return Math.exp(-TAU * safeCutoff / rate);
}
export function holdControl(values: readonly number[], ratio: number) {
if (!values.length) throw new RangeError('Control values cannot be empty');
if (!Number.isInteger(ratio) || ratio < 1) throw new RangeError('Hold ratio must be a positive integer');
const output = new Float32Array(values.length * ratio);
values.forEach((value, index) => output.fill(finite(value, 'Control value'), index * ratio, (index + 1) * ratio));
return output;
}
export function renderBoundedFeedback(input: Float32Array, gain: number) {
if (Math.abs(finite(gain, 'Feedback gain')) >= 1) throw new RangeError('Feedback gain magnitude must stay below one');
const output = new Float32Array(input.length);
for (let index = 0; index < input.length; index++) {
const delayed = index ? output[index - 1] : 0;
output[index] = finite(input[index], 'Input sample') + gain * Math.tanh(delayed);
}
return output;
}
function envelopeOn(time: number, attack: number, decay = 0.08, sustain = 0.6) {
if (time < attack) return linearEnvelopeSegment(time, 0, 1, attack);
if (time < attack + decay) return linearEnvelopeSegment(time - attack, 1, sustain, decay);
return sustain;
}
function repeatedEnvelope(time: number, period: number, gateLength: number, attack: number, decay = 0.08, sustain = 0.6, release = 0.06) {
const local = time % period;
if (local < gateLength) return envelopeOn(local, attack, decay, sustain);
return linearEnvelopeSegment(local - gateLength, envelopeOn(gateLength, attack, decay, sustain), 0, Math.min(release, period - gateLength));
}
function preset(id: ModularPreset) {
if (!(id in modularPresets)) throw new RangeError('Unknown modular preset');
return modularPresets[id];
}
export function renderModularPreset(id: ModularPreset, settings: ModularSettings, seconds = 2.4) {
preset(id);
const frames = Math.round(positive(seconds, 'Duration') * MODULAR_SAMPLE_RATE);
const output = new Float32Array(frames);
const notes = [48, 55, 60, 63, 60, 55, 51, 55];
const baseFrequency = positive(settings.baseFrequency, 'Base frequency');
const lfoRate = positive(settings.lfoRate, 'LFO rate');
const lfoDepth = clamp(finite(settings.lfoDepthSemitones, 'LFO depth'), 0, 12);
const attack = clamp(finite(settings.attack, 'Attack'), 0, 1);
const cutoff = positive(settings.cutoff, 'Cutoff');
const gain = clamp(finite(settings.gain, 'Gain'), 0, 1);
let phase = 0;
let filterState = 0;
for (let index = 0; index < frames; index++) {
const time = index / MODULAR_SAMPLE_RATE;
const sequenceStep = Math.floor(time / 0.25) % notes.length;
const octaveControl = id === 'sequence'
? (notes[sequenceStep] - 69) / 12
: id === 'vibrato'
? lfoDepth * Math.sin(TAU * lfoRate * time) / 12
: 0;
const frequency = id === 'sequence' ? pitchFromOctaves(440, octaveControl) : pitchFromOctaves(baseFrequency, octaveControl);
const source = id === 'sequence' ? 1 - 4 * Math.abs(phase - 0.5) : 2 * phase - 1;
phase = (phase + frequency / MODULAR_SAMPLE_RATE) % 1;
const articulated = id === 'adsr' || id === 'subtractive' || id === 'sequence';
const period = id === 'sequence' ? 0.25 : 0.8;
const gateLength = id === 'sequence' ? 0.2 : 0.55;
const envelope = id === 'sequence'
? repeatedEnvelope(time, period, gateLength, 0.005, 0.06, 0.6, 0.04)
: articulated
? repeatedEnvelope(time, period, gateLength, attack)
: 1;
let signal = source;
if (id === 'subtractive' || id === 'sequence') {
const movingCutoff = id === 'sequence' ? 600 + cutoff * envelope : cutoff * (0.25 + 1.75 * envelope);
const coefficient = onePoleCoefficient(movingCutoff);
filterState = (1 - coefficient) * signal + coefficient * filterState;
signal = filterState;
}
const fadeFrames = Math.max(1, Math.round(0.006 * MODULAR_SAMPLE_RATE));
const edge = Math.min(1, index / fadeFrames, (frames - 1 - index) / fadeFrames);
output[index] = index === 0 || index === frames - 1 ? 0 : 0.12 * Math.tanh(signal) * gain * envelope * edge;
}
return output;
}
export function sampleModularWindow(id: ModularPreset, settings: ModularSettings, seconds = 0.08) {
const output = renderModularPreset(id, settings, seconds);
const points = Math.min(320, output.length);
const stride = Math.max(1, Math.floor(output.length / points));
return Float32Array.from({ length: points }, (_, index) => output[Math.min(output.length - 1, index * stride)]);
}
export type ModulationDestination = 'pitch' | 'gain' | 'cutoff';
export type ControlMode = 'sample' | 'held' | 'interpolated';
export const modulationConnections: Record<ModulationDestination, string> = {
pitch: 'LFO control → VCO pitch',
gain: 'LFO control → VCA gain',
cutoff: 'LFO control → teaching VCF cutoff'
};
export interface ModulationTrajectories {
duration: number;
control: Float32Array;
parameter: Float32Array;
envelope: Float32Array;
output: Float32Array;
}
export function renderModulationDestination(destination: ModulationDestination, seconds = 1.25): ModulationTrajectories {
if (!(destination in modulationConnections)) throw new RangeError('Unknown modulation destination');
const duration = positive(seconds, 'Duration');
const frames = Math.round(duration * MODULAR_SAMPLE_RATE);
const control = new Float32Array(frames);
const parameter = new Float32Array(frames);
const envelope = new Float32Array(frames);
const output = new Float32Array(frames);
let phase = 0;
let filterState = 0;
const fadeFrames = Math.round(.008 * MODULAR_SAMPLE_RATE);
for (let index = 0; index < frames; index++) {
const time = index / MODULAR_SAMPLE_RATE;
const lfo = Math.sin(TAU * time);
control[index] = lfo;
const frequency = destination === 'pitch' ? pitchFromOctaves(220, lfo / 6) : 220;
const vcaGain = destination === 'gain' ? .35 + .25 * lfo : .35;
const cutoff = destination === 'cutoff' ? 900 * 2 ** (1.5 * lfo) : 4_000;
parameter[index] = destination === 'pitch' ? frequency : destination === 'gain' ? vcaGain : cutoff;
envelope[index] = vcaGain;
const source = 2 * phase - 1;
phase = (phase + frequency / MODULAR_SAMPLE_RATE) % 1;
let signal = source;
if (destination === 'cutoff') {
const coefficient = onePoleCoefficient(cutoff);
filterState = (1 - coefficient) * signal + coefficient * filterState;
signal = filterState;
}
const edge = Math.min(1, index / fadeFrames, (frames - 1 - index) / fadeFrames);
output[index] = index === 0 || index === frames - 1 ? 0 : .1 * Math.tanh(signal) * vcaGain * edge;
}
return { duration, control, parameter, envelope, output };
}
export function interpolateControl(values: readonly number[], ratio: number) {
if (!values.length) throw new RangeError('Control values cannot be empty');
if (!Number.isInteger(ratio) || ratio < 1) throw new RangeError('Interpolation ratio must be a positive integer');
const output = new Float32Array(values.length * ratio);
for (let index = 0; index < output.length; index++) {
const start = Math.floor(index / ratio);
const fraction = (index % ratio) / ratio;
const from = finite(values[start], 'Control value');
const to = finite(values[Math.min(start + 1, values.length - 1)], 'Control value');
output[index] = from + (to - from) * fraction;
}
return output;
}
export function controlRateTrajectories(updateRate: number, seconds = 1) {
const rate = positive(updateRate, 'Control update rate');
const frames = Math.round(positive(seconds, 'Duration') * MODULAR_SAMPLE_RATE);
const ratio = Math.max(1, Math.round(MODULAR_SAMPLE_RATE / rate));
const perSample = Float32Array.from({ length: frames }, (_, index) => Math.sin(TAU * 5 * index / MODULAR_SAMPLE_RATE));
const values = Array.from({ length: Math.ceil(frames / ratio) + 1 }, (_, index) => Math.sin(TAU * 5 * index * ratio / MODULAR_SAMPLE_RATE));
return {
duration: frames / MODULAR_SAMPLE_RATE,
ratio,
perSample,
held: holdControl(values, ratio).slice(0, frames),
interpolated: interpolateControl(values, ratio).slice(0, frames)
};
}
export function renderControlRate(mode: ControlMode, updateRate: number, seconds = 1) {
const trajectories = controlRateTrajectories(updateRate, seconds);
const control = mode === 'sample' ? trajectories.perSample : trajectories[mode];
const output = new Float32Array(control.length);
let phase = 0;
const fadeFrames = Math.round(.008 * MODULAR_SAMPLE_RATE);
for (let index = 0; index < output.length; index++) {
const frequency = pitchFromOctaves(440, control[index] * .25);
phase = (phase + frequency / MODULAR_SAMPLE_RATE) % 1;
const edge = Math.min(1, index / fadeFrames, (output.length - 1 - index) / fadeFrames);
output[index] = index === 0 || index === output.length - 1 ? 0 : .035 * Math.sin(TAU * phase) * edge;
}
return output;
}
export const FEEDBACK_DELAY_SECONDS = .12;
export const MAX_FEEDBACK_GAIN = .85;
export function renderFeedbackDecay(gain: number, seconds = 2) {
const safeGain = finite(gain, 'Feedback gain');
if (safeGain < 0 || safeGain > MAX_FEEDBACK_GAIN) throw new RangeError(`Feedback gain must be between zero and ${MAX_FEEDBACK_GAIN}`);
const frames = Math.round(positive(seconds, 'Duration') * MODULAR_SAMPLE_RATE);
const delaySamples = Math.round(FEEDBACK_DELAY_SECONDS * MODULAR_SAMPLE_RATE);
const output = new Float32Array(frames);
const excitationFrames = Math.round(.012 * MODULAR_SAMPLE_RATE);
for (let index = 0; index < frames; index++) {
const excitation = index < excitationFrames ? .22 * Math.sin(TAU * 330 * index / MODULAR_SAMPLE_RATE) * Math.sin(Math.PI * index / excitationFrames) : 0;
const delayed = index >= delaySamples ? output[index - delaySamples] : 0;
output[index] = excitation + safeGain * Math.tanh(delayed);
}
return { delaySamples, model: output, samples: Float32Array.from(output, (sample, index) => {
const edge = Math.min(1, (frames - 1 - index) / Math.round(.008 * MODULAR_SAMPLE_RATE));
return index === frames - 1 ? 0 : .1 * sample * edge;
}) };
}
export function feedbackEchoRms(model: Float32Array, delaySamples = Math.round(FEEDBACK_DELAY_SECONDS * MODULAR_SAMPLE_RATE)) {
if (!Number.isInteger(delaySamples) || delaySamples < 1) throw new RangeError('Delay must be a positive sample count');
const window = Math.min(Math.round(.012 * MODULAR_SAMPLE_RATE), delaySamples);
const echoes = Math.floor(model.length / delaySamples);
return Float32Array.from({ length: echoes }, (_, echo) => {
let sum = 0;
const start = echo * delaySamples;
for (let index = 0; index < window && start + index < model.length; index++) sum += model[start + index] ** 2;
return Math.sqrt(sum / window);
});
}
Section II · Practice
Use the model
Calculate, listen, route, diagnose, and check your understanding.Concept check: control, contour, and feedback
Show units and intermediate steps. Answers follow the implementation notebook.
- At 48 kHz, calculate the phase increment for 480 Hz.
- Starting at 110 Hz, find frequency for and .
- If and , find .
- A VCA moves from linear gain 0.5 to unity gain 1. Find both levels in decibels and the amplitude change.
- Explain why higher makes Equation 4.4 follow an input step faster.
- A release begins at 0.7 and lasts 0.4 s. Find its level after 0.1 s.
- If at 48 kHz, find the control update rate.
- Name the exact term that makes Equation 4.7 causal in this exercise.
Worked patch: Contour Opens Color
Patch a 110 Hz saw into the one-pole VCF, then the VCA. Sweep cutoff exponentially from 8 kHz to 200 Hz over four seconds. Hold VCA gain at 0.2. Listen to the subtractive sweep.
Long description. The upper panel shows cutoff descending exponentially from 8000 to 200 Hz. The lower panel compares spectra near 0.5 and 3.5 seconds. High harmonics visible early are much quieter late.
Figure and audio provenance. Project-authored deterministic output from ch04_modular.py: 110 Hz teaching saw, cutoff from 8 kHz to 200 Hz over four seconds, and VCA gain 0.2.
Observation: the VCO’s fundamental stays fixed while spectral brightness changes. Question: can one contour articulate both loudness and color? Route one ADSR to VCA gain and a scaled copy to VCF cutoff. Different depths preserve one timing relationship without forcing identical parameter ranges.
Song study 4: Clock Garden
The original pitch sequence is MIDI
[48,55,60,63,60,55,51,55], repeated twice. Each step lasts
250 ms and holds its gate for 80 percent. ADSR uses attack .005 s, decay
.06 s, sustain .6, and release .04 s. A triangle wave enters a VCF at
Hz, then the same envelope controls VCA gain.
Long description. Four aligned traces show stepped semitone offsets, rectangular gates, repeated ADSR contours, and the final filtered waveform. Every pitch step starts with a gate and brighter envelope-driven cutoff.
Figure and audio provenance. Project-authored deterministic output from ch04_modular.py. The motif, parameters, figure, and recording are released with the chapter under CC0-1.0.
The motif and generated recordings are project-authored and released under CC0-1.0. No historical composition, arrangement, or recording is embedded. Wendy Carlos's 1968 Switched-On Bach brought the Moog synthesizer to a much wider audience, but this chapter uses only the cited Library of Congress history (Niebur, n.d.; U.S. Copyright Office, 2021). Bach’s compositions can be public domain while Carlos’s realization, performance, sound recording, cover, and notes remain separate protected works.
LAB 04
Route control through a fixed modular patch
Choose a documented preset, adjust its modules, read the connection table, and hear the same deterministic model shown in the source.
Hearing safety. Lower device volume before starting. The rendered signal uses conservative gain. Stop or Mute immediately if listening becomes uncomfortable.
Clock Garden browser boundary. At default controls, the sequence uses the study’s eight MIDI notes, 5 ms attack, 60 ms decay, 0.6 sustain level, 40 ms release, and a cutoff of 600 Hz plus 2,400 times the envelope level. The live patch adds a 0.12 master scale and a soft bound, so it is a safe interactive variant rather than a copy of the study WAV.
| Source | Destination |
|---|
Subtractive contour routes a saw through the teaching filter and VCA while one envelope controls cutoff and gain.
Try these checks
- Compare Basic VCO to VCA with ADSR articulation. Which new connection creates separate events?
- Use LFO vibrato at 1 Hz, then 10 Hz. Name the source, destination, rate, and depth.
- Lower filter cutoff in Subtractive contour. Explain what changes and what remains fixed.
- Select Clock Garden browser patch. Trace pitch, gate, envelope, filter, VCA, and output in table order.
Read the exact source running this lab
The TypeScript implements Equations 4.1 through 4.7. The five audible presets use fixed connection lists rather than a general graph editor.
export const MODULAR_SAMPLE_RATE = 48_000;
const TAU = 2 * Math.PI;
export type ModularPreset = 'basic' | 'adsr' | 'subtractive' | 'vibrato' | 'sequence';
export interface ModularSettings {
baseFrequency: number;
lfoRate: number;
lfoDepthSemitones: number;
attack: number;
cutoff: number;
gain: number;
}
export const modularPresets: Record<ModularPreset, { name: string; controls: readonly (keyof ModularSettings)[]; connections: readonly string[] }> = {
basic: { name: 'Basic VCO to VCA', controls: ['baseFrequency', 'gain'], connections: ['VCO audio → VCA audio', 'VCA audio → Output audio'] },
adsr: { name: 'ADSR articulation', controls: ['baseFrequency', 'attack', 'gain'], connections: ['Gate → Envelope gate', 'VCO audio → VCA audio', 'Envelope control → VCA gain', 'VCA audio → Output audio'] },
subtractive: { name: 'Subtractive contour', controls: ['baseFrequency', 'attack', 'cutoff', 'gain'], connections: ['Gate → Envelope gate', 'VCO audio → VCF audio', 'Envelope control → VCF cutoff', 'VCF audio → VCA audio', 'Envelope control → VCA gain', 'VCA audio → Output audio'] },
vibrato: { name: 'LFO vibrato', controls: ['baseFrequency', 'lfoRate', 'lfoDepthSemitones', 'gain'], connections: ['LFO control → VCO pitch', 'VCO audio → VCA audio', 'VCA audio → Output audio'] },
sequence: { name: 'Clock Garden browser patch', controls: ['cutoff', 'gain'], connections: ['Sequencer pitch → VCO pitch', 'Sequencer gate → Envelope gate', 'VCO audio → VCF audio', 'Envelope control → VCF cutoff', 'VCF audio → VCA audio', 'Envelope control → VCA gain', 'VCA audio → Output audio'] }
};
function finite(value: number, label: string) {
if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite`);
return value;
}
function positive(value: number, label: string) {
if (finite(value, label) <= 0) throw new RangeError(`${label} must be positive`);
return value;
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.max(minimum, Math.min(maximum, value));
}
export function pitchFromOctaves(baseFrequency: number, octaveControl: number) {
return positive(baseFrequency, 'Base frequency') * 2 ** finite(octaveControl, 'Octave control');
}
export function linearEnvelopeSegment(time: number, start: number, target: number, duration: number) {
finite(time, 'Time');
finite(start, 'Start level');
finite(target, 'Target level');
if (finite(duration, 'Duration') <= 0) return target;
return start + (target - start) * clamp(time / duration, 0, 1);
}
export function onePoleCoefficient(cutoff: number, sampleRate = MODULAR_SAMPLE_RATE) {
const rate = positive(sampleRate, 'Sample rate');
const safeCutoff = clamp(positive(cutoff, 'Cutoff'), Number.EPSILON, 0.45 * rate);
return Math.exp(-TAU * safeCutoff / rate);
}
export function holdControl(values: readonly number[], ratio: number) {
if (!values.length) throw new RangeError('Control values cannot be empty');
if (!Number.isInteger(ratio) || ratio < 1) throw new RangeError('Hold ratio must be a positive integer');
const output = new Float32Array(values.length * ratio);
values.forEach((value, index) => output.fill(finite(value, 'Control value'), index * ratio, (index + 1) * ratio));
return output;
}
export function renderBoundedFeedback(input: Float32Array, gain: number) {
if (Math.abs(finite(gain, 'Feedback gain')) >= 1) throw new RangeError('Feedback gain magnitude must stay below one');
const output = new Float32Array(input.length);
for (let index = 0; index < input.length; index++) {
const delayed = index ? output[index - 1] : 0;
output[index] = finite(input[index], 'Input sample') + gain * Math.tanh(delayed);
}
return output;
}
function envelopeOn(time: number, attack: number, decay = 0.08, sustain = 0.6) {
if (time < attack) return linearEnvelopeSegment(time, 0, 1, attack);
if (time < attack + decay) return linearEnvelopeSegment(time - attack, 1, sustain, decay);
return sustain;
}
function repeatedEnvelope(time: number, period: number, gateLength: number, attack: number, decay = 0.08, sustain = 0.6, release = 0.06) {
const local = time % period;
if (local < gateLength) return envelopeOn(local, attack, decay, sustain);
return linearEnvelopeSegment(local - gateLength, envelopeOn(gateLength, attack, decay, sustain), 0, Math.min(release, period - gateLength));
}
function preset(id: ModularPreset) {
if (!(id in modularPresets)) throw new RangeError('Unknown modular preset');
return modularPresets[id];
}
export function renderModularPreset(id: ModularPreset, settings: ModularSettings, seconds = 2.4) {
preset(id);
const frames = Math.round(positive(seconds, 'Duration') * MODULAR_SAMPLE_RATE);
const output = new Float32Array(frames);
const notes = [48, 55, 60, 63, 60, 55, 51, 55];
const baseFrequency = positive(settings.baseFrequency, 'Base frequency');
const lfoRate = positive(settings.lfoRate, 'LFO rate');
const lfoDepth = clamp(finite(settings.lfoDepthSemitones, 'LFO depth'), 0, 12);
const attack = clamp(finite(settings.attack, 'Attack'), 0, 1);
const cutoff = positive(settings.cutoff, 'Cutoff');
const gain = clamp(finite(settings.gain, 'Gain'), 0, 1);
let phase = 0;
let filterState = 0;
for (let index = 0; index < frames; index++) {
const time = index / MODULAR_SAMPLE_RATE;
const sequenceStep = Math.floor(time / 0.25) % notes.length;
const octaveControl = id === 'sequence'
? (notes[sequenceStep] - 69) / 12
: id === 'vibrato'
? lfoDepth * Math.sin(TAU * lfoRate * time) / 12
: 0;
const frequency = id === 'sequence' ? pitchFromOctaves(440, octaveControl) : pitchFromOctaves(baseFrequency, octaveControl);
const source = id === 'sequence' ? 1 - 4 * Math.abs(phase - 0.5) : 2 * phase - 1;
phase = (phase + frequency / MODULAR_SAMPLE_RATE) % 1;
const articulated = id === 'adsr' || id === 'subtractive' || id === 'sequence';
const period = id === 'sequence' ? 0.25 : 0.8;
const gateLength = id === 'sequence' ? 0.2 : 0.55;
const envelope = id === 'sequence'
? repeatedEnvelope(time, period, gateLength, 0.005, 0.06, 0.6, 0.04)
: articulated
? repeatedEnvelope(time, period, gateLength, attack)
: 1;
let signal = source;
if (id === 'subtractive' || id === 'sequence') {
const movingCutoff = id === 'sequence' ? 600 + cutoff * envelope : cutoff * (0.25 + 1.75 * envelope);
const coefficient = onePoleCoefficient(movingCutoff);
filterState = (1 - coefficient) * signal + coefficient * filterState;
signal = filterState;
}
const fadeFrames = Math.max(1, Math.round(0.006 * MODULAR_SAMPLE_RATE));
const edge = Math.min(1, index / fadeFrames, (frames - 1 - index) / fadeFrames);
output[index] = index === 0 || index === frames - 1 ? 0 : 0.12 * Math.tanh(signal) * gain * envelope * edge;
}
return output;
}
export function sampleModularWindow(id: ModularPreset, settings: ModularSettings, seconds = 0.08) {
const output = renderModularPreset(id, settings, seconds);
const points = Math.min(320, output.length);
const stride = Math.max(1, Math.floor(output.length / points));
return Float32Array.from({ length: points }, (_, index) => output[Math.min(output.length - 1, index * stride)]);
}
export type ModulationDestination = 'pitch' | 'gain' | 'cutoff';
export type ControlMode = 'sample' | 'held' | 'interpolated';
export const modulationConnections: Record<ModulationDestination, string> = {
pitch: 'LFO control → VCO pitch',
gain: 'LFO control → VCA gain',
cutoff: 'LFO control → teaching VCF cutoff'
};
export interface ModulationTrajectories {
duration: number;
control: Float32Array;
parameter: Float32Array;
envelope: Float32Array;
output: Float32Array;
}
export function renderModulationDestination(destination: ModulationDestination, seconds = 1.25): ModulationTrajectories {
if (!(destination in modulationConnections)) throw new RangeError('Unknown modulation destination');
const duration = positive(seconds, 'Duration');
const frames = Math.round(duration * MODULAR_SAMPLE_RATE);
const control = new Float32Array(frames);
const parameter = new Float32Array(frames);
const envelope = new Float32Array(frames);
const output = new Float32Array(frames);
let phase = 0;
let filterState = 0;
const fadeFrames = Math.round(.008 * MODULAR_SAMPLE_RATE);
for (let index = 0; index < frames; index++) {
const time = index / MODULAR_SAMPLE_RATE;
const lfo = Math.sin(TAU * time);
control[index] = lfo;
const frequency = destination === 'pitch' ? pitchFromOctaves(220, lfo / 6) : 220;
const vcaGain = destination === 'gain' ? .35 + .25 * lfo : .35;
const cutoff = destination === 'cutoff' ? 900 * 2 ** (1.5 * lfo) : 4_000;
parameter[index] = destination === 'pitch' ? frequency : destination === 'gain' ? vcaGain : cutoff;
envelope[index] = vcaGain;
const source = 2 * phase - 1;
phase = (phase + frequency / MODULAR_SAMPLE_RATE) % 1;
let signal = source;
if (destination === 'cutoff') {
const coefficient = onePoleCoefficient(cutoff);
filterState = (1 - coefficient) * signal + coefficient * filterState;
signal = filterState;
}
const edge = Math.min(1, index / fadeFrames, (frames - 1 - index) / fadeFrames);
output[index] = index === 0 || index === frames - 1 ? 0 : .1 * Math.tanh(signal) * vcaGain * edge;
}
return { duration, control, parameter, envelope, output };
}
export function interpolateControl(values: readonly number[], ratio: number) {
if (!values.length) throw new RangeError('Control values cannot be empty');
if (!Number.isInteger(ratio) || ratio < 1) throw new RangeError('Interpolation ratio must be a positive integer');
const output = new Float32Array(values.length * ratio);
for (let index = 0; index < output.length; index++) {
const start = Math.floor(index / ratio);
const fraction = (index % ratio) / ratio;
const from = finite(values[start], 'Control value');
const to = finite(values[Math.min(start + 1, values.length - 1)], 'Control value');
output[index] = from + (to - from) * fraction;
}
return output;
}
export function controlRateTrajectories(updateRate: number, seconds = 1) {
const rate = positive(updateRate, 'Control update rate');
const frames = Math.round(positive(seconds, 'Duration') * MODULAR_SAMPLE_RATE);
const ratio = Math.max(1, Math.round(MODULAR_SAMPLE_RATE / rate));
const perSample = Float32Array.from({ length: frames }, (_, index) => Math.sin(TAU * 5 * index / MODULAR_SAMPLE_RATE));
const values = Array.from({ length: Math.ceil(frames / ratio) + 1 }, (_, index) => Math.sin(TAU * 5 * index * ratio / MODULAR_SAMPLE_RATE));
return {
duration: frames / MODULAR_SAMPLE_RATE,
ratio,
perSample,
held: holdControl(values, ratio).slice(0, frames),
interpolated: interpolateControl(values, ratio).slice(0, frames)
};
}
export function renderControlRate(mode: ControlMode, updateRate: number, seconds = 1) {
const trajectories = controlRateTrajectories(updateRate, seconds);
const control = mode === 'sample' ? trajectories.perSample : trajectories[mode];
const output = new Float32Array(control.length);
let phase = 0;
const fadeFrames = Math.round(.008 * MODULAR_SAMPLE_RATE);
for (let index = 0; index < output.length; index++) {
const frequency = pitchFromOctaves(440, control[index] * .25);
phase = (phase + frequency / MODULAR_SAMPLE_RATE) % 1;
const edge = Math.min(1, index / fadeFrames, (output.length - 1 - index) / fadeFrames);
output[index] = index === 0 || index === output.length - 1 ? 0 : .035 * Math.sin(TAU * phase) * edge;
}
return output;
}
export const FEEDBACK_DELAY_SECONDS = .12;
export const MAX_FEEDBACK_GAIN = .85;
export function renderFeedbackDecay(gain: number, seconds = 2) {
const safeGain = finite(gain, 'Feedback gain');
if (safeGain < 0 || safeGain > MAX_FEEDBACK_GAIN) throw new RangeError(`Feedback gain must be between zero and ${MAX_FEEDBACK_GAIN}`);
const frames = Math.round(positive(seconds, 'Duration') * MODULAR_SAMPLE_RATE);
const delaySamples = Math.round(FEEDBACK_DELAY_SECONDS * MODULAR_SAMPLE_RATE);
const output = new Float32Array(frames);
const excitationFrames = Math.round(.012 * MODULAR_SAMPLE_RATE);
for (let index = 0; index < frames; index++) {
const excitation = index < excitationFrames ? .22 * Math.sin(TAU * 330 * index / MODULAR_SAMPLE_RATE) * Math.sin(Math.PI * index / excitationFrames) : 0;
const delayed = index >= delaySamples ? output[index - delaySamples] : 0;
output[index] = excitation + safeGain * Math.tanh(delayed);
}
return { delaySamples, model: output, samples: Float32Array.from(output, (sample, index) => {
const edge = Math.min(1, (frames - 1 - index) / Math.round(.008 * MODULAR_SAMPLE_RATE));
return index === frames - 1 ? 0 : .1 * sample * edge;
}) };
}
export function feedbackEchoRms(model: Float32Array, delaySamples = Math.round(FEEDBACK_DELAY_SECONDS * MODULAR_SAMPLE_RATE)) {
if (!Number.isInteger(delaySamples) || delaySamples < 1) throw new RangeError('Delay must be a positive sample count');
const window = Math.min(Math.round(.012 * MODULAR_SAMPLE_RATE), delaySamples);
const echoes = Math.floor(model.length / delaySamples);
return Float32Array.from({ length: echoes }, (_, echo) => {
let sum = 0;
const start = echo * delaySamples;
for (let index = 0; index < window && start + index < model.length; index++) sum += model[start + index] ** 2;
return Math.sqrt(sum / window);
});
}
Worked, faded, and fault stations
Worked. Trace VCO to VCF to VCA. Mark the envelope fan-out to VCF and VCA. Predict which parameter changes if you halve only the VCF envelope depth.
Faded. Build LFO vibrato. Fill in only the missing
destination and range: LFO → ____, with depth limited to
three semitones. Explain why routing the same LFO to VCA would produce
tremolo instead.
Faulted. A patch is silent. Inspect in this order: master level, VCO activity, VCA gain, gate, envelope state, filter cutoff, then cable destinations. Change one layer and verify again. If a feedback patch becomes unexpectedly loud, stop playback before inspecting gain and delay.
Hearing-safety invariant. Start low. Keep a Stop or Mute control available. Feedback and resonance can change peak level quickly. Never compensate for a silent VCA by raising the monitor before checking its control input.
Chapter 4 readiness gate
Without looking back:
- Draw a VCO to VCF to VCA patch and add gate, envelope, and output.
- Explain why audio rate and control rate are properties of use and computation.
- Derive the frequency ratio for one octave and one semitone.
- State what memory Equation 4.4 needs.
- Describe this ADSR’s retrigger and release policies.
- Predict one audible difference between held and interpolated control.
- Explain why a digital feedback cycle needs delay.
- Give one reason the East Coast/West Coast binary is historically incomplete.
- Distinguish a Buchla commission date from a completed-system or catalog date.
Chapter 4 invariants
- Voltage control turns parameter relationships into patchable signals.
- A module’s role depends on source, destination, range, and timescale.
- VCO, VCF, and VCA mean generation, spectrum change, and multiplication.
- Envelopes and LFOs are signals, not privileged cable types.
- Held control is cheaper but can add discontinuities.
- Feedback in discrete time needs delay and level discipline.
- The one-pole exercise is not a transistor-ladder model.
- Moog and Buchla are best taught as near-concurrent developments with distinct documented interfaces, not as a rigid coastal binary.
Chapter 4 glossary additions
These definitions summarize the cited historic module documents and the declared digital teaching models (Moog 1964; Buchla Associates 1968; W3C Audio Working Group, n.d.).
| Term | Working definition |
|---|---|
| ADSR | Envelope with attack time, decay time, sustain level, and release time. |
| Audio rate | Timescale at which variation can enter the audible range; a digital system commonly evaluates it per sample-frame. |
| Control rate | Reduced digital evaluation rate for changing parameters, commonly followed by hold or interpolation. |
| Control voltage (CV) | Electrical signal used to change a module parameter. The browser lab models the same relation with numbers. |
| Fan-out | One output feeding more than one input. |
| Feedback | Routing a later signal back toward an earlier point in the graph. |
| Gate | A sustained on/off control commonly used to hold an envelope active. |
| Gain | Output amplitude divided by input amplitude. Unity gain is 1, attenuation is below 1, and amplification is above 1. |
| LFO | An oscillator used at a low rate for modulation. |
| Operational transconductance amplifier (OTA) | A circuit element that turns an input-voltage difference into output current, with transconductance set by a control current. |
| Patch | A chosen set of signal connections and parameter settings. |
| Pulse | A brief event signal used for triggers, clocks, or stage changes. |
| Sequencer | A source that selects stored values and events in ordered steps. |
| Subtractive synthesis | A patch pattern that removes or reshapes spectral energy from a rich source. |
| VCA | A voltage-controlled amplifier whose core teaching operation is multiplication by gain. |
| VCF | A voltage-controlled filter whose parameters can change over time. |
| VCO | A voltage-controlled oscillator whose frequency or other behavior responds to control. |
| Zero-order hold | Holding one control sample constant until the next update. |
IMPLEMENTATION NOTEBOOK
Chapter 4 source and generated output
Each website-owned Python generator appears beside every deterministic figure and audio file it creates. The browser lab displays its exact executed TypeScript above. Hashes are recorded in the implementation manifest.
SOURCE AND OUTPUT
VCO, VCA, and VCF electronic actions
One deterministic project-authored generator draws three teaching schematics without copying a commercial module circuit.
Output
Source
ch04_electronics.py
assets/figures/src/ch04_electronics.pyPython
#!/usr/bin/env python3
"""Generate Chapter 4 teaching schematics for VCO, VCA, and VCF actions."""
from pathlib import Path
import textwrap
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, FancyArrowPatch, Polygon, Rectangle
ROOT = Path(__file__).resolve().parents[3]
FIG = ROOT / "assets/figures/svg"
FIG.mkdir(parents=True, exist_ok=True)
plt.rcParams["svg.hashsalt"] = "contrapunk-ch04-electronics-v1"
plt.rcParams["svg.fonttype"] = "none"
RED = "#7f1d1d"
TEAL = "#0f6f70"
GOLD = "#b07d21"
INK = "#211d1a"
PAPER = "#fffdf8"
def wire(axis, start, end, color=INK, width=1.8, arrow=False):
if arrow:
axis.add_patch(FancyArrowPatch(start, end, arrowstyle="->", mutation_scale=12, linewidth=width, color=color))
else:
axis.plot([start[0], end[0]], [start[1], end[1]], color=color, linewidth=width)
def terminal(axis, x, y, label, side="above", color=RED):
axis.add_patch(Circle((x, y), .07, facecolor=PAPER, edgecolor=color, linewidth=1.8))
offset = .28 if side == "above" else -.34
axis.text(x, y + offset, label, ha="center", va="center", color=INK, fontsize=11)
def ground(axis, x, y):
wire(axis, (x, y), (x, y-.16), width=1.4)
for index, half_width in enumerate([.22, .14, .07]):
level = y - .16 - index*.07
wire(axis, (x-half_width, level), (x+half_width, level), width=1.2)
def capacitor(axis, x, y_top, y_bottom, label="C"):
middle = (y_top + y_bottom) / 2
wire(axis, (x, y_top), (x, middle+.08))
wire(axis, (x-.23, middle+.08), (x+.23, middle+.08), width=2.2)
wire(axis, (x-.23, middle-.08), (x+.23, middle-.08), width=2.2)
wire(axis, (x, middle-.08), (x, y_bottom))
axis.text(x+.35, middle, label, va="center", color=INK, fontsize=12)
def resistor(axis, x, y_top, y_bottom, label="R"):
segments = 8
height = y_top - y_bottom
xs, ys = [x], [y_top]
for index in range(1, segments):
xs.append(x + (.12 if index % 2 else -.12))
ys.append(y_top - height * index / segments)
xs.append(x)
ys.append(y_bottom)
axis.plot(xs, ys, color=INK, linewidth=1.5)
axis.text(x+.32, (y_top+y_bottom)/2, label, va="center", color=INK, fontsize=12)
def block(axis, x, y, width, height, title, detail):
axis.add_patch(Rectangle((x, y), width, height, facecolor="#f5eee5", edgecolor=RED, linewidth=1.8))
axis.text(x+width/2, y+height*.64, title, ha="center", va="center", color=RED, fontsize=12, fontweight="bold")
axis.text(x+width/2, y+height*.3, detail, ha="center", va="center", color=INK, fontsize=9)
def ota(axis, x, y, width=1.5, height=1.45):
points = [(x, y-height/2), (x, y+height/2), (x+width, y)]
axis.add_patch(Polygon(points, closed=True, facecolor="#eef4f0", edgecolor=TEAL, linewidth=2))
axis.text(x+.19, y+.27, "+", color=INK, fontsize=13)
axis.text(x+.19, y-.36, "−", color=INK, fontsize=13)
axis.text(x+.72, y, "OTA", ha="center", va="center", color=TEAL, fontsize=12, fontweight="bold")
return x+width, y
def finish(axis, title, subtitle, name):
axis.set_xlim(0, 10)
axis.set_ylim(0, 4.3)
axis.set_aspect("equal")
axis.axis("off")
axis.set_title(title, loc="left", color=RED, fontsize=16, fontweight="bold", pad=10)
note = axis.text(.15, .12, textwrap.fill(subtitle, 105), color="#514840", fontsize=9, va="bottom")
note.set_in_layout(False)
plt.tight_layout()
path = FIG / name
plt.savefig(path, format="svg", metadata={"Date": None}, facecolor=PAPER)
plt.close()
path.write_text("\n".join(line.rstrip() for line in path.read_text().splitlines()) + "\n")
# VCO: control changes charging current; state ramps until a reset threshold fires.
fig, axis = plt.subplots(figsize=(10, 4.3))
terminal(axis, .55, 2.35, "pitch control v")
wire(axis, (.62, 2.35), (1.45, 2.35), arrow=True, color=TEAL)
block(axis, 1.45, 1.75, 2.1, 1.2, "voltage → current", "linear or exponential")
wire(axis, (3.55, 2.35), (4.45, 2.35), arrow=True, color=TEAL)
axis.text(4.0, 2.62, "Ictrl", ha="center", color=TEAL, fontsize=10)
wire(axis, (4.45, 2.35), (4.45, 3.25))
capacitor(axis, 4.45, 3.25, .75, "C")
ground(axis, 4.45, .75)
wire(axis, (4.45, 2.35), (5.45, 2.35), color=RED)
terminal(axis, 5.45, 2.35, "ramp output", side="above")
block(axis, 6.0, 1.75, 1.55, 1.2, "threshold", "comparator ΔV")
wire(axis, (5.45, 2.35), (6.0, 2.35), arrow=True)
block(axis, 7.95, 1.75, 1.25, 1.2, "reset", "switch")
wire(axis, (7.55, 2.35), (7.95, 2.35), arrow=True, color=GOLD)
wire(axis, (8.58, 1.75), (8.58, 1.05), color=GOLD)
wire(axis, (8.58, 1.05), (4.85, 1.05), color=GOLD)
wire(axis, (4.85, 1.05), (4.85, 1.48), arrow=True, color=GOLD)
axis.text(6.65, .82, "threshold closes the switch and resets capacitor state", ha="center", color=GOLD, fontsize=9)
terminal(axis, 9.45, 2.35, "pulse output")
wire(axis, (9.2, 2.35), (9.38, 2.35), arrow=True)
axis.text(4.45, 3.78, "C dv/dt = Ictrl", ha="center", color=INK, fontsize=11)
finish(axis, "One teaching VCO topology", "Control voltage changes current; the capacitor ramp and reset create repeated cycles. Musical volts-per-octave control needs an exponential converter before the current source.", "ch04-vco-electronics.svg")
# VCA: an OTA turns control current into transconductance, then a load turns current into voltage.
fig, axis = plt.subplots(figsize=(10, 4.3))
terminal(axis, .6, 2.55, "audio x(t)")
wire(axis, (.67, 2.55), (2.0, 2.55), arrow=True)
wire(axis, (1.45, 1.95), (2.0, 1.95))
ground(axis, 1.45, 1.95)
out_x, out_y = ota(axis, 2.0, 2.25, 2.0, 1.65)
terminal(axis, 3.0, .55, "control current")
wire(axis, (3.0, .62), (3.0, 1.45), arrow=True, color=TEAL)
axis.text(3.18, 1.02, "Ictrl sets gm", color=TEAL, fontsize=10)
wire(axis, (out_x, out_y), (5.7, out_y), arrow=True, color=RED)
axis.text(4.85, 2.55, "iout = gm x", ha="center", color=RED, fontsize=11)
resistor(axis, 5.7, 2.25, .9, "RL")
ground(axis, 5.7, .9)
wire(axis, (5.7, 2.25), (7.5, 2.25), color=RED)
terminal(axis, 7.5, 2.25, "audio y(t)")
block(axis, 8.05, 1.55, 1.5, 1.4, "gain", "g = RL gm")
wire(axis, (7.57, 2.25), (8.05, 2.25), arrow=True, color=GOLD)
axis.text(5.25, 3.45, "small-signal relation: y(t) = g(t)x(t)", ha="center", color=INK, fontsize=12)
finish(axis, "One teaching VCA topology", "An operational transconductance amplifier (OTA) uses control current to change gm. The load resistor converts controlled output current back into voltage.", "ch04-vca-electronics.svg")
# VCF: an OTA drives a capacitor from the input/output difference; control current changes gm and cutoff.
fig, axis = plt.subplots(figsize=(10, 4.3))
terminal(axis, .55, 2.65, "audio input")
wire(axis, (.62, 2.65), (2.0, 2.65), arrow=True)
out_x, out_y = ota(axis, 2.0, 2.35, 2.0, 1.65)
terminal(axis, 3.0, .55, "cutoff control")
wire(axis, (3.0, .62), (3.0, 1.55), arrow=True, color=TEAL)
axis.text(3.18, 1.02, "Ictrl sets gm", color=TEAL, fontsize=10)
wire(axis, (out_x, out_y), (5.35, out_y), arrow=True, color=RED)
capacitor(axis, 5.35, 2.35, .8, "C")
ground(axis, 5.35, .8)
wire(axis, (5.35, 2.35), (7.15, 2.35), color=RED)
block(axis, 7.15, 1.8, 1.25, 1.1, "buffer", "high input Z")
terminal(axis, 9.0, 2.35, "low-pass output")
wire(axis, (8.4, 2.35), (8.93, 2.35), arrow=True, color=RED)
wire(axis, (9.0, 2.28), (9.0, 1.35), color=GOLD)
wire(axis, (9.0, 1.35), (1.55, 1.35), color=GOLD)
wire(axis, (1.55, 1.35), (1.55, 2.05), color=GOLD)
wire(axis, (1.55, 2.05), (2.0, 2.05), arrow=True, color=GOLD)
axis.text(7.2, 1.08, "Vout feedback → OTA −", ha="center", color=GOLD, fontsize=8.5)
axis.text(5.3, 3.45, "C dVout/dt = gm(Vin − Vout) fc = gm/(2πC)", ha="center", color=INK, fontsize=11)
finish(axis, "One teaching voltage-controlled low-pass topology", "The OTA charges a capacitor according to the input/output difference. Control current changes gm and therefore cutoff. This is not a Moog ladder schematic.", "ch04-vcf-electronics.svg")
print("Generated Chapter 4 VCO, VCA, and VCF teaching schematics.")
SOURCE AND OUTPUT
Modular figures and listening studies
One deterministic Python program renders six figures and six original mono 48 kHz PCM studies at conservative gain.
Output
Long description. Two columns show 20 milliseconds at 220 and 440 Hz. The pitch control rises by one octave, phase becomes twice as steep, and the saw cycles become twice as dense.
Long description. The upper panel compares instantaneous frequency under per-sample, 100 Hz held, and 100 Hz interpolated control. The lower panel compares their spectra below 3 kHz.
Long description. A gate begins at 0.1 second and ends at 1.2 seconds. The envelope attacks, decays, sustains, and releases while the output follows the same amplitude contour.
Long description. Cutoff falls exponentially from 8 kHz to 200 Hz. The late spectrum has much weaker high harmonics than the early spectrum while fundamental pitch stays fixed.
Long description. Four aligned traces show sixteen stored pitches, eighty-percent gates, repeated contours, and the final filtered waveform. The eight-step pattern repeats once.
Long description. Three one-second sections reset state before gains zero, 0.7, and 0.95. Dashed boundaries separate the sections. Higher feedback gain produces a longer filtered decay.
Source
ch04_modular.py
assets/figures/src/ch04_modular.pyPython
#!/usr/bin/env python3
"""Generate deterministic Chapter 4 modular studies and mono PCM16 WAVs."""
from pathlib import Path
import wave
import matplotlib.pyplot as plt
import numpy as np
plt.rcParams["svg.hashsalt"] = "contrapunk-ch04-studies"
ROOT = Path(__file__).resolve().parents[3]
FIG = ROOT / "assets/figures/svg"
AUDIO = ROOT / "assets/audio/ch04"
FIG.mkdir(parents=True, exist_ok=True)
AUDIO.mkdir(parents=True, exist_ok=True)
FS = 48_000
TAU = 2 * np.pi
RED = "#7f1d1d"
TEAL = "#0f6f70"
GOLD = "#a56a12"
INK = "#211b17"
def save_svg(name):
path = FIG / name
plt.tight_layout()
plt.savefig(path, format="svg", metadata={"Date": None})
plt.close()
path.write_text("\n".join(line.rstrip() for line in path.read_text().splitlines()) + "\n")
def edge_fade(samples, milliseconds=5):
result = np.asarray(samples, dtype=float).copy()
frames = min(round(milliseconds * FS / 1_000), len(result) // 2)
if frames:
fade = np.linspace(0, 1, frames, endpoint=False)
result[:frames] *= fade
result[-frames:] *= fade[::-1]
return result
def write_wav(name, samples, gain=1.0, fade=True):
values = edge_fade(samples) if fade else np.asarray(samples, dtype=float)
values = np.clip(values * gain, -1, 1)
pcm = np.rint(values * 32_767).astype("<i2")
with wave.open(str(AUDIO / name), "wb") as output:
output.setparams((1, 2, FS, 0, "NONE", "not compressed"))
output.writeframes(pcm.tobytes())
return values
def phase_from_frequency(frequency):
frequency = np.asarray(frequency, dtype=float)
return np.mod(np.cumsum(np.r_[0.0, frequency[:-1]]) / FS, 1)
def saw(frequency):
phase = phase_from_frequency(frequency)
return 2 * phase - 1, phase
def sine(frequency):
return np.sin(TAU * phase_from_frequency(frequency))
def triangle(frequency):
phase = phase_from_frequency(frequency)
return 1 - 4 * np.abs(phase - 0.5)
def adsr(gate, attack, decay, sustain, release):
output = np.zeros(len(gate))
level = 0.0
stage = "idle"
origin = 0.0
elapsed = 0
prior = False
settings = {
"attack": (1.0, attack, "decay"),
"decay": (sustain, decay, "sustain"),
"release": (0.0, release, "idle"),
}
for index, on in enumerate(gate):
if on and not prior:
stage, origin, elapsed = "attack", level, 0
elif prior and not on:
stage, origin, elapsed = "release", level, 0
prior = bool(on)
if stage == "sustain":
level = sustain
elif stage == "idle":
level = 0.0
else:
while stage in settings:
target, seconds, following = settings[stage]
frames = round(seconds * FS)
if frames == 0:
level, origin, elapsed, stage = target, target, 0, following
continue
amount = min(elapsed / frames, 1)
level = origin + (target - origin) * amount
elapsed += 1
if elapsed >= frames:
origin, elapsed, stage = target, 0, following
break
output[index] = level
return output
def one_pole(source, cutoff):
output = np.zeros_like(source, dtype=float)
previous = 0.0
for index, sample in enumerate(source):
frequency = cutoff[index] if np.ndim(cutoff) else cutoff
frequency = np.clip(frequency, np.finfo(float).eps, 0.45 * FS)
coefficient = np.exp(-TAU * frequency / FS)
previous = (1 - coefficient) * sample + coefficient * previous
output[index] = previous
return output
def spectrum(source):
count = 16_384
window = np.hanning(count)
if len(source) == count:
data = source
else:
center = len(source) // 2
data = source[center - count // 2:center + count // 2]
magnitude = np.abs(np.fft.rfft(data * window)) / (window.sum() / 2)
return np.fft.rfftfreq(count, 1 / FS), 20 * np.log10(np.maximum(magnitude, 1e-5))
# Study 1: One Voltage, Two Octaves.
count = 2 * FS
time = np.arange(count) / FS
control = (time >= 1).astype(float)
frequency = 220 * 2**control
source, phase = saw(frequency)
vco_audio = write_wav("04-vco-cv.wav", 0.2 * source)
figure, axes = plt.subplots(3, 2, figsize=(11, 6), sharex="col")
for column, start in enumerate((0, FS)):
excerpt = slice(start, start + round(0.02 * FS))
local_time = (np.arange(excerpt.start, excerpt.stop) - start) / FS * 1_000
axes[0, column].plot(local_time, control[excerpt], color=GOLD)
axes[1, column].plot(local_time, phase[excerpt], color=TEAL)
axes[2, column].plot(local_time, source[excerpt], color=RED)
axes[0, column].set_title(f"{frequency[start]:.0f} Hz, v={control[start]:.0f}")
axes[0, column].set_ylim(-0.1, 1.1)
axes[1, column].set_ylim(-0.05, 1.05)
axes[2, column].set_ylim(-1.1, 1.1)
axes[2, column].set_xlabel("Time from section start (ms)")
for axis, label in zip(axes[:, 0], ("Pitch control (octaves)", "Phase (cycles)", "Saw amplitude")):
axis.set_ylabel(label)
save_svg("ch04-vco-cv.svg")
# Study 2: per-sample, held, and interpolated vibrato control.
segment = FS
silence = round(0.01 * FS)
base_time = np.arange(segment) / FS
modulator = np.sin(TAU * 5 * base_time)
reference_frequency = 440 * 2 ** ((3 / 12) * modulator)
held_frequency = np.repeat(reference_frequency[::480], 480)[:segment]
anchors = np.arange(0, segment + 480, 480)
anchor_time = anchors / FS
anchor_frequency = 440 * 2 ** ((3 / 12) * np.sin(TAU * 5 * anchor_time))
interpolated_frequency = np.interp(np.arange(segment), anchors, anchor_frequency)
versions = [reference_frequency, held_frequency, interpolated_frequency]
control_audio = np.zeros(3 * segment + 2 * silence)
for index, version in enumerate(versions):
start = index * (segment + silence)
control_audio[start:start + segment] = edge_fade(0.15 * sine(version))
write_wav("04-audio-vs-control-rate.wav", control_audio, fade=False)
figure, (frequency_axis, spectrum_axis) = plt.subplots(2, 1, figsize=(10, 7))
excerpt = slice(round(0.9 * FS), FS)
labels = ("per sample", "100 Hz hold", "100 Hz linear")
for version, label, color in zip(versions, labels, (INK, RED, TEAL)):
frequency_axis.plot(base_time[excerpt], version[excerpt], color=color, label=label)
frequency_axis.set(xlabel="Time (s)", ylabel="Instantaneous frequency (Hz)")
frequency_axis.legend()
for version, label, color in zip(versions, labels, (INK, RED, TEAL)):
hertz, decibels = spectrum(sine(version))
mask = hertz < 3_000
spectrum_axis.plot(hertz[mask], decibels[mask], color=color, label=label)
spectrum_axis.set(xlabel="Frequency (Hz)", ylabel="Magnitude (dBFS)", ylim=(-100, 10))
spectrum_axis.legend()
save_svg("ch04-audio-vs-control-rate.svg")
# Study 3: ADSR-controlled VCA.
count = 2 * FS
time = np.arange(count) / FS
gate = (time >= 0.1) & (time < 1.2)
envelope = adsr(gate, 0.1, 0.2, 0.55, 0.35)
carrier, _ = saw(np.full(count, 220.0))
adsr_output = 0.2 * envelope * carrier
write_wav("04-adsr-vca.wav", adsr_output)
figure, axes = plt.subplots(3, 1, figsize=(10, 6), sharex=True)
axes[0].plot(time, gate, color=GOLD)
axes[1].plot(time, envelope, color=TEAL)
axes[2].plot(time, adsr_output, color=RED)
axes[2].set_xlabel("Time (s)")
for axis, label in zip(axes, ("Gate", "Envelope and gain", "Output amplitude")):
axis.set_ylabel(label)
save_svg("ch04-adsr-vca.svg")
# Study 4: subtractive sweep through a one-pole teaching filter.
count = 4 * FS
time = np.arange(count) / FS
source, _ = saw(np.full(count, 110.0))
cutoff = 8_000 * (200 / 8_000) ** (time / 4)
filtered = 0.2 * one_pole(source, cutoff)
write_wav("04-subtractive-sweep.wav", filtered)
figure, (cutoff_axis, spectrum_axis) = plt.subplots(2, 1, figsize=(10, 7))
cutoff_axis.semilogy(time, cutoff, color=TEAL)
cutoff_axis.set(xlabel="Time (s)", ylabel="Cutoff (Hz)")
for center, label, color in ((0.5, "0.5 s", RED), (3.5, "3.5 s", TEAL)):
excerpt = filtered[round(center * FS) - 8_192:round(center * FS) + 8_192]
hertz, decibels = spectrum(excerpt)
mask = hertz < 10_000
spectrum_axis.plot(hertz[mask], decibels[mask], color=color, label=label)
spectrum_axis.set(xlabel="Frequency (Hz)", ylabel="Magnitude (dBFS)", ylim=(-100, 5))
spectrum_axis.legend()
save_svg("ch04-subtractive-sweep.svg")
# Study 5: Clock Garden sequence.
notes = np.array([48, 55, 60, 63, 60, 55, 51, 55] * 2)
step_frames = round(0.25 * FS)
count = len(notes) * step_frames
time = np.arange(count) / FS
step_index = (np.arange(count) // step_frames) % len(notes)
gate = np.arange(count) % step_frames < round(0.8 * step_frames)
frequency = 440 * 2 ** ((notes[step_index] - 69) / 12)
envelope = adsr(gate, 0.005, 0.06, 0.6, 0.04)
source = triangle(frequency)
sequence_output = 0.22 * envelope * one_pole(source, 600 + 2_400 * envelope)
write_wav("04-sequence.wav", sequence_output)
figure, axes = plt.subplots(4, 1, figsize=(11, 7), sharex=True)
axes[0].step(time, notes[step_index] - 48, where="post", color=GOLD)
axes[1].plot(time, gate, color=INK)
axes[2].plot(time, envelope, color=TEAL)
axes[3].plot(time, sequence_output, color=RED)
axes[3].set_xlabel("Time (s)")
for axis, label in zip(axes, ("Pitch (semitones)", "Gate", "Envelope", "Output")):
axis.set_ylabel(label)
save_svg("ch04-sequence.svg")
# Study 6: Almost Singing, bounded delayed feedback through a one-pole filter.
count = 3 * FS
time = np.arange(count) / FS
feedback_output = np.zeros(count)
rms = np.zeros(count)
for section, gain_value in enumerate((0.0, 0.7, 0.95)):
previous_filter = 0.0
previous_output = 0.0
coefficient = np.exp(-TAU * 900 / FS)
start = section * FS
for local_index in range(FS):
source = 0.5 if local_index == 0 else 0.0
processor_input = source + gain_value * np.tanh(previous_output)
previous_filter = (1 - coefficient) * processor_input + coefficient * previous_filter
feedback_output[start + local_index] = previous_filter
previous_output = previous_filter
for start in range(0, count, 480):
rms[start:start + 480] = np.sqrt(np.mean(feedback_output[start:start + 480] ** 2))
feedback_audio = write_wav("04-feedback.wav", feedback_output, gain=10 ** (-12 / 20), fade=False)
figure, (waveform_axis, rms_axis) = plt.subplots(2, 1, figsize=(10, 6), sharex=True)
waveform_axis.plot(time, feedback_output, color=RED)
rms_axis.plot(time, rms, color=TEAL)
waveform_axis.set_ylabel("Output amplitude")
rms_axis.set(xlabel="Time (s)", ylabel="10 ms RMS")
for axis in (waveform_axis, rms_axis):
for boundary in (1, 2):
axis.axvline(boundary, color=INK, linestyle="--", alpha=0.5)
save_svg("ch04-feedback.svg")
test_gate = np.arange(round(0.08 * FS)) / FS < 0.05
test_envelope = adsr(test_gate, 0.005, 0.01, 0.5, 0.01)
assert test_envelope[0] == 0 and test_envelope[round(0.005 * FS)] == 1
assert test_envelope[round(0.015 * FS) - 1] > 0.5 and test_envelope[round(0.015 * FS)] == 0.5
assert test_envelope[round(0.05 * FS)] == test_envelope[round(0.05 * FS) - 1]
assert test_envelope[round(0.06 * FS) - 1] > 0 and test_envelope[round(0.06 * FS)] == 0
assert not np.isclose(interpolated_frequency[-1], anchor_frequency[-2])
assert np.isclose(frequency[0], 440 * 2 ** ((48 - 69) / 12))
assert np.isclose(phase[0], 0) and np.isclose(phase[1], 220 / FS)
assert np.isclose(envelope.max(), 1)
assert len(control_audio) == 144_960
assert np.all(np.isfinite(filtered)) and np.all(np.isfinite(feedback_output))
assert max(np.max(np.abs(values)) for values in (vco_audio, control_audio, adsr_output, filtered, sequence_output, feedback_audio)) <= 0.22
print("Generated Chapter 4 figures and project-authored PCM audio.")
SOURCE AND OUTPUT
Seven equation visuals
One chapter-specific generator produces the exact visuals for Equations 4.1 through 4.7.
Output
Long description. Phase wraps from one to zero and becomes twice as steep after 10 milliseconds. Saw cycles become twice as dense.
Long description. A logarithmic frequency axis makes the curve straight. Markers show 110, 220, and 440 Hz at minus one, zero, and plus one octave.
Long description. The fixed input sine remains visible behind a gain line. The multiplied output grows from silence to the input amplitude.
Long description. All three responses approach one. The 4 kHz response rises fastest, followed by 900 Hz and 200 Hz.
Long description. The line begins at 0.25, reaches 0.9 exactly at 0.8 second, and holds its target afterward.
Long description. Control samples appear at indices zero, eight, sixteen, and twenty-four. A staircase preserves each value until the next update.
Long description. A half-unit impulse ends immediately at zero gain and decays for progressively longer at gains 0.7 and 0.95.
Source
formula_visuals_ch04.py
assets/figures/src/formula_visuals_ch04.pyPython
#!/usr/bin/env python3
"""Generate deterministic visuals for Chapter 4 equations."""
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
plt.rcParams["svg.hashsalt"] = "contrapunk-ch04-formulas"
ROOT = Path(__file__).resolve().parents[3]
OUT = ROOT / "assets/figures/svg"
OUT.mkdir(parents=True, exist_ok=True)
FS = 48_000
TAU = 2 * np.pi
RED = "#7f1d1d"
TEAL = "#0f6f70"
GOLD = "#a56a12"
INK = "#211b17"
def save(name):
path = OUT / name
plt.tight_layout()
plt.savefig(path, format="svg", metadata={"Date": None})
plt.close()
path.write_text("\n".join(line.rstrip() for line in path.read_text().splitlines()) + "\n")
def base(title):
figure, axis = plt.subplots(figsize=(8.6, 3.8))
axis.set_title(title)
axis.grid(alpha=0.2)
return figure, axis
# Equation 4.1: phase-accumulator VCO.
count = 960
time = np.arange(count) / FS
frequency = np.where(time < 0.01, 220.0, 440.0)
phase = np.zeros(count)
for index in range(count - 1):
phase[index + 1] = (phase[index] + frequency[index] / FS) % 1
waveform = 2 * phase - 1
figure, axes = plt.subplots(2, 1, figsize=(8.6, 5), sharex=True)
axes[0].plot(time * 1_000, phase, color=TEAL)
axes[1].plot(time * 1_000, waveform, color=RED)
axes[0].axvline(10, color=GOLD, linestyle="--")
axes[1].axvline(10, color=GOLD, linestyle="--")
axes[0].set(ylabel="Phase (cycles)", title="Eq. 4.1: phase accumulation at 220 Hz, then 440 Hz")
axes[1].set(xlabel="Time (ms)", ylabel="Saw output")
for axis in axes:
axis.grid(alpha=0.2)
save("eq-4-1-phase-vco.svg")
# Equation 4.2: exponential pitch control.
control = np.linspace(-2, 2, 401)
pitch = 220 * 2**control
figure, axis = base("Eq. 4.2: exponential pitch control from a 220 Hz base")
axis.semilogy(control, pitch, color=TEAL)
axis.scatter([-1, 0, 1], [110, 220, 440], color=RED, zorder=3)
axis.set(xlabel="Control v (octaves)", ylabel="Frequency (Hz)")
save("eq-4-2-exponential-pitch.svg")
# Equation 4.3: VCA multiplication.
count = round(0.03 * FS)
time = np.arange(count) / FS
source = np.sin(TAU * 220 * time)
gain = np.linspace(0, 1, count)
output = gain * source
figure, axis = base("Eq. 4.3: a VCA multiplies a 220 Hz sine by gain 0 to 1")
axis.plot(time * 1_000, source, color=INK, alpha=0.35, label="input x[n]")
axis.plot(time * 1_000, gain, color=GOLD, label="gain g[n]")
axis.plot(time * 1_000, output, color=TEAL, label="output y[n]")
axis.set(xlabel="Time (ms)", ylabel="Normalized amplitude")
axis.legend()
save("eq-4-3-vca.svg")
# Equation 4.4: one-pole low-pass step responses.
count = 500
source = np.ones(count)
figure, axis = base("Eq. 4.4: one-pole step responses at 48 kHz")
responses = {}
for cutoff, color in zip((200, 900, 4_000), (GOLD, TEAL, RED)):
coefficient = np.exp(-TAU * cutoff / FS)
output = np.zeros(count)
previous = 0.0
for index, sample in enumerate(source):
previous = (1 - coefficient) * sample + coefficient * previous
output[index] = previous
responses[cutoff] = output
axis.plot(np.arange(count) / FS * 1_000, output, color=color, label=f"{cutoff:,} Hz")
axis.set(xlabel="Time (ms)", ylabel="Output")
axis.legend()
save("eq-4-4-one-pole.svg")
# Equation 4.5: exact linear envelope segment.
time = np.linspace(0, 1.2, 301)
start = 0.25
target = 0.9
duration = 0.8
envelope = start + (target - start) * np.clip(time / duration, 0, 1)
figure, axis = base("Eq. 4.5: linear segment from 0.25 to 0.9 in 0.8 seconds")
axis.plot(time, envelope, color=TEAL)
axis.scatter([0, duration], [start, target], color=RED, zorder=3)
axis.set(xlabel="Time (s)", ylabel="Envelope level", ylim=(0, 1))
save("eq-4-5-envelope-segment.svg")
# Equation 4.6: control-rate zero-order hold.
ratio = 8
control = np.array([0.1, 0.8, 0.35, 0.65])
held = np.repeat(control, ratio)
figure, axis = base("Eq. 4.6: four control values held for eight audio samples each")
axis.step(np.arange(len(held)), held, where="post", color=TEAL)
axis.scatter(np.arange(0, len(held), ratio), control, color=RED, zorder=3)
axis.set(xlabel="Audio sample n", ylabel="Held control", ylim=(0, 1))
save("eq-4-6-control-hold.svg")
# Equation 4.7: delayed bounded feedback with an identity processor y[n]=u[n].
count = 240
source = np.zeros(count)
source[0] = 0.5
figure, axis = base("Eq. 4.7: delayed bounded feedback, identity-processor case")
feedback_outputs = {}
for gain_value, color in zip((0.0, 0.7, 0.95), (INK, GOLD, RED)):
output = np.zeros(count)
for index in range(count):
delayed = output[index - 1] if index else 0.0
output[index] = source[index] + gain_value * np.tanh(delayed)
feedback_outputs[gain_value] = output
axis.plot(np.arange(count), output, color=color, label=f"k={gain_value:g}")
axis.set(xlabel="Sample n", ylabel="Output y[n]")
axis.legend()
save("eq-4-7-bounded-feedback.svg")
assert phase[0] == 0 and np.isclose(phase[1], 220 / FS)
assert np.isclose(220 * 2**-1, 110) and np.isclose(220 * 2, 440)
assert np.isclose(output[-1], feedback_outputs[0.95][-1])
assert np.isclose(gain[0], 0) and np.isclose(gain[-1], 1)
assert responses[4_000][20] > responses[900][20] > responses[200][20]
assert np.isclose(envelope[0], start) and np.isclose(envelope[-1], target)
assert np.array_equal(held[:ratio], np.full(ratio, control[0]))
assert all(np.isfinite(values).all() and np.max(np.abs(values)) <= 1.5 for values in feedback_outputs.values())
print("Generated Chapter 4 formula visuals.")
Chapter 4 Answers and Fault Invariants
Mathematical-practice answers
- cycle per sample.
- Hz. For , frequency is 55 Hz.
- .
- Gain 0.5 is dB. Unity gain is 0 dB. The change is about +6.02 dB and doubles amplitude.
- Higher cutoff makes smaller, so the current-input weight is larger and the previous-output weight is smaller.
- One quarter of the segment has elapsed. The level is .
- control updates per second.
- The delayed output , with an explicit integer delay .
Readiness answers
- The audio path is VCO to VCF to VCA to output. Gate enters envelope. Envelope controls VCA and optionally VCF. A controller or sequencer controls VCO pitch.
- Rate describes timescale or update policy. The same oscillator design can function below audibility as an LFO or in the audible range as a VCO.
- One octave is . One semitone is .
- One previous output sample, .
- Gate-on restarts attack from the current level. Gate-off starts release from the current level. Zero-length stages jump to target.
- A hold makes steps at update boundaries. Interpolation connects updates and usually reduces the abrupt spectral artifacts.
- Without delay, the current output would depend immediately on itself. Reading an earlier sample with makes evaluation ordered and causal.
- Both systems supported broader overlapping modular practice than a Moog-equals-subtractive and Buchla-equals-experimental binary suggests.
- A commission records a request or relationship. A prototype, usable completion, public demonstration, sale, and catalog date identify different milestones and must remain labeled.
Chapter 4 fault invariant
Stop playback before investigating an unexpectedly loud loop. For silence, verify output level, generator activity, VCA gain, gate, envelope, cutoff, and destinations in that order. Change one layer, verify, and keep monitor gain fixed.
Worked, faded, and listening invariants
- Halving only the VCF envelope depth reduces cutoff motion. The VCA contour and note timing remain unchanged.
- The missing vibrato destination is VCO pitch. A depth of three semitones means the pitch-control range is plus or minus three semitones around the center. Routing the LFO to VCA gain produces tremolo instead.
- One Voltage, Two Octaves keeps waveform recipe and duration fixed while one octave of control doubles frequency.
- The held-control study updates at 100 Hz. Linear interpolation connects those updates but cannot restore control information above that rate.
- Clock Garden keeps the documented sixteen pitch steps, gate ratio, envelope, filter relation, and conservative gain.
- Every listening file and musical sequence in Chapter 4 is project-authored. No Wendy Carlos recording, arrangement, cover, or liner-note asset is embedded.
Fault-station invariants
- Stop playback before inspecting an unexpectedly loud feedback path.
- For silence, check output level, oscillator activity, VCA gain, gate, envelope, cutoff, and destinations in that order.
- Keep monitor gain fixed while diagnosing the patch. Change one layer, then verify it.
- A zero-delay digital cycle is not this chapter's declared model. The feedback branch needs explicit delay, gain magnitude below one, and conservative output level.