When the Studio Became the Instrument
Section I · Theory
Build the modelSound, history, and the mathematics that connects them.Chapter contract
Prerequisites. Chapters 1 and 2, especially frequency, phase accumulation, heterodyne multiplication, and amplitude trajectories.
By the end of this chapter, you should be able to:
- explain how a fixed trace turns an event into editable material;
- calculate the linked pitch and duration change caused by playback rate;
- model selection, reversal, looping, splicing, envelope shaping, filtering, and ring modulation;
- name a source or base signal, carrier, modulating wave, modulation depth, and output before tracing a modulation rule;
- distinguish amplitude modulation, ring modulation, phase modulation, PCM, and line coding;
- distinguish Schaeffer’s disc practice, Cologne’s generated-signal studio, and RCA’s stored control;
- read a small event roll and render it with the browser lab;
- diagnose clicks, wrong duration, unintended sidebands, and scheduling errors.
Four copies of one synthetic strike
Listen first to the Chapter 3 transformation study. Begin at a low level. Stop if the repeated transient becomes uncomfortable.
The first sound is a 250 ms project-authored synthetic strike. It is a teaching surrogate, not a field recording and not a historical studio recording. The next segment repeats the same fixed samples four times. Repetition creates a pulse and exposes details that a single event can hide.
Long description. Time runs from 0 to 250 milliseconds. A fast onset leads to an irregular oscillation whose amplitude decays toward zero. The plot is a deterministic synthetic teaching surrogate.
Figure provenance. Author-generated by
assets/figures/src/ch03_studio.py at 48 kHz from sinusoids
at 317, 701, and 1193 Hz with exponential decay constant 18 s⁻¹ and a 2
ms onset ramp.
That observation asks a new question:
What changes when sound becomes a trace that can be repeated, cut, reordered, and played at another rate?
Schaeffer: fixation before tape
Pierre Schaeffer’s 1948 experiments at French radio used phonograph discs, including closed grooves. They did not begin on magnetic tape. Fixation and repetition separated a trace from the ordinary flow of its source and supported attention to the sound itself (INA grm n.d.a; IRCAM 1948). The word concrete describes beginning with fixed audible material rather than only an abstract score. It does not restrict the material to natural sounds.
Look at the support before the later tape tools. No archival photograph of Schaeffer’s 1948 RTF room with clear worldwide reuse rights is embedded here. The two openly licensed photographs below show a 1925 78 rpm groove and a 1940–1941 radio-phonograph with a record-cutting head. They are period context, not Schaeffer’s exact apparatus.
Long descriptions. The groove photograph fills the frame with several dark curved channels separated by lighter lands. The period recorder stands as a tall wooden cabinet. Its open lid reveals a turntable and cutting mechanism above radio controls.
Long description. The left disc path spirals inward. The center path is one closed circle with an arrow returning to its start. The right timeline repeats the same colored trace four times, connecting physical revolution to audible repetition.
Figure provenance. Deterministic project-authored reconstruction from ch03_foundations.py, based on the cited INA-GRM account. It explains the mechanism without pretending to reproduce a particular RTF machine.
Long description. Recorded event flows to disc or tape fixation, selection and cutting, looping or reversal or rate change or filtering, splicing and mixing, then a fixed composition.
Diagram provenance. Author-generated Mermaid summary from INA-GRM’s account of fixation and support manipulation (INA grm n.d.a, n.d.b).
Tape later made cutting, splicing, loops, reversal, filtering, mixing, and repeated copying more practical. A loop is physical memory. A splice changes order. Reversal changes every local trajectory. Speed changes time and pitch together.
Long description. An open suitcase-style recorder holds a full supply reel on the left and a take-up reel on the right. A thin strip of magnetic tape travels between them across the transport and recording heads. Knobs and switches control motion and level. A microphone stands beside the loudspeaker cabinet.
What the tape does. The moving plastic strip carries a magnetic coating. The record head turns the changing electrical audio signal into changing magnetization along the strip. On playback, the tape passes a head again and induces a changing electrical signal. The reels provide a visible timeline: material earlier on the strip reaches the head earlier.
Cologne: compose a signal chain
The WDR studio founded in Cologne in 1951 became a foundational early purpose-built electronic studio. Its mature practice centered on generated material, but it should not be called the world’s first without qualification. Sources describe pure-tone, pulse, and noise generators, filters, ring modulators, tape machines, meters, amplifiers, and mixing controls (Westdeutscher Rundfunk n.d.).
Long description. Pure tone, pulse, or noise enters a gate or envelope, filter or ring modulator, mixer, and tape recorder. Cut, loop, reverse, or speed operations return edited tape to the mixer.
Diagram provenance. Author-generated from WDR’s equipment and workflow account (Westdeutscher Rundfunk n.d.).
The useful model is a patched production chain, not a modern all-in-one synthesizer and not a DAW. Humans set generators, routed boxes, measured levels, recorded results, and assembled tape.
A gate and an envelope perform different jobs. A gate is a command with two states: open or closed. It says when an event is active. An amplitude envelope is a changing control level that responds to that command. It can rise after gate-on and fall after gate-off instead of switching the audio abruptly. The studio then multiplies the generated source by that envelope before filtering, modulation, mixing, and recording.
HISTORICAL MODEL · SOURCE TO TAPE
Patch a Cologne signal chain
Follow one generated signal through a gate, an amplitude envelope, one processor, a mixer level, and a recorder. This project-authored model explains the routing described for WDR. It does not claim to emulate one exact historical session or circuit.
Hearing safety. Lower device volume before playback. Every audition is bounded at or below 0.12 peak.
- 1 · SOURCEGenerator440 Hz pure tone
- 2 · CONTROLGate → envelopeGate commands; envelope smooths
- 3 · PROCESSRing modulator110 Hz modulating wave
- 4 · LEVELMixer75% level
- 5 · MEMORYMagnetic tapeFixes the output for replay and editing
Gate: open from 0.10 s to 0.75 sRing: 440 Hz × 110 Hz → 330 Hz and 550 Hz
Trace it before listening
- Set attack to zero. The gate and envelope now change together; listen for the hard edge.
- Restore an 80 ms attack. The binary gate still switches instantly, but the envelope rises gradually.
- Choose Ring modulation. For a pure tone, predict 330 Hz and 550 Hz before playing the output.
- Choose Bypass. The recorder now fixes the shaped source without a spectral processor.
- Choose Noise and the filter. Listen for the reduced high-frequency energy.
Read the exact TypeScript running this lab
The browser renders one bounded 1.4-second trace at 48 kHz. Noise is seeded, and no network or recorded source is used.
export const SAMPLE_RATE = 48_000;
const TAU = 2 * Math.PI;
const FIR_TAPS = [0.25, 0.5, 0.25] as const;
const PULSE_PARTIALS = [1, 3, 5, 7, 9] as const;
export type Transformation = 'envelope' | 'fir' | 'am' | 'ring' | 'phase';
export type CologneSource = 'tone' | 'pulse' | 'noise';
export type CologneProcessor = 'bypass' | 'filter' | 'ring';
export type StudioFault = 'splice' | 'rate' | 'sidebands' | 'overlap';
export interface PaperRollEvent { start: number; duration: number; ring: boolean }
export interface CologneChainSettings {
source: CologneSource;
processor: CologneProcessor;
gateSeconds: number;
attackSeconds: number;
releaseSeconds: number;
modulatorHz: number;
mix: number;
}
export const PAPER_ROLL_FREQUENCIES = [220, 330, 247, 440, 294] as const;
export const DEFAULT_PAPER_ROLL: PaperRollEvent[] = [
{ start: 0, duration: 0.5, ring: false },
{ start: 0.5, duration: 0.25, ring: true },
{ start: 0.75, duration: 0.5, ring: false },
{ start: 1.25, duration: 0.25, ring: true },
{ start: 1.5, duration: 0.75, ring: false },
];
export const faultGuidance: Record<StudioFault, string> = {
splice: 'The hard boundary jumps between sample values. Move the cut to a zero crossing or add a short complementary crossfade.',
rate: 'The faulty half-speed copy was truncated to the original duration. Derive duration as T divided by rate, so half speed lasts twice as long.',
sidebands: 'Ring multiplication replaces the ideal carrier with difference and sum components. Bypass the multiplier when 330 and 550 Hz were not intended.',
overlap: 'Event 2 starts before event 1 ends, so their levels add. Repair the stored start time or deliberately rebalance an intended overlap.',
};
export function tapeRate(originalHz: number, originalSeconds: number, rate: number) {
if (!Number.isFinite(rate) || rate <= 0) throw new RangeError('Rate must be positive');
return { frequencyHz: originalHz * rate, durationSeconds: originalSeconds / rate, semitones: 12 * Math.log2(rate) };
}
export function renderSyntheticStrike(repeats = 1, rate = 1, reverse = false) {
const sourceFrames = Math.round(0.25 * SAMPLE_RATE);
const readFrames = Math.floor((sourceFrames - 1) / rate) + 1;
const output = new Float32Array(readFrames * repeats);
for (let repeat = 0; repeat < repeats; repeat++) for (let frame = 0; frame < readFrames; frame++) {
const read = reverse ? sourceFrames - 1 - frame * rate : frame * rate;
const position = Math.max(0, Math.min(sourceFrames - 1, read));
const time = position / SAMPLE_RATE;
const strike = (Math.sin(TAU * 317 * time) + .55 * Math.sin(TAU * 701 * time) + .25 * Math.sin(TAU * 1193 * time)) * Math.exp(-18 * time);
const onset = Math.min(1, time / .002);
output[repeat * readFrames + frame] = .2 * onset * strike / 1.8;
}
return output;
}
export function matchPeakGain(samples: Float32Array, peak = 0.18) {
let current = 0;
for (const sample of samples) current = Math.max(current, Math.abs(sample));
if (!current) return samples.slice();
return samples.map((sample) => sample * peak / current);
}
export function complementaryCrossfadeGains(frame: number, frames: number) {
if (!Number.isInteger(frame) || !Number.isInteger(frames) || frames < 2 || frame < 0 || frame >= frames) throw new RangeError('Crossfade frame is outside the fade');
const incoming = frame / (frames - 1);
return { outgoing: 1 - incoming, incoming };
}
function spliceSlices() {
const frames = Math.round(0.12 * SAMPLE_RATE);
const left = new Float32Array(frames);
const right = new Float32Array(frames);
for (let frame = 0; frame < frames; frame++) {
const time = frame / SAMPLE_RATE;
left[frame] = .16 * Math.sin(TAU * 225 * time) * Math.min(1, time / .005);
right[frame] = .16 * Math.sin(TAU * 330 * time + Math.PI / 2) * Math.min(1, (frames - 1 - frame) / (.005 * SAMPLE_RATE));
}
return { left, right };
}
export function renderSplice(crossfadeFrames = 0) {
if (!Number.isFinite(crossfadeFrames) || crossfadeFrames < 0) throw new RangeError('Crossfade length must be non-negative');
const { left, right } = spliceSlices();
if (crossfadeFrames <= 0) {
const output = new Float32Array(left.length + right.length);
output.set(left); output.set(right, left.length);
return output;
}
const frames = Math.max(2, Math.min(Math.round(crossfadeFrames), left.length, right.length));
const output = new Float32Array(left.length + right.length - frames);
output.set(left.subarray(0, left.length - frames));
for (let frame = 0; frame < frames; frame++) {
const gains = complementaryCrossfadeGains(frame, frames);
output[left.length - frames + frame] = gains.outgoing * left[left.length - frames + frame] + gains.incoming * right[frame];
}
output.set(right.subarray(frames), left.length);
return output;
}
export function firFilter(input: ArrayLike<number>, taps: ArrayLike<number> = FIR_TAPS) {
if (!taps.length) throw new RangeError('FIR needs at least one tap');
const output = new Float32Array(input.length + taps.length - 1);
for (let frame = 0; frame < output.length; frame++) for (let tap = 0; tap < taps.length; tap++) {
const source = frame - tap;
if (source >= 0 && source < input.length) output[frame] += taps[tap] * input[source];
}
return output;
}
function edgeGain(frame: number, frames: number, fade = 480) {
return Math.max(0, Math.min(1, frame / fade, (frames - 1 - frame) / fade));
}
function sourceTone() {
const frames = SAMPLE_RATE / 2;
const output = new Float32Array(frames);
for (let frame = 0; frame < frames; frame++) output[frame] = .16 * edgeGain(frame, frames) * Math.sin(TAU * 440 * frame / SAMPLE_RATE);
return output;
}
export function renderTransformation(kind: Transformation) {
const source = sourceTone();
if (kind === 'fir') return { source, control: Float32Array.from(FIR_TAPS), output: firFilter(source), description: 'The impulse response is [0.25, 0.5, 0.25]. The output is the weighted current sample and two delays.' };
const control = new Float32Array(source.length);
const output = new Float32Array(source.length);
for (let frame = 0; frame < source.length; frame++) {
if (kind === 'envelope') control[frame] = Math.min(1, frame / 4_800, (source.length - 1 - frame) / 4_800);
else control[frame] = Math.cos(TAU * 110 * frame / SAMPLE_RATE);
if (kind === 'am') output[frame] = source[frame] * (.5 + .375 * control[frame]);
else if (kind === 'phase') output[frame] = .16 * edgeGain(frame, source.length) * Math.sin(TAU * 440 * frame / SAMPLE_RATE + control[frame]);
else output[frame] = source[frame] * control[frame];
}
const description = {
envelope: 'A unitless envelope rises, holds, and falls. Multiplication shapes the 440 Hz source to zero at both endpoints.',
am: 'Amplitude modulation uses 0.5[1 + 0.75m(t)]c(t): depth 0.75 plus a final 0.5 safety gain. The 440 Hz carrier remains beside 330 Hz and 550 Hz sidebands.',
ring: 'Ring modulation directly multiplies a 440 Hz carrier by a 110 Hz modulator. The ideal output contains 330 Hz and 550 Hz, but neither input frequency.',
phase: 'Phase modulation adds a 110 Hz modulator to the 440 Hz carrier phase with a one-radian index. A fixed phase offset would only move the wave in time; this changing offset creates multiple sidebands.',
} as const;
return { source, control, output, description: description[kind] };
}
export function renderCologneChain(settings: CologneChainSettings) {
const { source: sourceKind, processor, gateSeconds, attackSeconds, releaseSeconds, modulatorHz, mix } = settings;
if (![gateSeconds, attackSeconds, releaseSeconds, modulatorHz, mix].every(Number.isFinite)) throw new RangeError('Chain controls must be finite');
if (gateSeconds <= 0 || attackSeconds < 0 || releaseSeconds < 0 || modulatorHz <= 0 || mix < 0 || mix > 1) throw new RangeError('Chain controls are outside their safe range');
const frames = Math.round(1.4 * SAMPLE_RATE);
const gateOnSeconds = .1;
const gateOffSeconds = gateOnSeconds + gateSeconds;
if (gateOffSeconds + releaseSeconds > frames / SAMPLE_RATE) throw new RangeError('Gate and release must fit inside the render');
const source = new Float32Array(frames);
const gate = new Float32Array(frames);
const envelope = new Float32Array(frames);
const modulator = new Float32Array(frames);
const output = new Float32Array(frames);
let noiseState = 0x12345678;
let filtered = 0;
const filterAlpha = 1 - Math.exp(-TAU * 1_200 / SAMPLE_RATE);
const levelAtGateOff = attackSeconds ? Math.min(1, gateSeconds / attackSeconds) : 1;
for (let frame = 0; frame < frames; frame++) {
const time = frame / SAMPLE_RATE;
if (sourceKind === 'tone') source[frame] = .12 * Math.sin(TAU * 440 * time);
else if (sourceKind === 'pulse') {
for (const partial of PULSE_PARTIALS) source[frame] += .068 * Math.sin(TAU * 440 * partial * time) / partial;
} else {
noiseState ^= noiseState << 13; noiseState ^= noiseState >>> 17; noiseState ^= noiseState << 5;
source[frame] = .12 * (2 * (noiseState >>> 0) / 0xffffffff - 1);
}
const isOpen = time >= gateOnSeconds && time < gateOffSeconds;
gate[frame] = isOpen ? 1 : 0;
if (isOpen) envelope[frame] = attackSeconds ? Math.min(1, (time - gateOnSeconds) / attackSeconds) : 1;
else if (time >= gateOffSeconds && releaseSeconds) envelope[frame] = levelAtGateOff * Math.max(0, 1 - (time - gateOffSeconds) / releaseSeconds);
modulator[frame] = Math.cos(TAU * modulatorHz * time);
const shaped = source[frame] * envelope[frame];
if (processor === 'filter') {
filtered += filterAlpha * (shaped - filtered);
output[frame] = mix * filtered;
} else output[frame] = mix * shaped * (processor === 'ring' ? modulator[frame] : 1);
}
return { source, gate, envelope, modulator, output, gateOnSeconds, gateOffSeconds };
}
export function ringModulationComponents(carrierHz: number, modulatorHz: number) {
return { differenceHz: Math.abs(carrierHz - modulatorHz), sumHz: carrierHz + modulatorHz };
}
function validateSchedule(events: readonly PaperRollEvent[]) {
if (events.length !== PAPER_ROLL_FREQUENCIES.length) throw new RangeError('The paper roll has exactly five events');
for (const event of events) if (!Number.isFinite(event.start) || !Number.isFinite(event.duration) || event.start < 0 || event.duration <= 0 || event.start + event.duration > 6) throw new RangeError('Event times must be positive and remain within six seconds');
}
export function scheduleMetrics(events: readonly PaperRollEvent[]) {
validateSchedule(events);
const overlapPairs: string[] = [];
for (let first = 0; first < events.length; first++) for (let second = first + 1; second < events.length; second++) {
if (events[first].start < events[second].start + events[second].duration && events[second].start < events[first].start + events[first].duration) overlapPairs.push(`events ${first + 1} and ${second + 1}`);
}
return { totalDuration: Math.max(...events.map((event) => event.start + event.duration)), overlapPairs };
}
export function renderPaperRoll(events: readonly PaperRollEvent[]) {
const { totalDuration } = scheduleMetrics(events);
const output = new Float32Array(Math.ceil(totalDuration * SAMPLE_RATE));
events.forEach((event, index) => {
const start = Math.round(event.start * SAMPLE_RATE);
const frames = Math.round(event.duration * SAMPLE_RATE);
const fade = Math.min(240, Math.floor((frames - 1) / 2));
for (let frame = 0; frame < frames; frame++) {
const envelope = fade ? Math.min(1, frame / fade, (frames - 1 - frame) / fade) : 0;
const time = frame / SAMPLE_RATE;
const ring = event.ring ? Math.cos(TAU * 110 * time) : 1;
output[start + frame] += .055 * envelope * Math.sin(TAU * PAPER_ROLL_FREQUENCIES[index] * time) * ring;
}
});
return output.map((sample) => .18 * Math.tanh(sample / .18));
}
export function renderFault(kind: StudioFault) {
if (kind === 'splice') return renderSplice(0);
if (kind === 'sidebands') return renderTransformation('ring').output;
if (kind === 'overlap') {
const faulty = DEFAULT_PAPER_ROLL.map((event) => ({ ...event }));
faulty[1].start = .35;
return renderPaperRoll(faulty);
}
const truncated = renderSyntheticStrike(1, .5).slice(0, SAMPLE_RATE / 4);
const fade = 240;
for (let frame = 0; frame < fade; frame++) truncated[truncated.length - 1 - frame] *= frame / fade;
return truncated;
}
export function renderFaultRepair(kind: StudioFault) {
if (kind === 'splice') return renderSplice(Math.round(.008 * SAMPLE_RATE));
if (kind === 'sidebands') return renderTransformation('ring').source;
if (kind === 'overlap') return renderPaperRoll(DEFAULT_PAPER_ROLL);
return renderSyntheticStrike(1, .5);
}
Name the signals before you modulate them
Start with the chapter’s two fixed waves: a 440 Hz tone and a 110 Hz tone. The words carrier and modulator name their jobs, not their value. The carrier is the waveform whose amplitude or phase is changed. The modulating wave supplies the change. The output is the new waveform after the rule combines them. In direct ring multiplication the mathematics is symmetric, so swapping the two inputs gives the same product even though engineers still use role names (Puckette 2007).
Source, base signal, and baseband signal. In this book, source signal means the waveform entering a process. In communications, baseband or message signal means the information-bearing signal before it is placed on a carrier. “Base signal” is not a precise universal term, so name the role you mean.
- Carrier,
- The signal whose parameter is changed. Here it is the 440 Hz tone.
- Modulating wave,
- The signal that causes the change. Here it is the 110 Hz tone.
- Depth or index
- A number that sets how strongly the modulator changes the carrier.
- Output,
- The signal that can be heard, mixed, or fixed on tape after the rule is applied.
Amplitude modulation retains a carrier
Let the modulator stay between −1 and 1. Let modulation depth stay between 0 and 1. Conventional amplitude modulation adds a constant before multiplication:
The constant 1 keeps a copy of the carrier in the ideal output. A slow modulator makes tremolo. An audio-rate sinusoidal modulator adds sum and difference sidebands around the retained carrier. At , nothing changes. At , the gain can just reach zero.
Ring modulation removes the offset
Ideal balanced or ring modulation uses . There is no added constant, so the ideal carrier and modulator lines are suppressed. For 440 Hz and 110 Hz cosines, the new lines are 330 Hz and 550 Hz. The later derivation shows exactly why.
A phase shift is not yet phase modulation
A fixed phase offset moves a periodic wave along time: . Because the offset stays fixed, it is not modulation. Phase modulation makes the offset change:
The phase-modulation index is a peak phase displacement in radians. With a sinusoidal modulator, PM creates multiple sidebands spaced by the modulator frequency. Their levels depend on (Puckette 2007). Phase-shift keying is different again: a digital transmitter chooses among fixed carrier phases for successive symbols. For binary PSK, one simple convention maps 0 to phase 0 and 1 to phase π. That is data transmission, not the Cologne studio technique modeled here.
PCM and line coding are coding stages
The word modulation in pulse-code modulation can mislead an audio reader. PCM does not mean multiplying an audible carrier. It samples an input, quantizes each sample to one allowed level, and represents that level with a code word (Oliver, Pierce, and Shannon 1948). Chapter 5 derives the sampling and quantization steps.
Line coding comes after the bit words exist. It maps bits to a physical waveform on a cable, radio path, optical link, or digital recording channel. It is not the same operation as sampling, quantization, or musical modulation (Pauly 2021).
| Stage | Input → output | What changes |
|---|---|---|
| PCM | sampled amplitudes → code words | Continuous amplitude choices become finite numerical choices. |
| Polar NRZ line code | bits → one positive or negative level per bit interval | The level does not return to zero inside the bit cell; long identical runs can hide timing. |
| Manchester line code | bits → opposite half-bit levels | Every bit contains a middle transition, which helps a receiver recover timing. |
| Phase-shift keying | symbols → selected carrier phases | The carrier phase changes at symbol boundaries. |
Keep the historical media straight. The magnetic tape pictured above stores a continuous analog magnetization trace. The RCA Mark II paper holes later in this chapter store control choices. Neither is PCM audio. PCM and line coding become relevant when audio samples are quantized, encoded as bits, and prepared for digital storage or transmission.
Mathematical concepts 3: source, shape, filter, and modulation
A generated sampled source
Let be sample index, peak amplitude, frequency in hertz, sample rate in samples per second, and phase in radians:
Long description. Thirty-two stems show two cycles of the sampled sinusoid. The first sample begins above zero because phase is π/6. Eight samples represent each 500 Hz cycle at 8000 samples per second.
Tape speed couples pitch and duration
Let playback rate ratio compare new speed with recorded speed. Let be an original frequency in hertz, an original duration in seconds, and pitch displacement in semitones:
Long description. The left panel shows frequency rising with rate and duration falling reciprocally. The right panel shows pitch shift from minus twelve semitones at half speed through zero at normal speed to plus twelve at double speed.
Half speed produces half the frequency and twice the duration. Double speed produces twice the frequency and half the duration. Historical tape speed could not preserve one while changing the other (INA grm n.d.b).
Long description. Three panels show the same samples read at rates 0.5, 1, and 2. Half speed lasts 500 ms and lowers pitch by an octave. Normal speed lasts 250 ms. Double speed lasts 125 ms and raises pitch by an octave.
Hear one trace at three transport speeds. Lower device volume first. Each player starts from the same deterministic synthetic strike. Only read rate changes. The examples are level matched so loudness does not decide the comparison.
Play 1×, then 0.5×, then 2×. Ask two separate questions after every click: did the event become higher or lower, and did it become shorter or longer?
Montage is indexed selection and order
A fixed trace is a sequence of samples. Let select samples from start index up to end index in source . The concatenation symbol joins selected slices:
Long description. Three colored blocks labeled slice 1, slice 2, and slice 3 occupy consecutive output sample ranges. Their source identities and lengths remain visible after their order becomes one timeline.
A hard cut can click when adjacent boundary samples disagree. A short crossfade replaces that jump with complementary gains. This is a repair for the splice, not a claim that every historical edit used the same fade.
SPLICE MICROSCOPE
Hear the boundary, then repair it
A hard cut can jump from one sample value to another and create a click. A short crossfade overlaps the same slices with complementary outgoing and incoming gains.
Hearing safety. Set a low listening level before playing the click. Stop if any transient is uncomfortable.
The hard cut contains a discontinuity. The 8 ms crossfade begins with outgoing gain 1 and incoming gain 0, then ends with outgoing gain 0 and incoming gain 1.
Try these checks
- Listen at low level and compare the same slices with a hard cut and an 8 ms crossfade.
- Set 1 ms, then 20 ms. Describe how boundary smoothing and overlap time change.
- Verify that each crossfade endpoint keeps the two gains complementary.
Read the exact TypeScript running this microscope
export const SAMPLE_RATE = 48_000;
const TAU = 2 * Math.PI;
const FIR_TAPS = [0.25, 0.5, 0.25] as const;
const PULSE_PARTIALS = [1, 3, 5, 7, 9] as const;
export type Transformation = 'envelope' | 'fir' | 'am' | 'ring' | 'phase';
export type CologneSource = 'tone' | 'pulse' | 'noise';
export type CologneProcessor = 'bypass' | 'filter' | 'ring';
export type StudioFault = 'splice' | 'rate' | 'sidebands' | 'overlap';
export interface PaperRollEvent { start: number; duration: number; ring: boolean }
export interface CologneChainSettings {
source: CologneSource;
processor: CologneProcessor;
gateSeconds: number;
attackSeconds: number;
releaseSeconds: number;
modulatorHz: number;
mix: number;
}
export const PAPER_ROLL_FREQUENCIES = [220, 330, 247, 440, 294] as const;
export const DEFAULT_PAPER_ROLL: PaperRollEvent[] = [
{ start: 0, duration: 0.5, ring: false },
{ start: 0.5, duration: 0.25, ring: true },
{ start: 0.75, duration: 0.5, ring: false },
{ start: 1.25, duration: 0.25, ring: true },
{ start: 1.5, duration: 0.75, ring: false },
];
export const faultGuidance: Record<StudioFault, string> = {
splice: 'The hard boundary jumps between sample values. Move the cut to a zero crossing or add a short complementary crossfade.',
rate: 'The faulty half-speed copy was truncated to the original duration. Derive duration as T divided by rate, so half speed lasts twice as long.',
sidebands: 'Ring multiplication replaces the ideal carrier with difference and sum components. Bypass the multiplier when 330 and 550 Hz were not intended.',
overlap: 'Event 2 starts before event 1 ends, so their levels add. Repair the stored start time or deliberately rebalance an intended overlap.',
};
export function tapeRate(originalHz: number, originalSeconds: number, rate: number) {
if (!Number.isFinite(rate) || rate <= 0) throw new RangeError('Rate must be positive');
return { frequencyHz: originalHz * rate, durationSeconds: originalSeconds / rate, semitones: 12 * Math.log2(rate) };
}
export function renderSyntheticStrike(repeats = 1, rate = 1, reverse = false) {
const sourceFrames = Math.round(0.25 * SAMPLE_RATE);
const readFrames = Math.floor((sourceFrames - 1) / rate) + 1;
const output = new Float32Array(readFrames * repeats);
for (let repeat = 0; repeat < repeats; repeat++) for (let frame = 0; frame < readFrames; frame++) {
const read = reverse ? sourceFrames - 1 - frame * rate : frame * rate;
const position = Math.max(0, Math.min(sourceFrames - 1, read));
const time = position / SAMPLE_RATE;
const strike = (Math.sin(TAU * 317 * time) + .55 * Math.sin(TAU * 701 * time) + .25 * Math.sin(TAU * 1193 * time)) * Math.exp(-18 * time);
const onset = Math.min(1, time / .002);
output[repeat * readFrames + frame] = .2 * onset * strike / 1.8;
}
return output;
}
export function matchPeakGain(samples: Float32Array, peak = 0.18) {
let current = 0;
for (const sample of samples) current = Math.max(current, Math.abs(sample));
if (!current) return samples.slice();
return samples.map((sample) => sample * peak / current);
}
export function complementaryCrossfadeGains(frame: number, frames: number) {
if (!Number.isInteger(frame) || !Number.isInteger(frames) || frames < 2 || frame < 0 || frame >= frames) throw new RangeError('Crossfade frame is outside the fade');
const incoming = frame / (frames - 1);
return { outgoing: 1 - incoming, incoming };
}
function spliceSlices() {
const frames = Math.round(0.12 * SAMPLE_RATE);
const left = new Float32Array(frames);
const right = new Float32Array(frames);
for (let frame = 0; frame < frames; frame++) {
const time = frame / SAMPLE_RATE;
left[frame] = .16 * Math.sin(TAU * 225 * time) * Math.min(1, time / .005);
right[frame] = .16 * Math.sin(TAU * 330 * time + Math.PI / 2) * Math.min(1, (frames - 1 - frame) / (.005 * SAMPLE_RATE));
}
return { left, right };
}
export function renderSplice(crossfadeFrames = 0) {
if (!Number.isFinite(crossfadeFrames) || crossfadeFrames < 0) throw new RangeError('Crossfade length must be non-negative');
const { left, right } = spliceSlices();
if (crossfadeFrames <= 0) {
const output = new Float32Array(left.length + right.length);
output.set(left); output.set(right, left.length);
return output;
}
const frames = Math.max(2, Math.min(Math.round(crossfadeFrames), left.length, right.length));
const output = new Float32Array(left.length + right.length - frames);
output.set(left.subarray(0, left.length - frames));
for (let frame = 0; frame < frames; frame++) {
const gains = complementaryCrossfadeGains(frame, frames);
output[left.length - frames + frame] = gains.outgoing * left[left.length - frames + frame] + gains.incoming * right[frame];
}
output.set(right.subarray(frames), left.length);
return output;
}
export function firFilter(input: ArrayLike<number>, taps: ArrayLike<number> = FIR_TAPS) {
if (!taps.length) throw new RangeError('FIR needs at least one tap');
const output = new Float32Array(input.length + taps.length - 1);
for (let frame = 0; frame < output.length; frame++) for (let tap = 0; tap < taps.length; tap++) {
const source = frame - tap;
if (source >= 0 && source < input.length) output[frame] += taps[tap] * input[source];
}
return output;
}
function edgeGain(frame: number, frames: number, fade = 480) {
return Math.max(0, Math.min(1, frame / fade, (frames - 1 - frame) / fade));
}
function sourceTone() {
const frames = SAMPLE_RATE / 2;
const output = new Float32Array(frames);
for (let frame = 0; frame < frames; frame++) output[frame] = .16 * edgeGain(frame, frames) * Math.sin(TAU * 440 * frame / SAMPLE_RATE);
return output;
}
export function renderTransformation(kind: Transformation) {
const source = sourceTone();
if (kind === 'fir') return { source, control: Float32Array.from(FIR_TAPS), output: firFilter(source), description: 'The impulse response is [0.25, 0.5, 0.25]. The output is the weighted current sample and two delays.' };
const control = new Float32Array(source.length);
const output = new Float32Array(source.length);
for (let frame = 0; frame < source.length; frame++) {
if (kind === 'envelope') control[frame] = Math.min(1, frame / 4_800, (source.length - 1 - frame) / 4_800);
else control[frame] = Math.cos(TAU * 110 * frame / SAMPLE_RATE);
if (kind === 'am') output[frame] = source[frame] * (.5 + .375 * control[frame]);
else if (kind === 'phase') output[frame] = .16 * edgeGain(frame, source.length) * Math.sin(TAU * 440 * frame / SAMPLE_RATE + control[frame]);
else output[frame] = source[frame] * control[frame];
}
const description = {
envelope: 'A unitless envelope rises, holds, and falls. Multiplication shapes the 440 Hz source to zero at both endpoints.',
am: 'Amplitude modulation uses 0.5[1 + 0.75m(t)]c(t): depth 0.75 plus a final 0.5 safety gain. The 440 Hz carrier remains beside 330 Hz and 550 Hz sidebands.',
ring: 'Ring modulation directly multiplies a 440 Hz carrier by a 110 Hz modulator. The ideal output contains 330 Hz and 550 Hz, but neither input frequency.',
phase: 'Phase modulation adds a 110 Hz modulator to the 440 Hz carrier phase with a one-radian index. A fixed phase offset would only move the wave in time; this changing offset creates multiple sidebands.',
} as const;
return { source, control, output, description: description[kind] };
}
export function renderCologneChain(settings: CologneChainSettings) {
const { source: sourceKind, processor, gateSeconds, attackSeconds, releaseSeconds, modulatorHz, mix } = settings;
if (![gateSeconds, attackSeconds, releaseSeconds, modulatorHz, mix].every(Number.isFinite)) throw new RangeError('Chain controls must be finite');
if (gateSeconds <= 0 || attackSeconds < 0 || releaseSeconds < 0 || modulatorHz <= 0 || mix < 0 || mix > 1) throw new RangeError('Chain controls are outside their safe range');
const frames = Math.round(1.4 * SAMPLE_RATE);
const gateOnSeconds = .1;
const gateOffSeconds = gateOnSeconds + gateSeconds;
if (gateOffSeconds + releaseSeconds > frames / SAMPLE_RATE) throw new RangeError('Gate and release must fit inside the render');
const source = new Float32Array(frames);
const gate = new Float32Array(frames);
const envelope = new Float32Array(frames);
const modulator = new Float32Array(frames);
const output = new Float32Array(frames);
let noiseState = 0x12345678;
let filtered = 0;
const filterAlpha = 1 - Math.exp(-TAU * 1_200 / SAMPLE_RATE);
const levelAtGateOff = attackSeconds ? Math.min(1, gateSeconds / attackSeconds) : 1;
for (let frame = 0; frame < frames; frame++) {
const time = frame / SAMPLE_RATE;
if (sourceKind === 'tone') source[frame] = .12 * Math.sin(TAU * 440 * time);
else if (sourceKind === 'pulse') {
for (const partial of PULSE_PARTIALS) source[frame] += .068 * Math.sin(TAU * 440 * partial * time) / partial;
} else {
noiseState ^= noiseState << 13; noiseState ^= noiseState >>> 17; noiseState ^= noiseState << 5;
source[frame] = .12 * (2 * (noiseState >>> 0) / 0xffffffff - 1);
}
const isOpen = time >= gateOnSeconds && time < gateOffSeconds;
gate[frame] = isOpen ? 1 : 0;
if (isOpen) envelope[frame] = attackSeconds ? Math.min(1, (time - gateOnSeconds) / attackSeconds) : 1;
else if (time >= gateOffSeconds && releaseSeconds) envelope[frame] = levelAtGateOff * Math.max(0, 1 - (time - gateOffSeconds) / releaseSeconds);
modulator[frame] = Math.cos(TAU * modulatorHz * time);
const shaped = source[frame] * envelope[frame];
if (processor === 'filter') {
filtered += filterAlpha * (shaped - filtered);
output[frame] = mix * filtered;
} else output[frame] = mix * shaped * (processor === 'ring' ? modulator[frame] : 1);
}
return { source, gate, envelope, modulator, output, gateOnSeconds, gateOffSeconds };
}
export function ringModulationComponents(carrierHz: number, modulatorHz: number) {
return { differenceHz: Math.abs(carrierHz - modulatorHz), sumHz: carrierHz + modulatorHz };
}
function validateSchedule(events: readonly PaperRollEvent[]) {
if (events.length !== PAPER_ROLL_FREQUENCIES.length) throw new RangeError('The paper roll has exactly five events');
for (const event of events) if (!Number.isFinite(event.start) || !Number.isFinite(event.duration) || event.start < 0 || event.duration <= 0 || event.start + event.duration > 6) throw new RangeError('Event times must be positive and remain within six seconds');
}
export function scheduleMetrics(events: readonly PaperRollEvent[]) {
validateSchedule(events);
const overlapPairs: string[] = [];
for (let first = 0; first < events.length; first++) for (let second = first + 1; second < events.length; second++) {
if (events[first].start < events[second].start + events[second].duration && events[second].start < events[first].start + events[first].duration) overlapPairs.push(`events ${first + 1} and ${second + 1}`);
}
return { totalDuration: Math.max(...events.map((event) => event.start + event.duration)), overlapPairs };
}
export function renderPaperRoll(events: readonly PaperRollEvent[]) {
const { totalDuration } = scheduleMetrics(events);
const output = new Float32Array(Math.ceil(totalDuration * SAMPLE_RATE));
events.forEach((event, index) => {
const start = Math.round(event.start * SAMPLE_RATE);
const frames = Math.round(event.duration * SAMPLE_RATE);
const fade = Math.min(240, Math.floor((frames - 1) / 2));
for (let frame = 0; frame < frames; frame++) {
const envelope = fade ? Math.min(1, frame / fade, (frames - 1 - frame) / fade) : 0;
const time = frame / SAMPLE_RATE;
const ring = event.ring ? Math.cos(TAU * 110 * time) : 1;
output[start + frame] += .055 * envelope * Math.sin(TAU * PAPER_ROLL_FREQUENCIES[index] * time) * ring;
}
});
return output.map((sample) => .18 * Math.tanh(sample / .18));
}
export function renderFault(kind: StudioFault) {
if (kind === 'splice') return renderSplice(0);
if (kind === 'sidebands') return renderTransformation('ring').output;
if (kind === 'overlap') {
const faulty = DEFAULT_PAPER_ROLL.map((event) => ({ ...event }));
faulty[1].start = .35;
return renderPaperRoll(faulty);
}
const truncated = renderSyntheticStrike(1, .5).slice(0, SAMPLE_RATE / 4);
const fade = 240;
for (let frame = 0; frame < fade; frame++) truncated[truncated.length - 1 - frame] *= frame / fade;
return truncated;
}
export function renderFaultRepair(kind: StudioFault) {
if (kind === 'splice') return renderSplice(Math.round(.008 * SAMPLE_RATE));
if (kind === 'sidebands') return renderTransformation('ring').source;
if (kind === 'overlap') return renderPaperRoll(DEFAULT_PAPER_ROLL);
return renderSyntheticStrike(1, .5);
}
Long description. The forward curve starts high and decays. The reversed curve approaches from low amplitude and ends high. Sample reversal reverses the whole trajectory, not only note order.
Amplitude shaping
Let be a unitless envelope between zero and one:
Long description. A gray sinusoid continues at constant amplitude. A teal envelope rises, holds, and falls. The red output follows the sinusoid inside that boundary and reaches zero at both endpoints.
An envelope can create onset, sustain, accent, and release. Do not project one modern ADSR circuit onto every historical studio. The common idea is multiplication by a changing control.
Equation 3.4 is the multiplication. The envelope supplies each value of ; it does not replace the source. For example, if one source sample is 0.6 and the envelope is 0.25, the output is .
One modern linear ADSR sketch makes those control values explicit. Let attack, decay, and release times be , , and ; let be the sustain level; and let be gate-off time. Assume :
Attack controls how the onset rises. Decay can create a short accent by falling from peak level 1 toward . Sustain is a level held while the gate remains active, not a duration. Release starts at gate off and falls toward zero. At every instant, the audible result is still source times envelope: .
Long description. Four rows share a two-second axis. The onset row rises slowly. The sustain row holds 0.65 until a late gate-off line. The accent row rises to one and decays to 0.35. The release row begins falling at 0.9 second and reaches zero 0.9 second later. Shaded red bounds show where the multiplied waveform can exist.
Figure and audio provenance. Deterministic project-authored output from ch03_foundations.py. Source: 220 Hz plus a quieter 440 Hz component. All files peak at or below 0.16.
A finite linear filter
Let contain filter coefficients. Each output sample is a weighted sum of the current and delayed input samples:
Long description. An input impulse produces three output stems with amplitudes 0.25, 0.5, and 0.25. Later samples are zero. The response exposes the exact three weighted delays.
This three-point average reduces rapid sample-to-sample change. Historical studio filters could be much more selective. The small filter keeps the arithmetic visible.
Ring modulation
Multiplication of carrier frequency and modulator frequency creates sum and difference components:
Long description. Two equal spectrum stems appear at 330 and 550 Hz. No stem remains at the original 440 or 110 Hz in the ideal multiplication model.
This extends Chapter 2’s heterodyne identity. The studio uses the same mathematics as a timbre transformation.
Do not confuse addition with ring multiplication. First hear the 440 Hz carrier and 110 Hz modulator alone. Adding them keeps both original spectrum lines. Multiplying them sample by sample removes those ideal input lines and creates the 330 Hz difference and 550 Hz sum components predicted by Equation 3.6.
Long description. Four rows pair a 40 ms waveform with ideal spectrum stems. Carrier has one stem at 440 Hz. Modulator has one at 110 Hz. Add has stems at both 110 and 440 Hz. Multiply has neither input stem and instead has equal stems at 330 and 550 Hz.
Figure and audio provenance. Deterministic project-authored cosines from ch03_foundations.py. Every file has ten-millisecond safety edges and peaks at or below 0.14.
SOURCE · CONTROL · OUTPUT
Compare envelope, filter, and modulation
Keep the signal roles separate. The deterministic models implement Equations 3.4 through 3.6, then contrast amplitude and phase modulation with ring multiplication.
Hearing safety. Lower device volume before playback. Source and output stay at conservative levels.
- Carrier or source
- The waveform whose amplitude or phase is changed.
- Modulating wave or control
- The waveform that causes the change. It can be slow enough to hear as motion or fast enough to create sidebands.
- Output
- The new signal after the rule combines source and control.
Current prediction: The envelope changes level without changing the source frequency.
Try these checks
- For the envelope, find where a unitless control makes the output reach zero.
- For the FIR, predict the response to an impulse before reading [0.25, 0.5, 0.25].
- Compare AM with ring modulation. Which version retains the 440 Hz carrier?
- Compare a fixed phase offset in the prose with phase modulation here. Which one keeps changing?
- For ring modulation, listen for the changed spectrum and name both sidebands.
Read the exact TypeScript running this station
export const SAMPLE_RATE = 48_000;
const TAU = 2 * Math.PI;
const FIR_TAPS = [0.25, 0.5, 0.25] as const;
const PULSE_PARTIALS = [1, 3, 5, 7, 9] as const;
export type Transformation = 'envelope' | 'fir' | 'am' | 'ring' | 'phase';
export type CologneSource = 'tone' | 'pulse' | 'noise';
export type CologneProcessor = 'bypass' | 'filter' | 'ring';
export type StudioFault = 'splice' | 'rate' | 'sidebands' | 'overlap';
export interface PaperRollEvent { start: number; duration: number; ring: boolean }
export interface CologneChainSettings {
source: CologneSource;
processor: CologneProcessor;
gateSeconds: number;
attackSeconds: number;
releaseSeconds: number;
modulatorHz: number;
mix: number;
}
export const PAPER_ROLL_FREQUENCIES = [220, 330, 247, 440, 294] as const;
export const DEFAULT_PAPER_ROLL: PaperRollEvent[] = [
{ start: 0, duration: 0.5, ring: false },
{ start: 0.5, duration: 0.25, ring: true },
{ start: 0.75, duration: 0.5, ring: false },
{ start: 1.25, duration: 0.25, ring: true },
{ start: 1.5, duration: 0.75, ring: false },
];
export const faultGuidance: Record<StudioFault, string> = {
splice: 'The hard boundary jumps between sample values. Move the cut to a zero crossing or add a short complementary crossfade.',
rate: 'The faulty half-speed copy was truncated to the original duration. Derive duration as T divided by rate, so half speed lasts twice as long.',
sidebands: 'Ring multiplication replaces the ideal carrier with difference and sum components. Bypass the multiplier when 330 and 550 Hz were not intended.',
overlap: 'Event 2 starts before event 1 ends, so their levels add. Repair the stored start time or deliberately rebalance an intended overlap.',
};
export function tapeRate(originalHz: number, originalSeconds: number, rate: number) {
if (!Number.isFinite(rate) || rate <= 0) throw new RangeError('Rate must be positive');
return { frequencyHz: originalHz * rate, durationSeconds: originalSeconds / rate, semitones: 12 * Math.log2(rate) };
}
export function renderSyntheticStrike(repeats = 1, rate = 1, reverse = false) {
const sourceFrames = Math.round(0.25 * SAMPLE_RATE);
const readFrames = Math.floor((sourceFrames - 1) / rate) + 1;
const output = new Float32Array(readFrames * repeats);
for (let repeat = 0; repeat < repeats; repeat++) for (let frame = 0; frame < readFrames; frame++) {
const read = reverse ? sourceFrames - 1 - frame * rate : frame * rate;
const position = Math.max(0, Math.min(sourceFrames - 1, read));
const time = position / SAMPLE_RATE;
const strike = (Math.sin(TAU * 317 * time) + .55 * Math.sin(TAU * 701 * time) + .25 * Math.sin(TAU * 1193 * time)) * Math.exp(-18 * time);
const onset = Math.min(1, time / .002);
output[repeat * readFrames + frame] = .2 * onset * strike / 1.8;
}
return output;
}
export function matchPeakGain(samples: Float32Array, peak = 0.18) {
let current = 0;
for (const sample of samples) current = Math.max(current, Math.abs(sample));
if (!current) return samples.slice();
return samples.map((sample) => sample * peak / current);
}
export function complementaryCrossfadeGains(frame: number, frames: number) {
if (!Number.isInteger(frame) || !Number.isInteger(frames) || frames < 2 || frame < 0 || frame >= frames) throw new RangeError('Crossfade frame is outside the fade');
const incoming = frame / (frames - 1);
return { outgoing: 1 - incoming, incoming };
}
function spliceSlices() {
const frames = Math.round(0.12 * SAMPLE_RATE);
const left = new Float32Array(frames);
const right = new Float32Array(frames);
for (let frame = 0; frame < frames; frame++) {
const time = frame / SAMPLE_RATE;
left[frame] = .16 * Math.sin(TAU * 225 * time) * Math.min(1, time / .005);
right[frame] = .16 * Math.sin(TAU * 330 * time + Math.PI / 2) * Math.min(1, (frames - 1 - frame) / (.005 * SAMPLE_RATE));
}
return { left, right };
}
export function renderSplice(crossfadeFrames = 0) {
if (!Number.isFinite(crossfadeFrames) || crossfadeFrames < 0) throw new RangeError('Crossfade length must be non-negative');
const { left, right } = spliceSlices();
if (crossfadeFrames <= 0) {
const output = new Float32Array(left.length + right.length);
output.set(left); output.set(right, left.length);
return output;
}
const frames = Math.max(2, Math.min(Math.round(crossfadeFrames), left.length, right.length));
const output = new Float32Array(left.length + right.length - frames);
output.set(left.subarray(0, left.length - frames));
for (let frame = 0; frame < frames; frame++) {
const gains = complementaryCrossfadeGains(frame, frames);
output[left.length - frames + frame] = gains.outgoing * left[left.length - frames + frame] + gains.incoming * right[frame];
}
output.set(right.subarray(frames), left.length);
return output;
}
export function firFilter(input: ArrayLike<number>, taps: ArrayLike<number> = FIR_TAPS) {
if (!taps.length) throw new RangeError('FIR needs at least one tap');
const output = new Float32Array(input.length + taps.length - 1);
for (let frame = 0; frame < output.length; frame++) for (let tap = 0; tap < taps.length; tap++) {
const source = frame - tap;
if (source >= 0 && source < input.length) output[frame] += taps[tap] * input[source];
}
return output;
}
function edgeGain(frame: number, frames: number, fade = 480) {
return Math.max(0, Math.min(1, frame / fade, (frames - 1 - frame) / fade));
}
function sourceTone() {
const frames = SAMPLE_RATE / 2;
const output = new Float32Array(frames);
for (let frame = 0; frame < frames; frame++) output[frame] = .16 * edgeGain(frame, frames) * Math.sin(TAU * 440 * frame / SAMPLE_RATE);
return output;
}
export function renderTransformation(kind: Transformation) {
const source = sourceTone();
if (kind === 'fir') return { source, control: Float32Array.from(FIR_TAPS), output: firFilter(source), description: 'The impulse response is [0.25, 0.5, 0.25]. The output is the weighted current sample and two delays.' };
const control = new Float32Array(source.length);
const output = new Float32Array(source.length);
for (let frame = 0; frame < source.length; frame++) {
if (kind === 'envelope') control[frame] = Math.min(1, frame / 4_800, (source.length - 1 - frame) / 4_800);
else control[frame] = Math.cos(TAU * 110 * frame / SAMPLE_RATE);
if (kind === 'am') output[frame] = source[frame] * (.5 + .375 * control[frame]);
else if (kind === 'phase') output[frame] = .16 * edgeGain(frame, source.length) * Math.sin(TAU * 440 * frame / SAMPLE_RATE + control[frame]);
else output[frame] = source[frame] * control[frame];
}
const description = {
envelope: 'A unitless envelope rises, holds, and falls. Multiplication shapes the 440 Hz source to zero at both endpoints.',
am: 'Amplitude modulation uses 0.5[1 + 0.75m(t)]c(t): depth 0.75 plus a final 0.5 safety gain. The 440 Hz carrier remains beside 330 Hz and 550 Hz sidebands.',
ring: 'Ring modulation directly multiplies a 440 Hz carrier by a 110 Hz modulator. The ideal output contains 330 Hz and 550 Hz, but neither input frequency.',
phase: 'Phase modulation adds a 110 Hz modulator to the 440 Hz carrier phase with a one-radian index. A fixed phase offset would only move the wave in time; this changing offset creates multiple sidebands.',
} as const;
return { source, control, output, description: description[kind] };
}
export function renderCologneChain(settings: CologneChainSettings) {
const { source: sourceKind, processor, gateSeconds, attackSeconds, releaseSeconds, modulatorHz, mix } = settings;
if (![gateSeconds, attackSeconds, releaseSeconds, modulatorHz, mix].every(Number.isFinite)) throw new RangeError('Chain controls must be finite');
if (gateSeconds <= 0 || attackSeconds < 0 || releaseSeconds < 0 || modulatorHz <= 0 || mix < 0 || mix > 1) throw new RangeError('Chain controls are outside their safe range');
const frames = Math.round(1.4 * SAMPLE_RATE);
const gateOnSeconds = .1;
const gateOffSeconds = gateOnSeconds + gateSeconds;
if (gateOffSeconds + releaseSeconds > frames / SAMPLE_RATE) throw new RangeError('Gate and release must fit inside the render');
const source = new Float32Array(frames);
const gate = new Float32Array(frames);
const envelope = new Float32Array(frames);
const modulator = new Float32Array(frames);
const output = new Float32Array(frames);
let noiseState = 0x12345678;
let filtered = 0;
const filterAlpha = 1 - Math.exp(-TAU * 1_200 / SAMPLE_RATE);
const levelAtGateOff = attackSeconds ? Math.min(1, gateSeconds / attackSeconds) : 1;
for (let frame = 0; frame < frames; frame++) {
const time = frame / SAMPLE_RATE;
if (sourceKind === 'tone') source[frame] = .12 * Math.sin(TAU * 440 * time);
else if (sourceKind === 'pulse') {
for (const partial of PULSE_PARTIALS) source[frame] += .068 * Math.sin(TAU * 440 * partial * time) / partial;
} else {
noiseState ^= noiseState << 13; noiseState ^= noiseState >>> 17; noiseState ^= noiseState << 5;
source[frame] = .12 * (2 * (noiseState >>> 0) / 0xffffffff - 1);
}
const isOpen = time >= gateOnSeconds && time < gateOffSeconds;
gate[frame] = isOpen ? 1 : 0;
if (isOpen) envelope[frame] = attackSeconds ? Math.min(1, (time - gateOnSeconds) / attackSeconds) : 1;
else if (time >= gateOffSeconds && releaseSeconds) envelope[frame] = levelAtGateOff * Math.max(0, 1 - (time - gateOffSeconds) / releaseSeconds);
modulator[frame] = Math.cos(TAU * modulatorHz * time);
const shaped = source[frame] * envelope[frame];
if (processor === 'filter') {
filtered += filterAlpha * (shaped - filtered);
output[frame] = mix * filtered;
} else output[frame] = mix * shaped * (processor === 'ring' ? modulator[frame] : 1);
}
return { source, gate, envelope, modulator, output, gateOnSeconds, gateOffSeconds };
}
export function ringModulationComponents(carrierHz: number, modulatorHz: number) {
return { differenceHz: Math.abs(carrierHz - modulatorHz), sumHz: carrierHz + modulatorHz };
}
function validateSchedule(events: readonly PaperRollEvent[]) {
if (events.length !== PAPER_ROLL_FREQUENCIES.length) throw new RangeError('The paper roll has exactly five events');
for (const event of events) if (!Number.isFinite(event.start) || !Number.isFinite(event.duration) || event.start < 0 || event.duration <= 0 || event.start + event.duration > 6) throw new RangeError('Event times must be positive and remain within six seconds');
}
export function scheduleMetrics(events: readonly PaperRollEvent[]) {
validateSchedule(events);
const overlapPairs: string[] = [];
for (let first = 0; first < events.length; first++) for (let second = first + 1; second < events.length; second++) {
if (events[first].start < events[second].start + events[second].duration && events[second].start < events[first].start + events[first].duration) overlapPairs.push(`events ${first + 1} and ${second + 1}`);
}
return { totalDuration: Math.max(...events.map((event) => event.start + event.duration)), overlapPairs };
}
export function renderPaperRoll(events: readonly PaperRollEvent[]) {
const { totalDuration } = scheduleMetrics(events);
const output = new Float32Array(Math.ceil(totalDuration * SAMPLE_RATE));
events.forEach((event, index) => {
const start = Math.round(event.start * SAMPLE_RATE);
const frames = Math.round(event.duration * SAMPLE_RATE);
const fade = Math.min(240, Math.floor((frames - 1) / 2));
for (let frame = 0; frame < frames; frame++) {
const envelope = fade ? Math.min(1, frame / fade, (frames - 1 - frame) / fade) : 0;
const time = frame / SAMPLE_RATE;
const ring = event.ring ? Math.cos(TAU * 110 * time) : 1;
output[start + frame] += .055 * envelope * Math.sin(TAU * PAPER_ROLL_FREQUENCIES[index] * time) * ring;
}
});
return output.map((sample) => .18 * Math.tanh(sample / .18));
}
export function renderFault(kind: StudioFault) {
if (kind === 'splice') return renderSplice(0);
if (kind === 'sidebands') return renderTransformation('ring').output;
if (kind === 'overlap') {
const faulty = DEFAULT_PAPER_ROLL.map((event) => ({ ...event }));
faulty[1].start = .35;
return renderPaperRoll(faulty);
}
const truncated = renderSyntheticStrike(1, .5).slice(0, SAMPLE_RATE / 4);
const fade = 240;
for (let frame = 0; frame < fade; frame++) truncated[truncated.length - 1 - frame] *= frame / fade;
return truncated;
}
export function renderFaultRepair(kind: StudioFault) {
if (kind === 'splice') return renderSplice(Math.round(.008 * SAMPLE_RATE));
if (kind === 'sidebands') return renderTransformation('ring').source;
if (kind === 'overlap') return renderPaperRoll(DEFAULT_PAPER_ROLL);
return renderSyntheticStrike(1, .5);
}
Check the signal model
- A 440 Hz, two-second tape plays at . Find frequency, duration, and semitone shift.
- A 12,000-sample clip at 48 kHz is played at . Find output samples and duration.
- Slices contain 4000, 2500, and 6000 samples. Find concatenated length before crossfades.
- For and input , calculate the first three output samples.
- Multiply 700 and 130 Hz cosines. Name the two output frequencies.
- Explain why sample reversal changes an attack differently from reversing event order.
- For every Equation 3.1 through 3.6 visual, identify axes, fixed parameters, and one visible prediction.
RCA Mark II: store parameter choices
Olson and Belar’s system read coded paper that selected tone, amplitude, spectrum, rise, duration, decay, vibrato, portamento, and changes during an event. The punched control was discrete and programmatic. The audio path remained electronic and analog (Olson and Belar 1958, 1955). The Mark II was built in the late 1950s and installed at the Columbia-Princeton center around 1959. Institutional sources attach different 1957 to 1959 dates to construction, funding, and installation, so false precision would mislead (Columbia University Computer Music Center n.d.; Columbia University Libraries n.d.).
Long description. A large room-sized electronic system fills racks along a wall. Panels contain controls, meters, patch points, and paper-control machinery. The scale makes clear that stored instructions controlled an analog production system rather than software audio.
Image credit and licence. Finnianhughes101, own photograph, CC0 1.0 via Wikimedia Commons (Finnianhughes101 2024).
Long description. Composer parameter plan leads to punched-paper code and a reader. The reader controls oscillators plus envelope and spectrum stages, whose result is recorded.
Diagram provenance. Author-generated from Olson and Belar’s patent and paper (Olson and Belar 1958, 1955).
Do not describe the machine as understanding music. People encoded choices. The reader executed them. The output was recorded.
Section II · Practice
Use the modelCalculate, listen, build, diagnose, and check your understanding.Practice: a project-authored paper-roll étude
Long description. Five horizontal lanes show events beginning at exact sample positions. Each block states its oscillator frequency. In the default roll each event meets the next without overlap; the editable browser roll reports any overlap introduced by a changed start or duration.
Figure provenance. Author-generated from the same five-event schedule used by the browser paper-roll playground.
The study uses five original events. No melody or historical recording is copied. Read the roll before listening:
- predict the total duration from the final start plus duration;
- identify the two overlapping boundaries;
- mark which events use ring modulation;
- compare the forward render with its reversed splice;
- describe which information belongs to source, transformation, and schedule.
LAB 03
Edit a fixed trace and a five-event paper roll
Compare matched-peak original and transformed traces, then edit the chapter's fixed five-event schedule. This is a teaching roll, not a DAW or general scheduler.
Hearing safety. Lower device volume before playback. Fault examples contain deliberate discontinuities, but every rendered signal is conservatively bounded.
Rate, repetition, and direction
Pitch ratio: 1×Trace duration: 250 msBoth A/B renders are peak-matched to 0.18.
Four forward repeats at normal rate.
Editable five-event paper roll
Frequencies stay fixed at 220, 330, 247, 440, and 294 Hz. Edit only start, duration, and the ring-modulation flag. Event sums pass through a fixed tanh safety bound before playback.
| Event | Frequency | Start (s) | Duration (s) | Ring modulation |
|---|---|---|---|---|
| Event 1 | 220 Hz | |||
| Event 2 | 330 Hz | |||
| Event 3 | 247 Hz | |||
| Event 4 | 440 Hz | |||
| Event 5 | 294 Hz |
Total duration: 2.25 sOverlaps: none
Audible fault examples
Try these checks
- Choose half speed. Predict pitch and duration before reading the values.
- Move Event 2 before 0.5 s. Identify the reported overlap and listen at low level.
- Toggle each ring flag and name source, stored control, and rendered output.
- Use each fault's repair guidance to state the invariant that was broken.
Read the exact TypeScript running this lab
This deterministic browser model implements the trace, schedule, transformations, and faults.
export const SAMPLE_RATE = 48_000;
const TAU = 2 * Math.PI;
const FIR_TAPS = [0.25, 0.5, 0.25] as const;
const PULSE_PARTIALS = [1, 3, 5, 7, 9] as const;
export type Transformation = 'envelope' | 'fir' | 'am' | 'ring' | 'phase';
export type CologneSource = 'tone' | 'pulse' | 'noise';
export type CologneProcessor = 'bypass' | 'filter' | 'ring';
export type StudioFault = 'splice' | 'rate' | 'sidebands' | 'overlap';
export interface PaperRollEvent { start: number; duration: number; ring: boolean }
export interface CologneChainSettings {
source: CologneSource;
processor: CologneProcessor;
gateSeconds: number;
attackSeconds: number;
releaseSeconds: number;
modulatorHz: number;
mix: number;
}
export const PAPER_ROLL_FREQUENCIES = [220, 330, 247, 440, 294] as const;
export const DEFAULT_PAPER_ROLL: PaperRollEvent[] = [
{ start: 0, duration: 0.5, ring: false },
{ start: 0.5, duration: 0.25, ring: true },
{ start: 0.75, duration: 0.5, ring: false },
{ start: 1.25, duration: 0.25, ring: true },
{ start: 1.5, duration: 0.75, ring: false },
];
export const faultGuidance: Record<StudioFault, string> = {
splice: 'The hard boundary jumps between sample values. Move the cut to a zero crossing or add a short complementary crossfade.',
rate: 'The faulty half-speed copy was truncated to the original duration. Derive duration as T divided by rate, so half speed lasts twice as long.',
sidebands: 'Ring multiplication replaces the ideal carrier with difference and sum components. Bypass the multiplier when 330 and 550 Hz were not intended.',
overlap: 'Event 2 starts before event 1 ends, so their levels add. Repair the stored start time or deliberately rebalance an intended overlap.',
};
export function tapeRate(originalHz: number, originalSeconds: number, rate: number) {
if (!Number.isFinite(rate) || rate <= 0) throw new RangeError('Rate must be positive');
return { frequencyHz: originalHz * rate, durationSeconds: originalSeconds / rate, semitones: 12 * Math.log2(rate) };
}
export function renderSyntheticStrike(repeats = 1, rate = 1, reverse = false) {
const sourceFrames = Math.round(0.25 * SAMPLE_RATE);
const readFrames = Math.floor((sourceFrames - 1) / rate) + 1;
const output = new Float32Array(readFrames * repeats);
for (let repeat = 0; repeat < repeats; repeat++) for (let frame = 0; frame < readFrames; frame++) {
const read = reverse ? sourceFrames - 1 - frame * rate : frame * rate;
const position = Math.max(0, Math.min(sourceFrames - 1, read));
const time = position / SAMPLE_RATE;
const strike = (Math.sin(TAU * 317 * time) + .55 * Math.sin(TAU * 701 * time) + .25 * Math.sin(TAU * 1193 * time)) * Math.exp(-18 * time);
const onset = Math.min(1, time / .002);
output[repeat * readFrames + frame] = .2 * onset * strike / 1.8;
}
return output;
}
export function matchPeakGain(samples: Float32Array, peak = 0.18) {
let current = 0;
for (const sample of samples) current = Math.max(current, Math.abs(sample));
if (!current) return samples.slice();
return samples.map((sample) => sample * peak / current);
}
export function complementaryCrossfadeGains(frame: number, frames: number) {
if (!Number.isInteger(frame) || !Number.isInteger(frames) || frames < 2 || frame < 0 || frame >= frames) throw new RangeError('Crossfade frame is outside the fade');
const incoming = frame / (frames - 1);
return { outgoing: 1 - incoming, incoming };
}
function spliceSlices() {
const frames = Math.round(0.12 * SAMPLE_RATE);
const left = new Float32Array(frames);
const right = new Float32Array(frames);
for (let frame = 0; frame < frames; frame++) {
const time = frame / SAMPLE_RATE;
left[frame] = .16 * Math.sin(TAU * 225 * time) * Math.min(1, time / .005);
right[frame] = .16 * Math.sin(TAU * 330 * time + Math.PI / 2) * Math.min(1, (frames - 1 - frame) / (.005 * SAMPLE_RATE));
}
return { left, right };
}
export function renderSplice(crossfadeFrames = 0) {
if (!Number.isFinite(crossfadeFrames) || crossfadeFrames < 0) throw new RangeError('Crossfade length must be non-negative');
const { left, right } = spliceSlices();
if (crossfadeFrames <= 0) {
const output = new Float32Array(left.length + right.length);
output.set(left); output.set(right, left.length);
return output;
}
const frames = Math.max(2, Math.min(Math.round(crossfadeFrames), left.length, right.length));
const output = new Float32Array(left.length + right.length - frames);
output.set(left.subarray(0, left.length - frames));
for (let frame = 0; frame < frames; frame++) {
const gains = complementaryCrossfadeGains(frame, frames);
output[left.length - frames + frame] = gains.outgoing * left[left.length - frames + frame] + gains.incoming * right[frame];
}
output.set(right.subarray(frames), left.length);
return output;
}
export function firFilter(input: ArrayLike<number>, taps: ArrayLike<number> = FIR_TAPS) {
if (!taps.length) throw new RangeError('FIR needs at least one tap');
const output = new Float32Array(input.length + taps.length - 1);
for (let frame = 0; frame < output.length; frame++) for (let tap = 0; tap < taps.length; tap++) {
const source = frame - tap;
if (source >= 0 && source < input.length) output[frame] += taps[tap] * input[source];
}
return output;
}
function edgeGain(frame: number, frames: number, fade = 480) {
return Math.max(0, Math.min(1, frame / fade, (frames - 1 - frame) / fade));
}
function sourceTone() {
const frames = SAMPLE_RATE / 2;
const output = new Float32Array(frames);
for (let frame = 0; frame < frames; frame++) output[frame] = .16 * edgeGain(frame, frames) * Math.sin(TAU * 440 * frame / SAMPLE_RATE);
return output;
}
export function renderTransformation(kind: Transformation) {
const source = sourceTone();
if (kind === 'fir') return { source, control: Float32Array.from(FIR_TAPS), output: firFilter(source), description: 'The impulse response is [0.25, 0.5, 0.25]. The output is the weighted current sample and two delays.' };
const control = new Float32Array(source.length);
const output = new Float32Array(source.length);
for (let frame = 0; frame < source.length; frame++) {
if (kind === 'envelope') control[frame] = Math.min(1, frame / 4_800, (source.length - 1 - frame) / 4_800);
else control[frame] = Math.cos(TAU * 110 * frame / SAMPLE_RATE);
if (kind === 'am') output[frame] = source[frame] * (.5 + .375 * control[frame]);
else if (kind === 'phase') output[frame] = .16 * edgeGain(frame, source.length) * Math.sin(TAU * 440 * frame / SAMPLE_RATE + control[frame]);
else output[frame] = source[frame] * control[frame];
}
const description = {
envelope: 'A unitless envelope rises, holds, and falls. Multiplication shapes the 440 Hz source to zero at both endpoints.',
am: 'Amplitude modulation uses 0.5[1 + 0.75m(t)]c(t): depth 0.75 plus a final 0.5 safety gain. The 440 Hz carrier remains beside 330 Hz and 550 Hz sidebands.',
ring: 'Ring modulation directly multiplies a 440 Hz carrier by a 110 Hz modulator. The ideal output contains 330 Hz and 550 Hz, but neither input frequency.',
phase: 'Phase modulation adds a 110 Hz modulator to the 440 Hz carrier phase with a one-radian index. A fixed phase offset would only move the wave in time; this changing offset creates multiple sidebands.',
} as const;
return { source, control, output, description: description[kind] };
}
export function renderCologneChain(settings: CologneChainSettings) {
const { source: sourceKind, processor, gateSeconds, attackSeconds, releaseSeconds, modulatorHz, mix } = settings;
if (![gateSeconds, attackSeconds, releaseSeconds, modulatorHz, mix].every(Number.isFinite)) throw new RangeError('Chain controls must be finite');
if (gateSeconds <= 0 || attackSeconds < 0 || releaseSeconds < 0 || modulatorHz <= 0 || mix < 0 || mix > 1) throw new RangeError('Chain controls are outside their safe range');
const frames = Math.round(1.4 * SAMPLE_RATE);
const gateOnSeconds = .1;
const gateOffSeconds = gateOnSeconds + gateSeconds;
if (gateOffSeconds + releaseSeconds > frames / SAMPLE_RATE) throw new RangeError('Gate and release must fit inside the render');
const source = new Float32Array(frames);
const gate = new Float32Array(frames);
const envelope = new Float32Array(frames);
const modulator = new Float32Array(frames);
const output = new Float32Array(frames);
let noiseState = 0x12345678;
let filtered = 0;
const filterAlpha = 1 - Math.exp(-TAU * 1_200 / SAMPLE_RATE);
const levelAtGateOff = attackSeconds ? Math.min(1, gateSeconds / attackSeconds) : 1;
for (let frame = 0; frame < frames; frame++) {
const time = frame / SAMPLE_RATE;
if (sourceKind === 'tone') source[frame] = .12 * Math.sin(TAU * 440 * time);
else if (sourceKind === 'pulse') {
for (const partial of PULSE_PARTIALS) source[frame] += .068 * Math.sin(TAU * 440 * partial * time) / partial;
} else {
noiseState ^= noiseState << 13; noiseState ^= noiseState >>> 17; noiseState ^= noiseState << 5;
source[frame] = .12 * (2 * (noiseState >>> 0) / 0xffffffff - 1);
}
const isOpen = time >= gateOnSeconds && time < gateOffSeconds;
gate[frame] = isOpen ? 1 : 0;
if (isOpen) envelope[frame] = attackSeconds ? Math.min(1, (time - gateOnSeconds) / attackSeconds) : 1;
else if (time >= gateOffSeconds && releaseSeconds) envelope[frame] = levelAtGateOff * Math.max(0, 1 - (time - gateOffSeconds) / releaseSeconds);
modulator[frame] = Math.cos(TAU * modulatorHz * time);
const shaped = source[frame] * envelope[frame];
if (processor === 'filter') {
filtered += filterAlpha * (shaped - filtered);
output[frame] = mix * filtered;
} else output[frame] = mix * shaped * (processor === 'ring' ? modulator[frame] : 1);
}
return { source, gate, envelope, modulator, output, gateOnSeconds, gateOffSeconds };
}
export function ringModulationComponents(carrierHz: number, modulatorHz: number) {
return { differenceHz: Math.abs(carrierHz - modulatorHz), sumHz: carrierHz + modulatorHz };
}
function validateSchedule(events: readonly PaperRollEvent[]) {
if (events.length !== PAPER_ROLL_FREQUENCIES.length) throw new RangeError('The paper roll has exactly five events');
for (const event of events) if (!Number.isFinite(event.start) || !Number.isFinite(event.duration) || event.start < 0 || event.duration <= 0 || event.start + event.duration > 6) throw new RangeError('Event times must be positive and remain within six seconds');
}
export function scheduleMetrics(events: readonly PaperRollEvent[]) {
validateSchedule(events);
const overlapPairs: string[] = [];
for (let first = 0; first < events.length; first++) for (let second = first + 1; second < events.length; second++) {
if (events[first].start < events[second].start + events[second].duration && events[second].start < events[first].start + events[first].duration) overlapPairs.push(`events ${first + 1} and ${second + 1}`);
}
return { totalDuration: Math.max(...events.map((event) => event.start + event.duration)), overlapPairs };
}
export function renderPaperRoll(events: readonly PaperRollEvent[]) {
const { totalDuration } = scheduleMetrics(events);
const output = new Float32Array(Math.ceil(totalDuration * SAMPLE_RATE));
events.forEach((event, index) => {
const start = Math.round(event.start * SAMPLE_RATE);
const frames = Math.round(event.duration * SAMPLE_RATE);
const fade = Math.min(240, Math.floor((frames - 1) / 2));
for (let frame = 0; frame < frames; frame++) {
const envelope = fade ? Math.min(1, frame / fade, (frames - 1 - frame) / fade) : 0;
const time = frame / SAMPLE_RATE;
const ring = event.ring ? Math.cos(TAU * 110 * time) : 1;
output[start + frame] += .055 * envelope * Math.sin(TAU * PAPER_ROLL_FREQUENCIES[index] * time) * ring;
}
});
return output.map((sample) => .18 * Math.tanh(sample / .18));
}
export function renderFault(kind: StudioFault) {
if (kind === 'splice') return renderSplice(0);
if (kind === 'sidebands') return renderTransformation('ring').output;
if (kind === 'overlap') {
const faulty = DEFAULT_PAPER_ROLL.map((event) => ({ ...event }));
faulty[1].start = .35;
return renderPaperRoll(faulty);
}
const truncated = renderSyntheticStrike(1, .5).slice(0, SAMPLE_RATE / 4);
const fade = 240;
for (let frame = 0; frame < fade; frame++) truncated[truncated.length - 1 - frame] *= frame / fade;
return truncated;
}
export function renderFaultRepair(kind: StudioFault) {
if (kind === 'splice') return renderSplice(Math.round(.008 * SAMPLE_RATE));
if (kind === 'sidebands') return renderTransformation('ring').source;
if (kind === 'overlap') return renderPaperRoll(DEFAULT_PAPER_ROLL);
return renderSyntheticStrike(1, .5);
}
Browser lab 3: make edits and stored control audible
Use the studio lab on this page. The Web Audio implementation performs bounded rate change, reversal, envelope shaping, filtering, ring modulation, and event scheduling without a local toolchain.
- Verify half and double rate lengths by hand.
- Trace the first three FIR outputs for an impulse.
- Change one event start time and predict total length.
- Remove ring modulation and predict which sidebands disappear.
- Change crossfade length and inspect the splice boundary.
Challenge: diagnose the edit
A student writes:
“Musique concrète began when tape was invented in 1948. Half-speed changes pitch but preserves duration. Cologne’s studio was a DAW. An RCA paper roll contained digital audio.”
Correct every claim. Your answer must distinguish disc from tape, rate coupling, manual signal chains, stored control, and analog audio.
Chapter 3 readiness gate
- Why can fixation and repetition change how an event is heard?
- What happens to frequency and duration at double speed?
- What produces a splice click, and what does a crossfade change?
- How do a binary gate and a changing amplitude envelope differ?
- How do envelope multiplication and FIR filtering differ?
- Which frequencies result from ring-modulating 440 Hz by 110 Hz?
- Why does conventional AM retain a carrier while ideal ring modulation suppresses it?
- Why is a fixed phase offset not phase modulation?
- How do PCM and line coding perform different jobs?
- Contrast Schaeffer, Cologne, and RCA by material and control.
Chapter 3 invariants
- Schaeffer’s 1948 fixed-sound practice began on disc, not tape.
- Tape speed couples pitch and duration.
- Montage selects, orders, joins, and mixes fixed traces.
- A gate commands event activity; an envelope supplies a changing amplitude control.
- An envelope multiplies amplitude over time.
- A linear filter combines weighted delayed samples.
- Conventional AM retains the carrier; ideal ring multiplication suppresses it and creates sum and difference components.
- A fixed phase offset moves a wave; phase modulation changes the offset over time.
- PCM encodes quantized sample values; line coding maps bits to a physical transmission or storage waveform.
- Cologne’s studio was a manually operated signal chain.
- RCA stored parameter control while its audio path remained analog.
- The bundled strike and paper-roll study are synthetic project-authored teaching surrogates.
Chapter 3 glossary additions
| Term | Working definition |
|---|---|
| Automation | Stored or driven change of parameters over time. |
| Amplitude modulation (AM) | Multiplication by an offset modulating wave so the carrier remains in the ideal output. |
| Carrier | The signal whose amplitude, phase, or another parameter is changed by a modulator. |
| Envelope | A control trajectory multiplied with a signal’s amplitude. |
| Fixed trace | Sound preserved on a support so it can be replayed and edited. |
| Gate | A binary command stating when an event is active or inactive. |
| Impulse response | Output produced by a unit impulse, exposing a linear filter’s coefficients. |
| Line coding | Mapping bits to physical levels or transitions over time for storage or transmission. |
| Loop | A fixed segment repeated cyclically. |
| Modulating wave | The signal that drives a change in a carrier or source. |
| Modulation depth or index | A number controlling how strongly the modulator changes the carrier. |
| Montage | Selection and ordering of fixed segments into a new timeline. |
| Musique concrète | Practice beginning with fixed audible material and transformations of its trace. |
| Phase modulation (PM) | A changing signal added to carrier phase; its index is measured in radians. |
| Pulse-code modulation (PCM) | Representation of quantized sample values as code words. |
| Punched-paper control | Coded holes that select stored event and sound parameters. |
| Ring modulation | Multiplication that creates sum and difference spectral components. |
| Splice | Boundary joining two fixed segments. |
IMPLEMENTATION NOTEBOOK
Chapter 3 source and generated output
Each complete website-owned source appears beside its deterministic output. Hashes are recorded in the implementation manifest.
SOURCE AND OUTPUT
Disc support, tape rate, envelope, ring, and audio
Project-authored reconstructions and synthetic surrogates only. No historical audio or purported 1948 studio reconstruction is embedded.
Output
Source
ch03_studio.py
assets/figures/src/ch03_studio.pyPython
#!/usr/bin/env python3
"""Generate Chapter 3 figures and synthetic teaching-surrogate audio."""
from pathlib import Path
import wave
import numpy as np
import matplotlib.pyplot as plt
ROOT = Path(__file__).resolve().parents[3]
FIG = ROOT / "assets/figures/svg"
AUDIO = ROOT / "assets/audio/ch03"
FIG.mkdir(parents=True, exist_ok=True)
AUDIO.mkdir(parents=True, exist_ok=True)
FS = 48_000
def save(name):
plt.tight_layout(); plt.savefig(FIG / name, format="svg", metadata={"Date": None}); plt.close()
def synthetic_strike(seconds=.25):
t=np.arange(round(seconds*FS))/FS
# Project-authored deterministic surrogate, not a historical or field recording.
x=(np.sin(2*np.pi*317*t)+.55*np.sin(2*np.pi*701*t)+.25*np.sin(2*np.pi*1193*t))*np.exp(-18*t)
x*=np.minimum(1,t/.002)
return .65*x/np.max(np.abs(x))
x=synthetic_strike(); t=np.arange(len(x))/FS*1000
fig,ax=plt.subplots(figsize=(9,4)); ax.plot(t,x,color="#7f1d1d"); ax.set(xlabel="Time (ms)",ylabel="Amplitude",title="Synthetic strike teaching surrogate: one fixed trace"); ax.grid(alpha=.25); save("ch03-loop-to-object.svg")
fig,axes=plt.subplots(3,1,figsize=(9,7))
for ax,r in zip(axes,[.5,1,2]):
idx=np.minimum((np.arange(max(1,int(len(x)/r)))*r).astype(int),len(x)-1)
y=x[idx]; ax.plot(np.arange(len(y))/FS*1000,y,color="#0f6f70"); ax.set_title(f"rate r={r:g}: duration {len(y)/FS*1000:.0f} ms, pitch ratio {r:g}"); ax.set_ylabel("Amplitude")
axes[-1].set_xlabel("Time (ms)"); save("ch03-rate-triptych.svg")
env=np.exp(-8*np.arange(FS//2)/FS); rev=env[::-1]
fig,ax=plt.subplots(figsize=(9,4)); ax.plot(env,label="decay",color="#7f1d1d"); ax.plot(rev,label="reversed",color="#0f6f70"); ax.set(xlabel="Sample",ylabel="Amplitude",title="Reversal turns decay into approach"); ax.legend(); ax.grid(alpha=.25); save("ch03-reverse-envelope.svg")
events=[(0,24000,220),(24000,12000,330),(36000,24000,247),(60000,12000,440),(72000,36000,294)]
fig,ax=plt.subplots(figsize=(9,4));
for i,(start,duration,f) in enumerate(events): ax.broken_barh([(start/FS,duration/FS)],(i-.35,.7),facecolors="#0f6f70"); ax.text((start+duration/2)/FS,i,str(f)+" Hz",ha="center",va="center",color="white",fontsize=8)
ax.set(xlabel="Time (s)",ylabel="Event lane",yticks=range(5),title="Stored event controls: integer sample starts and durations"); save("ch03-paper-roll.svg")
# Concatenated audible comparison: single, four repeats, reverse, half and double rate.
clips=[x,np.tile(x,4),x[::-1]]
for r in [.5,2]:
pos=np.arange(max(1,int(len(x)/r)))*r; left=np.floor(pos).astype(int); right=np.minimum(left+1,len(x)-1); frac=pos-left; clips.append(x[left]*(1-frac)+x[right]*frac)
silence=np.zeros(round(.25*FS)); out=np.concatenate([v for pair in zip(clips,[silence]*len(clips)) for v in pair])
with wave.open(str(AUDIO/"ch03-studio-transformations.wav"),"wb") as w:
w.setparams((1,2,FS,len(out),"NONE","not compressed")); w.writeframes((np.clip(out,-1,1)*32767).astype("<i2").tobytes())
print("Generated Chapter 3 studio figures and synthetic-surrogate audio.")
ch03_foundations.py
assets/figures/src/ch03_foundations.pyPython
#!/usr/bin/env python3
"""Generate Chapter 3 disc, tape-rate, envelope, and ring examples."""
from pathlib import Path
import wave
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, FancyArrowPatch, Rectangle
ROOT = Path(__file__).resolve().parents[3]
FIG = ROOT / "assets/figures/svg"
AUDIO = ROOT / "assets/audio/ch03"
FIG.mkdir(parents=True, exist_ok=True)
AUDIO.mkdir(parents=True, exist_ok=True)
FS = 48_000
plt.rcParams["svg.hashsalt"] = "contrapunk-ch03-foundations-v1"
RED = "#7f1d1d"
TEAL = "#0f6f70"
GOLD = "#b07d21"
INK = "#211d1a"
def save(name):
plt.tight_layout()
path = FIG / name
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 write_wav(name, samples):
samples = np.asarray(samples, dtype=np.float64)
if not np.all(np.isfinite(samples)):
raise ValueError(f"{name} contains a non-finite sample")
if np.max(np.abs(samples), initial=0) > .180001:
raise ValueError(f"{name} exceeds the 0.18 peak limit")
pcm = np.round(np.clip(samples, -1, 1) * 32767).astype("<i2")
with wave.open(str(AUDIO / name), "wb") as output:
output.setparams((1, 2, FS, len(pcm), "NONE", "not compressed"))
output.writeframes(pcm.tobytes())
def edge_fade(samples, seconds=.01):
result = np.asarray(samples, dtype=np.float64).copy()
frames = min(round(seconds * FS), len(result) // 2)
if frames:
ramp = np.linspace(0, 1, frames, endpoint=False)
result[:frames] *= ramp
result[-frames:] *= ramp[::-1]
return result
def peak_scale(samples, peak=.16):
samples = np.asarray(samples, dtype=np.float64)
current = np.max(np.abs(samples), initial=0)
return samples.copy() if current == 0 else samples * peak / current
def synthetic_strike(seconds=.25):
time = np.arange(round(seconds * FS)) / FS
signal = (np.sin(2*np.pi*317*time) + .55*np.sin(2*np.pi*701*time) + .25*np.sin(2*np.pi*1193*time)) * np.exp(-18*time)
signal *= np.minimum(1, time/.002)
return signal / np.max(np.abs(signal))
def rate_read(signal, rate):
positions = np.arange(int(np.floor((len(signal) - 1) / rate)) + 1) * rate
left = np.floor(positions).astype(int)
right = np.minimum(left + 1, len(signal) - 1)
fraction = positions - left
return signal[left] * (1 - fraction) + signal[right] * fraction
def adsr(duration, attack, decay, sustain, gate_off, release):
time = np.arange(round(duration * FS)) / FS
envelope = np.zeros_like(time)
attack_end = attack
decay_end = attack + decay
if attack > 0:
mask = time < attack_end
envelope[mask] = time[mask] / attack
else:
envelope[time < decay_end] = 1
if decay > 0:
mask = (time >= attack_end) & (time < decay_end)
envelope[mask] = 1 - (1 - sustain) * (time[mask] - attack_end) / decay
mask = (time >= decay_end) & (time < gate_off)
envelope[mask] = sustain
if release > 0:
mask = (time >= gate_off) & (time < gate_off + release)
envelope[mask] = sustain * (1 - (time[mask] - gate_off) / release)
return np.clip(envelope, 0, 1)
# The same fixed trace read at three rates. Only read rate changes.
strike = synthetic_strike()
for rate, name in [(.5, "half"), (1, "normal"), (2, "double")]:
write_wav(f"03-rate-{name}.wav", peak_scale(edge_fade(rate_read(strike, rate))))
# Project-authored reconstruction of ordinary and deliberately closed disc grooves.
fig, axes = plt.subplots(1, 3, figsize=(11, 3.8))
angle = np.linspace(0, 7*np.pi, 900)
radius = 1 - .025 * angle
axes[0].plot(radius*np.cos(angle), radius*np.sin(angle), color=INK, linewidth=1.6)
axes[0].add_patch(FancyArrowPatch((-.63, .45), (-.54, .35), arrowstyle="->", mutation_scale=12, color=RED))
axes[0].set_title("Ordinary groove")
axes[0].text(0, -1.2, "stylus advances inward", ha="center", color=TEAL)
axes[1].add_patch(Circle((0, 0), .72, fill=False, linewidth=2.2, color=RED))
axes[1].add_patch(Circle((0, 0), .48, fill=False, linewidth=.7, color="#a99d92"))
axes[1].plot([.72], [0], "o", color=INK)
axes[1].add_patch(FancyArrowPatch((.35, .62), (-.1, .71), connectionstyle="arc3,rad=.3", arrowstyle="->", mutation_scale=12, color=TEAL))
axes[1].set_title("Closed groove")
axes[1].text(0, -1.2, "stylus returns to the same circle", ha="center", color=TEAL)
for axis in axes[:2]:
axis.set_aspect("equal")
axis.set_xlim(-1.25, 1.25)
axis.set_ylim(-1.35, 1.15)
axis.axis("off")
for index in range(4):
axes[2].add_patch(Rectangle((index, .3), .86, .42, facecolor=[RED, TEAL, GOLD, RED][index], alpha=.9))
axes[2].text(index + .43, .51, "same\ntrace", ha="center", va="center", color="white", fontsize=9)
axes[2].set(xlim=(-.1, 4), ylim=(0, 1), title="Audible result")
axes[2].set_xlabel("time → repeated revolutions")
axes[2].set_yticks([])
axes[2].spines[["left", "right", "top"]].set_visible(False)
fig.suptitle("Disc-era repetition: the support determines the loop")
save("ch03-closed-groove-reconstruction.svg")
# Four fixed envelope hearings. Sustain is always a level; gate_off controls its duration.
envelope_specs = {
"onset": (.35, .12, .65, 1.2, .2),
"sustain": (.02, .1, .65, 1.65, .15),
"accent": (.01, .22, .35, 1.2, .2),
"release": (.01, .12, .6, .9, .9),
}
envelope_duration = 2.0
envelope_time = np.arange(round(envelope_duration * FS)) / FS
envelope_source = .16 * (np.sin(2*np.pi*220*envelope_time) + .22*np.sin(2*np.pi*440*envelope_time)) / 1.22
write_wav("03-envelope-source.wav", edge_fade(envelope_source))
fig, axes = plt.subplots(4, 1, figsize=(9, 7), sharex=True)
for axis, (name, (attack, decay_time, sustain, gate_off, release)) in zip(axes, envelope_specs.items()):
envelope = adsr(envelope_duration, attack, decay_time, sustain, gate_off, release)
write_wav(f"03-envelope-{name}.wav", envelope_source * envelope)
plot_time = np.unique([0, attack, attack + decay_time, gate_off, gate_off + release, envelope_duration])
plot_indices = np.minimum(np.round(plot_time * FS).astype(int), len(envelope) - 1)
plot_envelope = envelope[plot_indices]
axis.plot(plot_time, plot_envelope, color=TEAL, label="e(t)")
axis.fill_between(plot_time, -plot_envelope, plot_envelope, color=RED, alpha=.16, label="output bounds")
axis.axvline(gate_off, color=GOLD, linestyle="--", linewidth=1)
axis.set_ylim(-1.05, 1.05)
axis.set_ylabel(name)
axis.grid(alpha=.18)
axes[0].set_title("One source multiplied by four envelope contours")
axes[-1].set_xlabel("Time (s); dashed line is gate off")
save("ch03-envelope-roles.svg")
# Carrier, modulator, their linear sum, and their ring product.
ring_time = np.arange(FS) / FS
fade = np.ones_like(ring_time)
fade_frames = round(.01 * FS)
fade[:fade_frames] = np.linspace(0, 1, fade_frames, endpoint=False)
fade[-fade_frames:] = np.linspace(1, 0, fade_frames, endpoint=False)
carrier = .14 * np.cos(2*np.pi*440*ring_time) * fade
modulator = .14 * np.cos(2*np.pi*110*ring_time) * fade
linear_sum = .07 * (np.cos(2*np.pi*440*ring_time) + np.cos(2*np.pi*110*ring_time)) * fade
ring_product = .14 * np.cos(2*np.pi*440*ring_time) * np.cos(2*np.pi*110*ring_time) * fade
for name, signal in [("carrier", carrier), ("modulator", modulator), ("linear-sum", linear_sum), ("product", ring_product)]:
write_wav(f"03-ring-{name}.wav", signal)
ring_rows = [
("Carrier", carrier, [(440, 1)]),
("Modulator", modulator, [(110, 1)]),
("Add", linear_sum, [(110, .5), (440, .5)]),
("Multiply", ring_product, [(330, .5), (550, .5)]),
]
fig, axes = plt.subplots(4, 2, figsize=(10, 8), gridspec_kw={"width_ratios": [2.2, 1]})
window = ring_time < .04
for row, (label, signal, components) in enumerate(ring_rows):
axes[row, 0].plot(ring_time[window] * 1000, signal[window], color=RED if label == "Multiply" else TEAL)
axes[row, 0].set_ylabel(label)
axes[row, 0].grid(alpha=.18)
for frequency, magnitude in components:
axes[row, 1].vlines(frequency, 0, magnitude, color=RED if label == "Multiply" else TEAL, linewidth=3)
axes[row, 1].text(frequency, magnitude + .05, f"{frequency} Hz", ha="center", fontsize=9)
axes[row, 1].set(xlim=(0, 650), ylim=(0, 1.18), yticks=[])
axes[row, 1].grid(axis="x", alpha=.18)
axes[0, 0].set_title("First 40 ms")
axes[0, 1].set_title("Ideal one-sided components")
axes[-1, 0].set_xlabel("Time (ms)")
axes[-1, 1].set_xlabel("Frequency (Hz)")
fig.suptitle("Addition keeps 110 and 440 Hz; multiplication creates 330 and 550 Hz")
save("ch03-ring-step-by-step.svg")
print("Generated Chapter 3 disc, rate, envelope, and ring examples.")
SOURCE AND OUTPUT
Six equation visuals
One chapter-specific generator produces Equations 3.1 through 3.6.
Output
Source
formula_visuals_ch03.py
assets/figures/src/formula_visuals_ch03.pyPython
#!/usr/bin/env python3
"""Generate deterministic visuals for Chapter 3 equations."""
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
ROOT=Path(__file__).resolve().parents[3]; OUT=ROOT/"assets/figures/svg"; OUT.mkdir(parents=True,exist_ok=True)
RED="#7f1d1d"; TEAL="#0f6f70"
def save(name): plt.tight_layout(); plt.savefig(OUT/name,format="svg",metadata={"Date":None}); plt.close()
# 3.1 sampled source
fs=8000; f=500; n=np.arange(32); x=.8*np.sin(2*np.pi*f*n/fs+np.pi/6)
plt.figure(figsize=(9,4)); plt.stem(n,x,linefmt=RED,markerfmt="o",basefmt="k-"); plt.title("Eq. 3.1: A=.8, f=500 Hz, fs=8000 Hz, phase=π/6"); plt.xlabel("Sample n"); plt.ylabel("x[n]"); save("eq-3-1-sampled-source.svg")
# 3.2 rate law
r=np.linspace(.5,2,200); fig,ax=plt.subplots(1,2,figsize=(9,4)); ax[0].plot(r,r*440,color=RED); ax[0].plot(r,2/r,color=TEAL); ax[0].set(xlabel="Rate r",title="Frequency (Hz) and duration (s)"); ax[1].plot(r,12*np.log2(r),color=RED); ax[1].set(xlabel="Rate r",ylabel="Semitones",title="Pitch shift"); save("eq-3-2-tape-rate.svg")
# 3.3 montage
fig,ax=plt.subplots(figsize=(9,3)); colors=[RED,TEAL,"#b07d21"]; start=0
for i,(length,c) in enumerate(zip([4,3,5],colors),1): ax.broken_barh([(start,length)],(.25,.5),facecolor=c); ax.text(start+length/2,.5,f"slice {i}",ha="center",va="center",color="white"); start+=length
ax.set(xlim=(0,12),ylim=(0,1),yticks=[],xlabel="Output sample index",title="Eq. 3.3: selected slices concatenate in declared order"); save("eq-3-3-montage.svg")
# 3.4 envelope
n=np.arange(100); e=np.minimum(np.minimum(1,n/20),(99-n)/25).clip(0,1); x=np.sin(2*np.pi*n/20)
fig,ax=plt.subplots(figsize=(9,4)); ax.plot(n,x,color="#aaa",label="x[n]"); ax.plot(n,e*x,color=RED,label="e[n]x[n]"); ax.plot(n,e,color=TEAL,label="e[n]"); ax.legend(); ax.set(xlabel="Sample n",ylabel="Amplitude",title="Eq. 3.4: 20-sample attack, 25-sample release"); save("eq-3-4-envelope.svg")
# 3.5 FIR impulse
h=np.array([.25,.5,.25]); inp=np.r_[1.,np.zeros(7)]; out=np.convolve(inp,h)[:len(inp)]; fig,ax=plt.subplots(figsize=(9,4)); ax.stem(range(len(out)),out,linefmt=RED,markerfmt="o",basefmt="k-"); ax.set(xlabel="Sample n",ylabel="y[n]",title="Eq. 3.5: impulse through h=[.25,.5,.25]"); save("eq-3-5-fir.svg")
# 3.6 ring modulation
fig,ax=plt.subplots(figsize=(9,4)); ax.stem([330,550],[.5,.5],linefmt=RED,markerfmt="o",basefmt="k-"); ax.set(xlim=(250,630),xlabel="Frequency (Hz)",ylabel="Relative amplitude",title="Eq. 3.6: 440 Hz × 110 Hz gives 330 and 550 Hz"); save("eq-3-6-ring-modulation.svg")
print("Generated Chapter 3 formula visuals.")
SOURCE AND OUTPUT
Historical signal chains
Three tested Mermaid sources produce the diagrams shown here.
Output
Source
ch03-concrete-montage.mmd
assets/diagrams/src/ch03-concrete-montage.mmdMermaid
flowchart LR
A["Recorded event"] --> B["Disc or tape<br/>fixed trace"]
B --> C["Select and cut"]
C --> D["Loop, reverse,<br/>rate, filter"]
D --> E["Splice and mix"]
E --> F["Fixed composition"]
ch03-cologne-studio-chain.mmd
assets/diagrams/src/ch03-cologne-studio-chain.mmdMermaid
flowchart LR
A["Pure tone, pulse,<br/>or noise generator"] --> B["Gate or envelope"]
B --> C["Filter or<br/>ring modulator"]
C --> D["Mixer"]
D --> E["Tape record"]
E --> F["Cut, loop, reverse,<br/>or change speed"]
F --> D
ch03-rca-punched-control.mmd
assets/diagrams/src/ch03-rca-punched-control.mmdMermaid
flowchart LR
A["Composer parameter plan"] --> B["Punched-paper code"]
B --> C["Reader and<br/>control selection"]
C --> D["Oscillators"]
C --> E["Envelope and<br/>spectrum controls"]
D --> E
E --> F["Recorded output"]
Chapter 3 Answers and Fault Invariants
Chapter 3 mathematical-practice answers
- Hz, seconds, and semitones.
- Output length is samples and duration is 0.125 seconds.
- samples.
- The first outputs are 0.25, 0.5, and 0.25.
- Difference 570 Hz and sum 830 Hz.
- Sample reversal reverses each sound’s internal envelope and oscillation. Event-order reversal preserves each event internally but changes sequence.
- A valid answer records all stated parameters and links a visible feature to the equation, such as duration falling as rate rises or two ring-modulation stems appearing at sum and difference.
Chapter 3 readiness answers
- Fixation permits isolation and exact repetition. Repetition weakens the event’s one-time causal flow and exposes internal rhythm and color.
- Frequency doubles, duration halves, and pitch rises twelve semitones.
- A discontinuity between boundary samples can click. A crossfade replaces the abrupt jump with overlapping complementary gains.
- An envelope multiplies each sample by a time trajectory. An FIR adds weighted current and delayed samples.
- 330 and 550 Hz.
- Schaeffer manipulated fixed recorded traces, first on disc. Cologne built and transformed generated signals through manually patched equipment and tape. RCA stored event and sound parameters on punched paper while an analog electronic path produced recorded output.
Chapter 3 fault invariant
- Schaeffer’s 1948 experiments used phonograph discs; tape entered later.
- Half speed halves pitch frequency and doubles duration.
- Cologne required manual generation, patching, measurement, recording, and editing; it was not a DAW.
- RCA’s punched paper stored control codes, not digital audio samples. Its audio path was analog.