CONTRAPUNK
In this chapter 23 sections

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.

Pitch control, phase, and saw output compare 220 Hz with 440 Hz.
One control octave doubles oscillator frequency from 220 to 440 Hz.

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:

A voltage-controlled patch connects performance controls to oscillator pitch, an oscillator to filter and amplifier, and an envelope to filter and amplifier.
A voltage-controlled patch separates audio paths from parameter-control paths.

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.

Close view of a Moog modular synthesizer showing knobs, switches, sockets, and patch cords.
Patch cords and controls on a Moog modular synthesizer photographed in 2016.

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 → output

A 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, Cdvdt=Ictrl and frequency is proportional to IctrlCΔV. A musical volts-per-octave input adds an exponential voltage-to-current stage, which anticipates Equation 4.2.

A control voltage enters a voltage-to-current stage that charges a capacitor; a comparator triggers a reset switch so the ramp repeats.
Teaching VCO: control current changes capacitor ramp rate.

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 gm. A load resistor turns the controlled current back into voltage, giving the small-signal relation y(t)=RLgmx(t).

An audio voltage enters an OTA while control current sets transconductance; output current crosses a load resistor to create a controlled output voltage.
Teaching VCA: control current changes the audio gain ratio.

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 gm, so it changes cutoff: CdVoutdt=gm(VinVout) and fc=gm2πC. The feedback is what makes the capacitor settle toward the input instead of ramping without limit.

An OTA drives a capacitor from the difference between input and output; cutoff control changes transconductance and a buffer supplies the low-pass output.
Teaching VCF: controlled transconductance changes how fast the capacitor can follow.

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 fsf_s be sample rate in samples per second, f[n]f[n] instantaneous frequency in hertz, and ϕ[n]\phi[n] phase measured in cycles. Let ww map one phase cycle to a waveform:

ϕ[n+1]=(ϕ[n]+f[n]fs)mod1,x[n]=w(ϕ[n]).(4.1) \phi[n+1]=\left(\phi[n]+\frac{f[n]}{f_s}\right)\bmod 1, \qquad x[n]=w(\phi[n]). \tag{4.1}

Equation 4.1 plotted at 48 kHz for 20 ms, with frequency changing from 220 to 440 Hz at 10 ms.
Equation 4.1 plotted at 48 kHz for 20 ms, with frequency changing from 220 to 440 Hz at 10 ms.

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 220/480000.004583220/48000\approx0.004583 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 v[n]v[n] measure octaves relative to base frequency f0f_0:

f[n]=f02v[n].(4.2) f[n]=f_0 2^{v[n]}. \tag{4.2}

Equation 4.2 plotted for base frequency 220 Hz and control from minus two to plus two octaves.
Equation 4.2 plotted for base frequency 220 Hz and control from minus two to plus two octaves.

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 v=1/12v=1/12. 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 g=0.2/0.4=0.5. 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: GdB=20log10|g|. The factor is 20 for an amplitude ratio. The sign of g is handled separately because decibels use magnitude.

Linear gainAmplitude resultLevel
0silenceminus infinity dB
0.25one-quarter amplitude−12.04 dB
0.5half amplitude−6.02 dB
1unity0 dB
2double amplitude+6.02 dB

Equation 4.3 uses a bounded teaching VCA that only attenuates. Let x[n]x[n] be input and g[n]g[n] normalized gain:

y[n]=g[n]x[n],0g[n]1.(4.3) y[n]=g[n]x[n],\qquad 0\le g[n]\le1. \tag{4.3}

Equation 4.3 showing a 220 Hz sine multiplied by gain rising from zero to one over 30 ms.
Equation 4.3 showing a 220 Hz sine multiplied by gain rising from zero to one over 30 ms.

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 gg 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 fcf_c and sample rate fsf_s, define coefficient aa and output y[n]y[n]:

a=exp(2πfcfs),y[n]=(1a)x[n]+ay[n1].(4.4) a=\exp\!\left(-\frac{2\pi f_c}{f_s}\right), \qquad y[n]=(1-a)x[n]+a y[n-1]. \tag{4.4}

Equation 4.4 step responses at 48 kHz for cutoffs 200, 900, and 4000 Hz.
Equation 4.4 step responses at 48 kHz for cutoffs 200, 900, and 4000 Hz.

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 fcf_c above zero and no higher than 0.45fs0.45f_s 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: LFO control → VCO pitch

Four aligned plots cover 1.25 seconds. A text description follows.

The identical 1 Hz LFO changes VCO frequency around 220 Hz, creating vibrato. The view covers one complete cycle plus 0.25 second.

Ready. Audio is stopped.
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 e0e_0, target e1e_1, and last positive duration TT seconds:

e(t)=e0+(e1e0)clamp(tT,0,1).(4.5) e(t)=e_0+(e_1-e_0)\,\operatorname{clamp}\!\left(\frac{t}{T},0,1\right). \tag{4.5}

Equation 4.5 rising from 0.25 to 0.9 over 0.8 seconds and then holding.
Equation 4.5 rising from 0.25 to 0.9 over 0.8 seconds and then holding.

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

Gate, ADSR envelope, and VCA output share one time axis.
Gate, contour, and audible output align.

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 ccc_c is evaluated every RR audio samples and held:

ca[n]=cc[nR].(4.6) c_a[n]=c_c\!\left[\left\lfloor\frac{n}{R}\right\rfloor\right]. \tag{4.6}

Equation 4.6 showing four control values, each held for eight audio samples.
Equation 4.6 showing four control values, each held for eight audio samples.

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.

Per-sample, held, and linearly interpolated vibrato controls are compared in time and frequency.
Per-sample and reduced-rate vibrato control.

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.

Audible control method
Three overlaid control trajectories cover one second. A text description follows.

At 50 updates per second, each held value lasts 960 audio samples. Per-sample is selected for playback.

Ready. Audio is stopped.
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 D 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.).

The adjacent equation visual uses the identity processor, so y[n]=u[n]. The recurrence is:

u[n]=x[n]+ktanh(y[nD]),y[n]=u[n],D1,|k|<1.(4.7) u[n]=x[n]+k\tanh\!\left(y[n-D]\right),\quad y[n]=u[n],\quad D\ge1,\quad |k|<1. \tag{4.7}

The equation visual below chooses D=1 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.

Equation 4.7 impulse responses for feedback gains zero, 0.7, and 0.95.
Equation 4.7 impulse responses for feedback gains zero, 0.7, and 0.95.

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.

The D equals one feedback instance routes output through feedback gain and one-sample delay before mixing it with the external input.
The D = 1 instance passes through gain and one-sample delay before returning to the mixer.

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 D=1.

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 k=0k=0, 0.70.7, and 0.950.95. The file carries an additional 12 dB attenuation.

Feedback waveform and ten-millisecond RMS compare gains zero, 0.7, and 0.95.
Almost Singing: delayed bounded feedback.

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.

An RMS echo-decay plot follows one bounded excitation. A text description follows.

Gain 0.65 produces echoes spaced 120 ms apart. The first returned echo cannot occur before the explicit delay.

Ready. Audio is stopped.
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.

  1. At 48 kHz, calculate the phase increment for 480 Hz.
  2. Starting at 110 Hz, find frequency for v=2v=2 and v=1v=-1.
  3. If x[n]=0.6x[n]=-0.6 and g[n]=0.25g[n]=0.25, find y[n]y[n].
  4. A VCA moves from linear gain 0.5 to unity gain 1. Find both levels in decibels and the amplitude change.
  5. Explain why higher fcf_c makes Equation 4.4 follow an input step faster.
  6. A release begins at 0.7 and lasts 0.4 s. Find its level after 0.1 s.
  7. If R=480R=480 at 48 kHz, find the control update rate.
  8. 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.

Cutoff trajectory and early and late spectra for the subtractive sweep.
Cutoff trajectory and early and late spectra for 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 600+2400e[n]600+2400e[n] Hz, then the same envelope controls VCA gain.

Pitch, gate, envelope, and output for the 16-step sequence.
Pitch, gate, envelope, and output for the 16-step sequence.

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.

Oscillator
LFO
Envelope

Sustain is a level, not a duration. Release starts after the gate ends.

Filter

This is a one-pole teaching filter, not a transistor-ladder model.

VCA
Sequencer

Clock Garden stores eight pitch steps and matching gates.

Output

The model applies a fixed 0.12 master scale before the selected VCA gain.

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.

Current preset connections
SourceDestination
A text description follows.

Subtractive contour routes a saw through the teaching filter and VCA while one envelope controls cutoff and gain.

Ready. Audio is stopped.

Try these checks

  1. Compare Basic VCO to VCA with ADSR articulation. Which new connection creates separate events?
  2. Use LFO vibrato at 1 Hz, then 10 Hz. Name the source, destination, rate, and depth.
  3. Lower filter cutoff in Subtractive contour. Explain what changes and what remains fixed.
  4. 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:

  1. Draw a VCO to VCF to VCA patch and add gate, envelope, and output.
  2. Explain why audio rate and control rate are properties of use and computation.
  3. Derive the frequency ratio for one octave and one semitone.
  4. State what memory Equation 4.4 needs.
  5. Describe this ADSR’s retrigger and release policies.
  6. Predict one audible difference between held and interpolated control.
  7. Explain why a digital feedback cycle needs delay.
  8. Give one reason the East Coast/West Coast binary is historically incomplete.
  9. 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.).

TermWorking definition
ADSREnvelope with attack time, decay time, sustain level, and release time.
Audio rateTimescale at which variation can enter the audible range; a digital system commonly evaluates it per sample-frame.
Control rateReduced 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-outOne output feeding more than one input.
FeedbackRouting a later signal back toward an earlier point in the graph.
GateA sustained on/off control commonly used to hold an envelope active.
GainOutput amplitude divided by input amplitude. Unity gain is 1, attenuation is below 1, and amplification is above 1.
LFOAn 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.
PatchA chosen set of signal connections and parameter settings.
PulseA brief event signal used for triggers, clocks, or stage changes.
SequencerA source that selects stored values and events in ordered steps.
Subtractive synthesisA patch pattern that removes or reshapes spectral energy from a rich source.
VCAA voltage-controlled amplifier whose core teaching operation is multiplication by gain.
VCFA voltage-controlled filter whose parameters can change over time.
VCOA voltage-controlled oscillator whose frequency or other behavior responds to control.
Zero-order holdHolding 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

Teaching VCO with voltage-to-current conversion, capacitor ramp, comparator, and reset path.
Teaching VCO topology.
Teaching VCA with audio and control entering an OTA before a load resistor creates output voltage.
Teaching VCA topology.
Teaching low-pass VCF with an OTA driving a capacitor and receiving output feedback.
Teaching VCF topology.

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

Pitch control, phase, and saw output compare 220 Hz with 440 Hz.
One Voltage, Two Octaves.

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.

Per-sample, held, and linearly interpolated vibrato controls are compared in time and frequency.
Per-sample and reduced-rate control.

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.

Gate, ADSR envelope, and multiplied saw output share one time axis.
Gate to envelope to VCA.

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.

A descending filter cutoff is paired with early and late spectra.
Contour Opens Color.

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.

Pitch steps, gates, envelopes, and filtered output align for a sixteen-step sequence.
Clock Garden.

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.

Feedback waveform and ten-millisecond RMS compare gains zero, 0.7, and 0.95.
Almost Singing.

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

Phase and saw output at 220 Hz, then 440 Hz.
Equation 4.1 deterministic output.

Long description. Phase wraps from one to zero and becomes twice as steep after 10 milliseconds. Saw cycles become twice as dense.

Frequency rises exponentially across minus two to plus two octave controls.
Equation 4.2 deterministic output.

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.

A sine is multiplied by gain rising from zero to one.
Equation 4.3 deterministic output.

Long description. The fixed input sine remains visible behind a gain line. The multiplied output grows from silence to the input amplitude.

One-pole step responses compare 200, 900, and 4000 Hz cutoffs.
Equation 4.4 deterministic output.

Long description. All three responses approach one. The 4 kHz response rises fastest, followed by 900 Hz and 200 Hz.

A linear envelope rises from 0.25 to 0.9 in 0.8 seconds.
Equation 4.5 deterministic output.

Long description. The line begins at 0.25, reaches 0.9 exactly at 0.8 second, and holds its target afterward.

Four control values are each held for eight audio samples.
Equation 4.6 deterministic output.

Long description. Control samples appear at indices zero, eight, sixteen, and twenty-four. A staircase preserves each value until the next update.

Identity-processor feedback responses compare gains zero, 0.7, and 0.95.
Equation 4.7 deterministic output.

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

  1. 480/48000=0.01480/48000=0.01 cycle per sample.
  2. 110×22=440110\times2^2=440 Hz. For v=1v=-1, frequency is 55 Hz.
  3. y[n]=0.25(0.6)=0.15y[n]=0.25(-0.6)=-0.15.
  4. Gain 0.5 is 20log10(0.5)6.02 dB. Unity gain is 0 dB. The change is about +6.02 dB and doubles amplitude.
  5. Higher cutoff makes aa smaller, so the current-input weight 1a1-a is larger and the previous-output weight is smaller.
  6. One quarter of the segment has elapsed. The level is 0.7+(00.7)(0.25)=0.5250.7+(0-0.7)(0.25)=0.525.
  7. 48000/480=10048000/480=100 control updates per second.
  8. The delayed output y[nD]y[n-D], with an explicit integer delay D1.

Readiness answers

  1. 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.
  2. 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.
  3. One octave is 21=22^1=2. One semitone is 21/121.059462^{1/12}\approx1.05946.
  4. One previous output sample, y[n1]y[n-1].
  5. Gate-on restarts attack from the current level. Gate-off starts release from the current level. Zero-length stages jump to target.
  6. A hold makes steps at update boundaries. Interpolation connects updates and usually reduces the abrupt spectral artifacts.
  7. Without delay, the current output would depend immediately on itself. Reading an earlier sample y[nD] with D1 makes evaluation ordered and causal.
  8. Both systems supported broader overlapping modular practice than a Moog-equals-subtractive and Buchla-equals-experimental binary suggests.
  9. 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.