CONTRAPUNK
In this chapter 24 sections

Sound from Numbers

Section I · Theory

Build the model

Turn ordered numbers into timed sound, then organize the work into reusable signal processors.

Sixteen numbers, one tone

Read these values from left to right:

0, 0.707107, 1, 0.707107, 0, -0.707107, -1, -0.707107,
0, 0.707107, 1, 0.707107, 0, -0.707107, -1, -0.707107

If a system presents 8,000 values each second, this list lasts 2 milliseconds. It describes two cycles of a 1 kHz sine wave.

Sixteen sample stems repeat the values zero, 0.707, one, 0.707, zero, minus 0.707, minus one, and minus 0.707 twice.
Sixteen ordered values form two cycles when read at 8,000 samples per second.

Long description. Sample indices zero through fifteen form two identical groups of eight. Each group rises from zero through about 0.707 to one, returns through 0.707 to zero, then mirrors the motion below zero. The sequence repeats at index eight.

Figure provenance. Project-authored deterministic output from ch05_digital_audio.py.

The values alone are not yet sound. They become a timed signal only when their order, presentation rate, and amplitude meaning are known. A connecting line may help the eye, and an output system may reconstruct a continuous voltage, but the stored digital signal remains an ordered sequence.

A sample is one channel's amplitude value at one indexed instant. A sample-frame contains one simultaneous sample for every channel. Stereo at 48 kHz has 48,000 two-value frames per second. It does not have a 96 kHz time rate (MDN Web Docs, n.d.).

Question. How did a list of numbers become a practical musical instrument, and how can a browser carry the same idea?

Chapter contract

Prerequisites. Frequency, amplitude, phase, signal flow, and the difference between a physical sound and a mathematical model.

By the end of this chapter, you should be able to:

  • explain how a timed sequence of numbers can represent one channel of sound;
  • distinguish a sample from a sample-frame;
  • convert between sample index, seconds, and sample rate;
  • apply a strict Nyquist boundary and explain why equality is unsafe;
  • distinguish sampling from amplitude quantization;
  • calculate quantizer steps, buffer durations, and block schedules;
  • read a unit-generator graph as a graph of numerical work; and
  • preserve generator state across processing blocks.

This chapter stops before stored-cycle lookup, interpolation, Fourier transforms, and alias-fold calculations. Chapters 6 through 9 take those next steps.

Why computers made sound from numbers

At Bell Telephone Laboratories, Max Mathews worked on speech, hearing, and communication. Those fields needed precisely controlled sounds. A digital computer offered a general route: calculate amplitude numbers, convert them to electrical pulses, smooth the pulses, and drive a loudspeaker (Mathews 1963).

Mathews demonstrated MUSIC in 1957 with a short computer composition. The Computer History Museum identifies that experiment with an IBM 704 (Computer History Museum, n.d.). The period photograph below shows the same computer model at NACA Langley. It does not show the Bell Labs session.

Two operators stand at the control console of a room-sized IBM 704 computer installation.
An IBM 704 installation at NACA Langley on March 21, 1957.

Long description. A man and woman operate a central console surrounded by tall cabinets, tape drives, panels, and a card reader. The room-sized installation shows the physical machinery behind numerical computing in 1957. This is NACA's IBM 704, not the Bell Labs installation used by Mathews.

Image credit and rights. NASA image GPN-2000-001881 via Wikimedia Commons, public domain in the United States. Downloaded as the 1280-pixel derivative without alteration. NASA permits factual educational and editorial use without implied endorsement (NASA 1957; NASA, n.d.).

A rich sound could take the computer longer to calculate than the sound took to hear. Mathews described an offline rendering path: calculate samples, store them on digital magnetic tape, then replay them later at a steady rate. Offline means that computation and listening happen at different times.

Score and instrument descriptions lead to sample calculation, digital tape and buffering, conversion, filtering, and a loudspeaker.
The historical numbers-to-sound path separated sample calculation from steady playback.

Long description. A left-to-right chain starts with score note parameters and instrument unit generators. The computer calculates sample numbers. Digital tape and a buffer hold those numbers. A digital-to-analog converter turns them into voltage steps. A smoothing filter and loudspeaker produce the audible result.

Diagram provenance. Author-generated from Mathews's 1963 article and the 1969 MUSIC V account (Mathews 1963; Mathews et al. 1969).

No composer could type tens of thousands of amplitudes for every second. The MUSIC programs expanded compact scores and reusable instrument descriptions into full sample streams. By MUSIC V, instruments were graphs built from small procedures called unit generators. The documented 1967 to 1968 program used three passes: expand the score, order note records in time, then generate samples (Mathews et al. 1969). MUSIC-N is a later family label for MUSIC V and descendants such as Music-11, Csound, and cmusic. It is not the literal name of a sixth version (Dannenberg 1997).

Samples are values at scheduled instants

Picture a microphone voltage as a smooth curve through time. At every time t, the curve has one amplitude. Call that continuous-time model xc(t), where the subscript c means continuous. A sampler does not store the whole curve. It reads one value whenever its clock ticks.

Choose sample rate Fs in sample-frames per second. Give each clock tick an integer index n. Tick n occurs at time tn=n/Fs. The stored sample is the curve's amplitude at exactly that time:

x[n]=xc(nFs).(5.1)x[n]=x_c(n/F_s).\qquad(5.1)

Read Equation 5.1 from right to left: calculate the scheduled time n/Fs, visit the continuous curve there, then store that amplitude as x[n]. Parentheses mark a continuous function of time. Square brackets mark a discrete sequence addressed by integer index.

Concrete check. Let the curve be a 1 kHz sine and sample it at 8 kHz. Clock ticks are 125 microseconds apart. The first five reads are:

Index nTime n/FsStored sample x[n]
00 microseconds0
1125 microsecondsabout 0.707
2250 microseconds1
3375 microsecondsabout 0.707
4500 microseconds0

These are sample values for one channel. Stereo uses two values at each frame index, one per channel. Sampling has chosen when to read; quantization later chooses which numerical level can represent each read.

A continuous 1 kHz sine with sample stems at 8 kHz over two milliseconds.
Equation 5.1 for a 1 kHz sine sampled at 8 kHz from 0 through 2 milliseconds.

Long description. A two-cycle 1 kHz sine runs from zero to 2 milliseconds. Seventeen equally spaced stems include both plotted endpoints. The eight values in each cycle are zero, about 0.707, one, 0.707, zero, minus 0.707, minus one, and minus 0.707 before the pattern repeats.

Visual provenance. Deterministic output from formula_visuals_ch05.py with Fs=8000 Hz and a 1 kHz sine.

Sample rate sets the clock

The distance from one sample instant to the next is the reciprocal of the sample rate:

Δt=1Fs,tn=nΔt=nFs.(5.2)\Delta t=1/F_s,\quad t_n=n\Delta t=n/F_s.\qquad(5.2)

At 48 kHz, one interval lasts about 20.833 microseconds. A sequence with N frames uses indices zero through N1. Index 47,999 occurs at 0.999979 seconds, not at one second.

Five sample instants at 48 kHz separated by 20.833 microseconds.
Equation 5.2 at 48 kHz; adjacent sample instants are 20.833 microseconds apart.

Long description. Five sample instants numbered zero through four sit at 0, 20.833, 41.667, 62.5, and 83.333 microseconds. An arrow marks the 20.833-microsecond spacing between the first two instants.

Visual provenance. Deterministic output from formula_visuals_ch05.py at 48,000 frames per second.

The Nyquist boundary is strict

A sampled signal can uniquely represent a properly band-limited continuous model only when its highest frequency stays below half the sample rate. Band-limited means that no component exists above a declared maximum frequency. This chapter uses the strict condition:

Fs>2fmax,fmax<Fs2.(5.3)F_s>2f_{\max},\quad f_{\max}<F_s/2.\qquad(5.3)

Half the rate is the Nyquist frequency. Equality is excluded. At an 8 kHz rate, a zero-phase 4 kHz sine produces zeros at every sample instant. A 4 kHz cosine produces alternating plus one and minus one. Two samples per cycle do not identify arbitrary phase (Shannon 1949; Smith 2011).

At an 8 kHz sample rate, a 4 kHz sine yields zeros while a 4 kHz cosine alternates plus and minus one.
Equation 5.3 at an 8 kHz sample rate; 4 kHz is the unsafe equality boundary.

Long description. At the 4 kHz boundary, a zero-phase sine produces eight zero-valued samples while a cosine produces alternating plus-one and minus-one samples. A note classifies 3 kHz as safe, 4 kHz as equality, and 5 kHz as outside the condition.

Visual provenance. Deterministic output from formula_visuals_ch05.py with eight samples of each phase case.

Treat the boundary as a fence, not a playable target. Leave headroom. Chapter 9 will calculate and measure what happens after the condition fails.

The next playground isolates two reasons for the strict boundary. First, different continuous curves can meet at the same sample instants. Second, phase changes the values observed at equality. The experiment demonstrates ambiguity without introducing the alias-fold calculation reserved for Chapter 9.

BOUNDARY PLAYGROUND

One sample sequence does not identify every curve

The upper plot shows two continuous cosine models that meet at every 8 kHz sample instant. The lower plot lets phase change the samples at the unsafe 4 kHz equality boundary.

Sample rate: 8 kHz1 kHz and 7 kHz cosine: same samples4 kHz sine at 0°: all zeros

The text following this canvas describes both plots.

At 8 kHz, 1 kHz and 7 kHz cosine curves meet at the same seventeen sample points over two milliseconds. A zero-phase 4 kHz sine produces only zeros at the equality boundary.

Model boundary. This demonstrates non-uniqueness. It does not calculate alias folding. Chapter 9 develops that calculation.

Try these checks

  1. Follow one sample stem upward. Verify that both upper curves cross its point.
  2. Move phase from 0° to 90°. Predict the lower pattern before reading its description.
  3. Explain why two samples per cycle cannot recover arbitrary phase.
Read the exact source running this playground

The TypeScript evaluates both continuous teaching curves and the exact equality-boundary samples.

export const DEFAULT_SAMPLE_RATE = 48_000;
const TAU = 2 * Math.PI;

function bitDepth(bits: number) {
  if (!Number.isInteger(bits) || bits < 2 || bits > 16) throw new RangeError('Bit depth must be an integer from 2 to 16');
  return bits;
}

function positive(value: number, label: string) {
  if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${label} must be positive`);
  return value;
}

function frameCount(frames: number, label = 'Frame count') {
  if (!Number.isInteger(frames) || frames < 1) throw new RangeError(`${label} must be a positive integer`);
  return frames;
}

function fadeAt(frame: number, frames: number, sampleRate: number) {
  const fadeFrames = Math.max(1, Math.round(0.006 * sampleRate));
  return Math.max(0, Math.min(1, frame / fadeFrames, (frames - 1 - frame) / fadeFrames));
}

export function quantizationModel(bits: number, fullScale = 1) {
  const levels = 2 ** bitDepth(bits);
  const step = 2 * positive(fullScale, 'Full scale') / levels;
  return { levels, step, errorBound: step / 2 };
}

export function quantizeMidRise(value: number, bits: number, fullScale = 1) {
  if (!Number.isFinite(value)) throw new RangeError('Sample value must be finite');
  const { levels, step } = quantizationModel(bits, fullScale);
  const code = Math.max(0, Math.min(levels - 1, Math.floor((value + fullScale) / step)));
  return -fullScale + (code + 0.5) * step;
}

export function isNyquistSafe(frequency: number, sampleRate: number) {
  return Number.isFinite(frequency) && frequency > 0 && Number.isFinite(sampleRate) && sampleRate > 0 && frequency < sampleRate / 2;
}

export function bufferDuration(frames: number, sampleRate: number) {
  if (!Number.isInteger(frames) || frames < 0) throw new RangeError('Frame count must be a non-negative integer');
  return frames / positive(sampleRate, 'Sample rate');
}

export function sampleQuantizedWindow(frequency: number, bits: number, sampleRate: number, frames: number) {
  positive(frequency, 'Frequency');
  positive(sampleRate, 'Sample rate');
  frameCount(frames);
  const raw = new Float32Array(frames);
  const quantized = new Float32Array(frames);
  const error = new Float32Array(frames);
  for (let n = 0; n < frames; n++) {
    raw[n] = 0.7 * Math.sin(TAU * frequency * n / sampleRate);
    quantized[n] = quantizeMidRise(raw[n], bits);
    error[n] = raw[n] - quantized[n];
  }
  return { raw, quantized, error };
}

export function renderPcmComparison(frequency: number, bits: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  if (!isNyquistSafe(frequency, sampleRate)) throw new RangeError('Frequency must stay below the Nyquist boundary');
  const frames = Math.round(positive(seconds, 'Duration') * sampleRate);
  const { raw, quantized, error } = sampleQuantizedWindow(frequency, bits, sampleRate, frames);
  const reference = new Float32Array(frames);
  const quantizedOutput = new Float32Array(frames);
  const errorOutput = new Float32Array(frames);
  for (let n = 0; n < frames; n++) {
    const gain = 0.14 * fadeAt(n, frames, sampleRate);
    reference[n] = gain * raw[n];
    quantizedOutput[n] = gain * quantized[n];
    errorOutput[n] = gain * error[n];
  }
  return { reference, quantized: quantizedOutput, error: errorOutput };
}

export function renderReferenceTone(frequency: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  return renderPcmComparison(frequency, 16, seconds, sampleRate).reference;
}

export function renderQuantizedTone(frequency: number, bits: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  return renderPcmComparison(frequency, bits, seconds, sampleRate).quantized;
}

export function renderQuantizationError(frequency: number, bits: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  return renderPcmComparison(frequency, bits, seconds, sampleRate).error;
}

export function nyquistBoundarySamples(phaseDegrees: number, frames = 9) {
  if (!Number.isFinite(phaseDegrees)) throw new RangeError('Phase must be finite');
  frameCount(frames);
  const phase = phaseDegrees * Math.PI / 180;
  return Float32Array.from({ length: frames }, (_, n) => {
    const value = Math.sin(Math.PI * n + phase);
    return Math.abs(value) < 1e-12 ? 0 : value;
  });
}

export function nyquistAmbiguityTrace(sampleRate = 8_000, lowFrequency = 1_000, seconds = 0.002, points = 321) {
  positive(sampleRate, 'Sample rate');
  positive(lowFrequency, 'Low frequency');
  positive(seconds, 'Duration');
  frameCount(points, 'Point count');
  if (points < 2) throw new RangeError('Point count must be at least two');
  if (lowFrequency >= sampleRate / 2) throw new RangeError('Low frequency must stay below half the sample rate');
  const highFrequency = sampleRate - lowFrequency;
  const time = new Float32Array(points);
  const low = new Float32Array(points);
  const high = new Float32Array(points);
  for (let index = 0; index < points; index++) {
    const t = seconds * index / (points - 1);
    time[index] = t;
    low[index] = Math.cos(TAU * lowFrequency * t);
    high[index] = Math.cos(TAU * highFrequency * t);
  }
  const sampleFrames = Math.floor(seconds * sampleRate) + 1;
  const sampleTime = new Float32Array(sampleFrames);
  const samples = new Float32Array(sampleFrames);
  for (let n = 0; n < sampleFrames; n++) {
    sampleTime[n] = n / sampleRate;
    samples[n] = Math.cos(TAU * lowFrequency * n / sampleRate);
  }
  return { time, low, high, sampleTime, samples, lowFrequency, highFrequency };
}

export function renderBlockedTone(frequency: number, blockFrames = 128, blocks = 8, resetPhase = false, sampleRate = DEFAULT_SAMPLE_RATE) {
  if (!isNyquistSafe(frequency, sampleRate)) throw new RangeError('Frequency must stay below the Nyquist boundary');
  frameCount(blockFrames, 'Block size');
  frameCount(blocks, 'Block count');
  const frames = blockFrames * blocks;
  const output = new Float32Array(frames);
  const step = TAU * frequency / sampleRate;
  let phase = 0;
  for (let n = 0; n < frames; n++) {
    if (resetPhase && n > 0 && n % blockFrames === 0) phase = 0;
    output[n] = 0.14 * fadeAt(n, frames, sampleRate) * Math.sin(phase);
    phase = (phase + step) % TAU;
  }
  return output;
}

export function blockBoundaryJumps(samples: Float32Array, blockFrames: number) {
  frameCount(blockFrames, 'Block size');
  const jumps: number[] = [];
  for (let frame = blockFrames; frame < samples.length; frame += blockFrames) jumps.push(samples[frame] - samples[frame - 1]);
  return jumps;
}

export function bufferFillState(totalFrames: number, blockFrames: number, blocksAdded: number) {
  frameCount(totalFrames, 'Buffer size');
  frameCount(blockFrames, 'Block size');
  if (!Number.isInteger(blocksAdded) || blocksAdded < 0) throw new RangeError('Blocks added must be a non-negative integer');
  const filledFrames = Math.min(totalFrames, blockFrames * blocksAdded);
  const previousFrames = Math.min(totalFrames, blockFrames * Math.max(0, blocksAdded - 1));
  return { filledFrames, previousFrames, complete: filledFrames === totalFrames, blocksAdded };
}

export function unitGeneratorTrace(frames = 8) {
  frameCount(frames);
  const source = new Float32Array(frames);
  const envelope = new Float32Array(frames);
  const output = new Float32Array(frames);
  for (let n = 0; n < frames; n++) {
    source[n] = Math.sin(TAU * n / frames);
    envelope[n] = frames === 1 ? 1 : n / (frames - 1);
    output[n] = source[n] * envelope[n];
  }
  return { source, envelope, output };
}

PCM assigns finite amplitude choices

Sampling chooses when to evaluate a signal. Quantization chooses among a finite set of amplitude values. Pulse-code modulation, or PCM, represents successive sample amplitudes with numerical codes (Mathews et al. 1969; Smith 2011).

Real PCM formats differ in endpoint and signed-number conventions. We therefore name one exact classroom model: a uniform mid-rise quantizer. Uniform means equal bin widths. Mid-rise means zero is a decision boundary rather than an output level.

Let B be bit depth and let the full-scale input interval be minus A through positive A. Bit depth is the number of binary digits used for each code. The level count and step are:

L=2B,Δ=2AL.(5.4)L=2^B,\quad \Delta=2A/L.\qquad(5.4)

For B=3 and A=1, there are eight levels separated by 0.25:

-0.875, -0.625, -0.375, -0.125, 0.125, 0.375, 0.625, 0.875
Eight horizontal reconstruction levels from minus 0.875 to plus 0.875, separated by 0.25.
Equation 5.4 with 3 bits and full scale ±1; eight midpoint levels are separated by 0.25.

Long description. Eight horizontal reconstruction levels run from minus 0.875 to plus 0.875 across the input range minus one to plus one. A vertical arrow marks the constant 0.25 gap between adjacent levels.

Visual provenance. Deterministic output from formula_visuals_ch05.py with B=3, A=1, and Δ=0.25.

Quantization chooses one level

The exact mid-rise rule is:

QB(x)=A+(k+12)Δ,k=clip(x+AΔ,0,L1),e=xQB(x),Δ2e<Δ2forAx<A.(5.5)Q_B(x)=-A+(k+1/2)\Delta,\ k=\operatorname{clip}(\lfloor(x+A)/\Delta\rfloor,0,L-1),\ e=x-Q_B(x),\ -\Delta/2\le e<\Delta/2\text{ for }-A\le x<A.\qquad(5.5)

floor chooses an integer bin. clip limits that bin to zero through L1. The difference e is quantization error. For input from minus A up to but not including positive A, the selected midpoint stays within half a step. Overload means that input has left this declared full-scale interval. Clipping then removes the half-step guarantee.

A 3-bit mid-rise staircase over a diagonal reference and its error, with overload regions outside plus or minus one.
Equation 5.5 for the named 3-bit uniform mid-rise teaching quantizer, including clipped inputs outside ±1.

Long description. The upper panel maps a diagonal ideal input onto an eight-level staircase. Pale red outer regions mark clipping below minus one and at or above plus one. The lower panel shows sawtooth-shaped error bounded by dashed plus and minus 0.125 lines inside the declared input interval. Error grows after overload.

Visual provenance. Deterministic output from formula_visuals_ch05.py over input minus 1.2 through plus 1.2.

InputOutputErrorStatus
-1.20-0.875-0.325overload
-0.75-0.625-0.125in range
00.125-0.125in range
0.240.1250.115in range
0.990.8750.115in range
1.200.8750.325overload

Web Audio processing values and AudioBuffer channel data use 32-bit floating-point numbers. The chapter's low-bit quantizer is a deliberate effect model, not the browser's native representation (W3C Audio Working Group, n.d.).

Buffers hold a finite duration

A buffer is a finite stored collection of sample-frames, organized by channel (MDN Web Docs, n.d.). If it contains N frames per channel, its duration is:

Tbuffer=NFs.(5.6)T_{\mathrm{buffer}}=N/F_s.\qquad(5.6)

Do not replace N with N1. The last sample begins at (N1)/Fs, but its final interval carries playback to N/Fs.

Four buffer cases show duration bars and a final sample instant just before each end.
Equation 5.6 for four frame-count and sample-rate pairs; red marks each last sample instant.

Long description. Horizontal bars show that one frame at 48 kHz lasts 0.02083 milliseconds, 128 frames last 2.667 milliseconds, and 48,000 frames at 48 kHz or 44,100 frames at 44.1 kHz each last one second. A red mark just before each bar end identifies the final sample instant.

Visual provenance. Deterministic output from formula_visuals_ch05.py. The horizontal scale is symmetric-log so that one-frame, one-block, and one-second cases remain visible together.

A requested duration may not produce an integer frame count. State the rounding rule. Use round for the nearest duration, ceil for at least the requested duration, or floor for no more than it.

Blocks divide the work, not the timeline

A render block is a finite group of consecutive frames processed together. Let one block contain B frames. If block zero begins at audio time t0, then:

tk=t0+kBFs,Tblock=BFs.(5.7)t_k=t_0+kB/F_s,\quad T_{\mathrm{block}}=B/F_s.\qquad(5.7)

At 48 kHz, 128 frames last about 2.667 milliseconds. Web Audio calls its processing group a render quantum. The default is 128 frames, but worklet code should read the array length it actually receives rather than hard-code that value (W3C Audio Working Group, n.d.).

Five contiguous 128-frame blocks begin at 100, 102.667, 105.333, 108, and 110.667 milliseconds.
Equation 5.7 for five 128-frame blocks at 48 kHz, beginning at 100 milliseconds.

Long description. Five adjacent blocks begin at 100, 102.667, 105.333, 108, and 110.667 milliseconds. Each block spans 2.667 milliseconds. The rectangles touch without gaps or overlaps even though their colors alternate.

Visual provenance. Deterministic output from formula_visuals_ch05.py with B=128 and Fs=48000 Hz.

Blocks are not separate sounds. Oscillator phase, envelope position, and note state must continue from one block to the next. Resetting phase at each boundary creates discontinuities. One block duration also is not total listener latency. Browser, operating-system, and device buffering add more delay.

The block playground makes both claims visible. One comparison preserves oscillator phase while another deliberately resets it. A separate stepper groups the same 1,024-frame timeline into different block sizes.

BLOCK PLAYGROUND

Blocks divide work, not the waveform

Compare continuous oscillator state with a faulty phase reset at every block boundary. Then fill a fixed buffer one processing block at a time.

Hearing safety. Lower device volume before playback. The reset example can sound bright because repeated discontinuities add energy.

Block: 2.667 msPreserved largest boundary step: 0.000Reset largest boundary step: 0.000

The text following this canvas describes the two signals and their boundaries.

The upper oscillator continues through every boundary. The lower oscillator restarts at zero, creating repeated discontinuities.

Ready

Fill a 1,024-frame buffer

Each press adds one complete processing block. The sample timeline remains contiguous regardless of grouping.

0 of 1024 frames

0 / 1,024 framesNo block addedBlock duration is not total device latency

Buffer empty

Try these checks

  1. Predict whether changing block size should change the preserved oscillator pitch.
  2. Compare the marked boundaries. Find a reset whose first sample does not continue the previous slope.
  3. Fill the buffer with 64-frame blocks, then 512-frame blocks. Explain why total frames stay fixed.
  4. Name two other states that must survive a block boundary.
Read the exact source running this playground

The TypeScript keeps or resets one phase accumulator and calculates every buffer-fill state from integer frame counts.

export const DEFAULT_SAMPLE_RATE = 48_000;
const TAU = 2 * Math.PI;

function bitDepth(bits: number) {
  if (!Number.isInteger(bits) || bits < 2 || bits > 16) throw new RangeError('Bit depth must be an integer from 2 to 16');
  return bits;
}

function positive(value: number, label: string) {
  if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${label} must be positive`);
  return value;
}

function frameCount(frames: number, label = 'Frame count') {
  if (!Number.isInteger(frames) || frames < 1) throw new RangeError(`${label} must be a positive integer`);
  return frames;
}

function fadeAt(frame: number, frames: number, sampleRate: number) {
  const fadeFrames = Math.max(1, Math.round(0.006 * sampleRate));
  return Math.max(0, Math.min(1, frame / fadeFrames, (frames - 1 - frame) / fadeFrames));
}

export function quantizationModel(bits: number, fullScale = 1) {
  const levels = 2 ** bitDepth(bits);
  const step = 2 * positive(fullScale, 'Full scale') / levels;
  return { levels, step, errorBound: step / 2 };
}

export function quantizeMidRise(value: number, bits: number, fullScale = 1) {
  if (!Number.isFinite(value)) throw new RangeError('Sample value must be finite');
  const { levels, step } = quantizationModel(bits, fullScale);
  const code = Math.max(0, Math.min(levels - 1, Math.floor((value + fullScale) / step)));
  return -fullScale + (code + 0.5) * step;
}

export function isNyquistSafe(frequency: number, sampleRate: number) {
  return Number.isFinite(frequency) && frequency > 0 && Number.isFinite(sampleRate) && sampleRate > 0 && frequency < sampleRate / 2;
}

export function bufferDuration(frames: number, sampleRate: number) {
  if (!Number.isInteger(frames) || frames < 0) throw new RangeError('Frame count must be a non-negative integer');
  return frames / positive(sampleRate, 'Sample rate');
}

export function sampleQuantizedWindow(frequency: number, bits: number, sampleRate: number, frames: number) {
  positive(frequency, 'Frequency');
  positive(sampleRate, 'Sample rate');
  frameCount(frames);
  const raw = new Float32Array(frames);
  const quantized = new Float32Array(frames);
  const error = new Float32Array(frames);
  for (let n = 0; n < frames; n++) {
    raw[n] = 0.7 * Math.sin(TAU * frequency * n / sampleRate);
    quantized[n] = quantizeMidRise(raw[n], bits);
    error[n] = raw[n] - quantized[n];
  }
  return { raw, quantized, error };
}

export function renderPcmComparison(frequency: number, bits: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  if (!isNyquistSafe(frequency, sampleRate)) throw new RangeError('Frequency must stay below the Nyquist boundary');
  const frames = Math.round(positive(seconds, 'Duration') * sampleRate);
  const { raw, quantized, error } = sampleQuantizedWindow(frequency, bits, sampleRate, frames);
  const reference = new Float32Array(frames);
  const quantizedOutput = new Float32Array(frames);
  const errorOutput = new Float32Array(frames);
  for (let n = 0; n < frames; n++) {
    const gain = 0.14 * fadeAt(n, frames, sampleRate);
    reference[n] = gain * raw[n];
    quantizedOutput[n] = gain * quantized[n];
    errorOutput[n] = gain * error[n];
  }
  return { reference, quantized: quantizedOutput, error: errorOutput };
}

export function renderReferenceTone(frequency: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  return renderPcmComparison(frequency, 16, seconds, sampleRate).reference;
}

export function renderQuantizedTone(frequency: number, bits: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  return renderPcmComparison(frequency, bits, seconds, sampleRate).quantized;
}

export function renderQuantizationError(frequency: number, bits: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  return renderPcmComparison(frequency, bits, seconds, sampleRate).error;
}

export function nyquistBoundarySamples(phaseDegrees: number, frames = 9) {
  if (!Number.isFinite(phaseDegrees)) throw new RangeError('Phase must be finite');
  frameCount(frames);
  const phase = phaseDegrees * Math.PI / 180;
  return Float32Array.from({ length: frames }, (_, n) => {
    const value = Math.sin(Math.PI * n + phase);
    return Math.abs(value) < 1e-12 ? 0 : value;
  });
}

export function nyquistAmbiguityTrace(sampleRate = 8_000, lowFrequency = 1_000, seconds = 0.002, points = 321) {
  positive(sampleRate, 'Sample rate');
  positive(lowFrequency, 'Low frequency');
  positive(seconds, 'Duration');
  frameCount(points, 'Point count');
  if (points < 2) throw new RangeError('Point count must be at least two');
  if (lowFrequency >= sampleRate / 2) throw new RangeError('Low frequency must stay below half the sample rate');
  const highFrequency = sampleRate - lowFrequency;
  const time = new Float32Array(points);
  const low = new Float32Array(points);
  const high = new Float32Array(points);
  for (let index = 0; index < points; index++) {
    const t = seconds * index / (points - 1);
    time[index] = t;
    low[index] = Math.cos(TAU * lowFrequency * t);
    high[index] = Math.cos(TAU * highFrequency * t);
  }
  const sampleFrames = Math.floor(seconds * sampleRate) + 1;
  const sampleTime = new Float32Array(sampleFrames);
  const samples = new Float32Array(sampleFrames);
  for (let n = 0; n < sampleFrames; n++) {
    sampleTime[n] = n / sampleRate;
    samples[n] = Math.cos(TAU * lowFrequency * n / sampleRate);
  }
  return { time, low, high, sampleTime, samples, lowFrequency, highFrequency };
}

export function renderBlockedTone(frequency: number, blockFrames = 128, blocks = 8, resetPhase = false, sampleRate = DEFAULT_SAMPLE_RATE) {
  if (!isNyquistSafe(frequency, sampleRate)) throw new RangeError('Frequency must stay below the Nyquist boundary');
  frameCount(blockFrames, 'Block size');
  frameCount(blocks, 'Block count');
  const frames = blockFrames * blocks;
  const output = new Float32Array(frames);
  const step = TAU * frequency / sampleRate;
  let phase = 0;
  for (let n = 0; n < frames; n++) {
    if (resetPhase && n > 0 && n % blockFrames === 0) phase = 0;
    output[n] = 0.14 * fadeAt(n, frames, sampleRate) * Math.sin(phase);
    phase = (phase + step) % TAU;
  }
  return output;
}

export function blockBoundaryJumps(samples: Float32Array, blockFrames: number) {
  frameCount(blockFrames, 'Block size');
  const jumps: number[] = [];
  for (let frame = blockFrames; frame < samples.length; frame += blockFrames) jumps.push(samples[frame] - samples[frame - 1]);
  return jumps;
}

export function bufferFillState(totalFrames: number, blockFrames: number, blocksAdded: number) {
  frameCount(totalFrames, 'Buffer size');
  frameCount(blockFrames, 'Block size');
  if (!Number.isInteger(blocksAdded) || blocksAdded < 0) throw new RangeError('Blocks added must be a non-negative integer');
  const filledFrames = Math.min(totalFrames, blockFrames * blocksAdded);
  const previousFrames = Math.min(totalFrames, blockFrames * Math.max(0, blocksAdded - 1));
  return { filledFrames, previousFrames, complete: filledFrames === totalFrames, blocksAdded };
}

export function unitGeneratorTrace(frames = 8) {
  frameCount(frames);
  const source = new Float32Array(frames);
  const envelope = new Float32Array(frames);
  const output = new Float32Array(frames);
  for (let n = 0; n < frames; n++) {
    source[n] = Math.sin(TAU * n / frames);
    envelope[n] = frames === 1 ? 1 : n / (frames - 1);
    output[n] = source[n] * envelope[n];
  }
  return { source, envelope, output };
}

MUSIC-N builds instruments from small jobs

A unit generator is a small reusable numerical processor. MUSIC instruments connected oscillators, adders, multipliers, noise sources, envelope generators, and output units. The connections formed a computational signal graph: directed edges show which processor supplies values to which input (Mathews 1963; Mathews et al. 1969).

Score parameters drive an oscillator and envelope, which meet at a multiplier before an output block and audio destination.
A minimal unit-generator graph separates sound production, amplitude shape, and output.

Long description. Score parameters branch to an oscillator and an envelope. Their outputs enter a multiply unit. The multiplied samples enter an output block, then the audio destination. Arrows point in the direction that numerical values travel.

Diagram provenance. Author-generated from the unit-generator descriptions in Mathews's 1963 article and the MUSIC V manual (Mathews 1963; Mathews et al. 1969).

Graph arrows show signal dependency, not arbitrary source-code order. Web Audio likewise connects small AudioNode processors into a routing graph. A Web Audio node is not historically identical to a MUSIC unit generator, but both systems build larger instruments from connected numerical jobs.

The value-trace playground follows one sample through a fixed oscillator, envelope, multiplier, and output. It does not turn the chapter into a general graph editor.

VALUE-TRACE PLAYGROUND

Follow one value through a fixed instrument

Step through eight frames. The oscillator supplies a value, the envelope supplies a level, and the multiplier produces the output sent to the destination.

Source: 0.000Envelope: 0.000Output: 0.000 × 0.000 = 0.000

Fixed graph. Oscillator → multiplier ← envelope; multiplier → output.

The following text and table contain every plotted value.

Frame zero multiplies oscillator value zero by envelope level zero, producing output zero.

Eight-frame unit-generator trace
FrameOscillatorEnvelopeMultiplied output
Frame 0 selected

Try these checks

  1. Before advancing, multiply the displayed source and envelope values yourself.
  2. Find a large oscillator value that produces a small output. Explain the envelope's role.
  3. Describe why graph arrows show value dependency rather than JavaScript statement order.
Read the exact source running this playground

The TypeScript creates one deterministic oscillator cycle, one rising envelope, and their sample-by-sample product.

export const DEFAULT_SAMPLE_RATE = 48_000;
const TAU = 2 * Math.PI;

function bitDepth(bits: number) {
  if (!Number.isInteger(bits) || bits < 2 || bits > 16) throw new RangeError('Bit depth must be an integer from 2 to 16');
  return bits;
}

function positive(value: number, label: string) {
  if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${label} must be positive`);
  return value;
}

function frameCount(frames: number, label = 'Frame count') {
  if (!Number.isInteger(frames) || frames < 1) throw new RangeError(`${label} must be a positive integer`);
  return frames;
}

function fadeAt(frame: number, frames: number, sampleRate: number) {
  const fadeFrames = Math.max(1, Math.round(0.006 * sampleRate));
  return Math.max(0, Math.min(1, frame / fadeFrames, (frames - 1 - frame) / fadeFrames));
}

export function quantizationModel(bits: number, fullScale = 1) {
  const levels = 2 ** bitDepth(bits);
  const step = 2 * positive(fullScale, 'Full scale') / levels;
  return { levels, step, errorBound: step / 2 };
}

export function quantizeMidRise(value: number, bits: number, fullScale = 1) {
  if (!Number.isFinite(value)) throw new RangeError('Sample value must be finite');
  const { levels, step } = quantizationModel(bits, fullScale);
  const code = Math.max(0, Math.min(levels - 1, Math.floor((value + fullScale) / step)));
  return -fullScale + (code + 0.5) * step;
}

export function isNyquistSafe(frequency: number, sampleRate: number) {
  return Number.isFinite(frequency) && frequency > 0 && Number.isFinite(sampleRate) && sampleRate > 0 && frequency < sampleRate / 2;
}

export function bufferDuration(frames: number, sampleRate: number) {
  if (!Number.isInteger(frames) || frames < 0) throw new RangeError('Frame count must be a non-negative integer');
  return frames / positive(sampleRate, 'Sample rate');
}

export function sampleQuantizedWindow(frequency: number, bits: number, sampleRate: number, frames: number) {
  positive(frequency, 'Frequency');
  positive(sampleRate, 'Sample rate');
  frameCount(frames);
  const raw = new Float32Array(frames);
  const quantized = new Float32Array(frames);
  const error = new Float32Array(frames);
  for (let n = 0; n < frames; n++) {
    raw[n] = 0.7 * Math.sin(TAU * frequency * n / sampleRate);
    quantized[n] = quantizeMidRise(raw[n], bits);
    error[n] = raw[n] - quantized[n];
  }
  return { raw, quantized, error };
}

export function renderPcmComparison(frequency: number, bits: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  if (!isNyquistSafe(frequency, sampleRate)) throw new RangeError('Frequency must stay below the Nyquist boundary');
  const frames = Math.round(positive(seconds, 'Duration') * sampleRate);
  const { raw, quantized, error } = sampleQuantizedWindow(frequency, bits, sampleRate, frames);
  const reference = new Float32Array(frames);
  const quantizedOutput = new Float32Array(frames);
  const errorOutput = new Float32Array(frames);
  for (let n = 0; n < frames; n++) {
    const gain = 0.14 * fadeAt(n, frames, sampleRate);
    reference[n] = gain * raw[n];
    quantizedOutput[n] = gain * quantized[n];
    errorOutput[n] = gain * error[n];
  }
  return { reference, quantized: quantizedOutput, error: errorOutput };
}

export function renderReferenceTone(frequency: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  return renderPcmComparison(frequency, 16, seconds, sampleRate).reference;
}

export function renderQuantizedTone(frequency: number, bits: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  return renderPcmComparison(frequency, bits, seconds, sampleRate).quantized;
}

export function renderQuantizationError(frequency: number, bits: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  return renderPcmComparison(frequency, bits, seconds, sampleRate).error;
}

export function nyquistBoundarySamples(phaseDegrees: number, frames = 9) {
  if (!Number.isFinite(phaseDegrees)) throw new RangeError('Phase must be finite');
  frameCount(frames);
  const phase = phaseDegrees * Math.PI / 180;
  return Float32Array.from({ length: frames }, (_, n) => {
    const value = Math.sin(Math.PI * n + phase);
    return Math.abs(value) < 1e-12 ? 0 : value;
  });
}

export function nyquistAmbiguityTrace(sampleRate = 8_000, lowFrequency = 1_000, seconds = 0.002, points = 321) {
  positive(sampleRate, 'Sample rate');
  positive(lowFrequency, 'Low frequency');
  positive(seconds, 'Duration');
  frameCount(points, 'Point count');
  if (points < 2) throw new RangeError('Point count must be at least two');
  if (lowFrequency >= sampleRate / 2) throw new RangeError('Low frequency must stay below half the sample rate');
  const highFrequency = sampleRate - lowFrequency;
  const time = new Float32Array(points);
  const low = new Float32Array(points);
  const high = new Float32Array(points);
  for (let index = 0; index < points; index++) {
    const t = seconds * index / (points - 1);
    time[index] = t;
    low[index] = Math.cos(TAU * lowFrequency * t);
    high[index] = Math.cos(TAU * highFrequency * t);
  }
  const sampleFrames = Math.floor(seconds * sampleRate) + 1;
  const sampleTime = new Float32Array(sampleFrames);
  const samples = new Float32Array(sampleFrames);
  for (let n = 0; n < sampleFrames; n++) {
    sampleTime[n] = n / sampleRate;
    samples[n] = Math.cos(TAU * lowFrequency * n / sampleRate);
  }
  return { time, low, high, sampleTime, samples, lowFrequency, highFrequency };
}

export function renderBlockedTone(frequency: number, blockFrames = 128, blocks = 8, resetPhase = false, sampleRate = DEFAULT_SAMPLE_RATE) {
  if (!isNyquistSafe(frequency, sampleRate)) throw new RangeError('Frequency must stay below the Nyquist boundary');
  frameCount(blockFrames, 'Block size');
  frameCount(blocks, 'Block count');
  const frames = blockFrames * blocks;
  const output = new Float32Array(frames);
  const step = TAU * frequency / sampleRate;
  let phase = 0;
  for (let n = 0; n < frames; n++) {
    if (resetPhase && n > 0 && n % blockFrames === 0) phase = 0;
    output[n] = 0.14 * fadeAt(n, frames, sampleRate) * Math.sin(phase);
    phase = (phase + step) % TAU;
  }
  return output;
}

export function blockBoundaryJumps(samples: Float32Array, blockFrames: number) {
  frameCount(blockFrames, 'Block size');
  const jumps: number[] = [];
  for (let frame = blockFrames; frame < samples.length; frame += blockFrames) jumps.push(samples[frame] - samples[frame - 1]);
  return jumps;
}

export function bufferFillState(totalFrames: number, blockFrames: number, blocksAdded: number) {
  frameCount(totalFrames, 'Buffer size');
  frameCount(blockFrames, 'Block size');
  if (!Number.isInteger(blocksAdded) || blocksAdded < 0) throw new RangeError('Blocks added must be a non-negative integer');
  const filledFrames = Math.min(totalFrames, blockFrames * blocksAdded);
  const previousFrames = Math.min(totalFrames, blockFrames * Math.max(0, blocksAdded - 1));
  return { filledFrames, previousFrames, complete: filledFrames === totalFrames, blocksAdded };
}

export function unitGeneratorTrace(frames = 8) {
  frameCount(frames);
  const source = new Float32Array(frames);
  const envelope = new Float32Array(frames);
  const output = new Float32Array(frames);
  for (let n = 0; n < frames; n++) {
    source[n] = Math.sin(TAU * n / frames);
    envelope[n] = frames === 1 ? 1 : n / (frames - 1);
    output[n] = source[n] * envelope[n];
  }
  return { source, envelope, output };
}

Section II · Practice

Use the model

Calculate, listen, build, diagnose, and check your understanding.

Concept check: samples, codes, and blocks

Show units and intermediate steps. Answers follow the chapter implementation notebook.

  1. At 48 kHz, find sample spacing in seconds and microseconds.
  2. Find the time of sample index 47,999 at 48 kHz. Then state the duration of a 48,000-frame buffer.
  3. At 44.1 kHz, decide whether 22,049 Hz, 22,050 Hz, and 22,051 Hz meet Equation 5.3.
  4. For the 3-bit mid-rise model with full scale ±1, find the level count, step, output for -0.62, and error.
  5. Explain why input zero maps to 0.125 rather than zero in this named model.
  6. A buffer must last at least 0.12501 seconds at 48 kHz. Choose its frame count and state the rounding rule.
  7. At 48 kHz with 128-frame blocks, find one block's duration and the start of block 7 when block zero starts at 0.050 seconds.
  8. For every Equation 5.1 through 5.7 visual, name the axes, fixed parameters, and one visible prediction.

Worked example: one second through the model

Design one channel at 48 kHz, one second long, with a highest component of 9 kHz, 128-frame blocks, and the 3-bit mid-rise teaching quantizer.

  1. Boundary. Nine kilohertz is strictly below 24 kHz, so Equation 5.3 is met.
  2. Buffer. Equation 5.6 gives 48,000 frames. Indices run from zero through 47,999.
  3. Blocks. Each block lasts 2.667 milliseconds. Since 48,000 divided by 128 is 375, the buffer contains exactly 375 full blocks.
  4. Quantization. Eight levels give a 0.25 step. Input 0.24 maps to 0.125, with error 0.115.
  5. Graph. A source creates values, gain shapes them, and the destination consumes them. State crosses every block boundary.

The calculation proves frame counts and model behavior. It does not prove fixed device latency or identical loudness on every playback system.

Listening station 5: same tone, fewer levels

Set device volume low before playback. The file first presents the same 440 Hz tone at 3, 5, 8, and 16 bits, with short silences between. It then presents one original eight-note phrase at 4 bits and 12 bits. Every segment uses the same explicit mid-rise model and conservative output gain.

A 440 Hz reference overlaid with three-bit, five-bit, and eight-bit quantized versions.
The same 440 Hz waveform under 3-bit, 5-bit, and 8-bit mid-rise quantization.

Long description. Three panels show 12 milliseconds of the same 440 Hz reference. The 3-bit output has broad visible steps. The 5-bit output follows more closely. At 8 bits, the steps are dense enough that the quantized line nearly covers the reference at this scale.

Figure and audio provenance. Project-authored deterministic output from ch05_digital_audio.py. No historical or commercial recording is embedded.

  1. Predict which pair will sound most different before pressing play.
  2. Describe the 3-bit tone without claiming that Web Audio itself became a 3-bit engine.
  3. Compare the two melody versions. Which identity survives the code change: pitch order, rhythm, contour, or surface texture?
  4. Name one claim the plot verifies and one listening word that remains subjective.

Song study 5: Code Steps

Code Steps is an original eight-note study written for this chapter:

220, 275, 330, 440, 330, 275, 247.5, 220 Hz

Each note lasts 240 milliseconds. The first version uses 4-bit quantization. The second uses 12-bit quantization. Pitch centers, durations, order, and level stay fixed.

  1. Mark the ascent, return, and final step below 275 Hz.
  2. Decide whether bit depth changes phrase identity, surface color, or both.
  3. Make a third version in which only the four-note return uses 4 bits. Keep every pitch and duration unchanged.
  4. Submit the event list, quantizer settings, and 100 words comparing phrase identity and surface texture.

LAB 05

Compare a tone, its PCM levels, and the error

Choose a sample rate, bit depth, and block size. Predict the difference, switch between matched-gain signals, then inspect the exact error left by quantization.

Hearing safety. Lower device volume before playback. All three buttons use the same conservative gain. Error-only playback may be quiet at higher bit depths.

8 levelsStep: 0.250Maximum error: 0.125Block: 16.000 ms

The upper plot compares the reference with quantized samples. The lower plot shows reference minus quantized output.

A safe 1 kHz tone has eight samples per cycle at 8 kHz. Three bits provide eight amplitude levels. The lower trajectory is the exact reference-minus-quantized error.

Model boundary. The half-step error bound applies only for input from -1 through values below +1. This teaching tone peaks at ±0.7, so it does not overload.

Ready

Try these checks

  1. Set 3 bits. Predict the error before switching between original, quantized, and error-only playback.
  2. Raise the depth to 8 bits. Explain why the reference stays fixed while the error shrinks.
  3. Keep 1 kHz and compare 8 kHz with 48 kHz. Count the samples in one cycle.
  4. Raise the tone to the Nyquist boundary. Explain why all three playback buttons stop.
  5. Compare 64-frame and 512-frame block durations. Name what does not change about the sample timeline.
Read the exact source running this lab

This TypeScript implements Equations 5.1 and 5.3 through 5.6. Original, quantized, and error-only playback use one envelope and one fixed gain.

export const DEFAULT_SAMPLE_RATE = 48_000;
const TAU = 2 * Math.PI;

function bitDepth(bits: number) {
  if (!Number.isInteger(bits) || bits < 2 || bits > 16) throw new RangeError('Bit depth must be an integer from 2 to 16');
  return bits;
}

function positive(value: number, label: string) {
  if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${label} must be positive`);
  return value;
}

function frameCount(frames: number, label = 'Frame count') {
  if (!Number.isInteger(frames) || frames < 1) throw new RangeError(`${label} must be a positive integer`);
  return frames;
}

function fadeAt(frame: number, frames: number, sampleRate: number) {
  const fadeFrames = Math.max(1, Math.round(0.006 * sampleRate));
  return Math.max(0, Math.min(1, frame / fadeFrames, (frames - 1 - frame) / fadeFrames));
}

export function quantizationModel(bits: number, fullScale = 1) {
  const levels = 2 ** bitDepth(bits);
  const step = 2 * positive(fullScale, 'Full scale') / levels;
  return { levels, step, errorBound: step / 2 };
}

export function quantizeMidRise(value: number, bits: number, fullScale = 1) {
  if (!Number.isFinite(value)) throw new RangeError('Sample value must be finite');
  const { levels, step } = quantizationModel(bits, fullScale);
  const code = Math.max(0, Math.min(levels - 1, Math.floor((value + fullScale) / step)));
  return -fullScale + (code + 0.5) * step;
}

export function isNyquistSafe(frequency: number, sampleRate: number) {
  return Number.isFinite(frequency) && frequency > 0 && Number.isFinite(sampleRate) && sampleRate > 0 && frequency < sampleRate / 2;
}

export function bufferDuration(frames: number, sampleRate: number) {
  if (!Number.isInteger(frames) || frames < 0) throw new RangeError('Frame count must be a non-negative integer');
  return frames / positive(sampleRate, 'Sample rate');
}

export function sampleQuantizedWindow(frequency: number, bits: number, sampleRate: number, frames: number) {
  positive(frequency, 'Frequency');
  positive(sampleRate, 'Sample rate');
  frameCount(frames);
  const raw = new Float32Array(frames);
  const quantized = new Float32Array(frames);
  const error = new Float32Array(frames);
  for (let n = 0; n < frames; n++) {
    raw[n] = 0.7 * Math.sin(TAU * frequency * n / sampleRate);
    quantized[n] = quantizeMidRise(raw[n], bits);
    error[n] = raw[n] - quantized[n];
  }
  return { raw, quantized, error };
}

export function renderPcmComparison(frequency: number, bits: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  if (!isNyquistSafe(frequency, sampleRate)) throw new RangeError('Frequency must stay below the Nyquist boundary');
  const frames = Math.round(positive(seconds, 'Duration') * sampleRate);
  const { raw, quantized, error } = sampleQuantizedWindow(frequency, bits, sampleRate, frames);
  const reference = new Float32Array(frames);
  const quantizedOutput = new Float32Array(frames);
  const errorOutput = new Float32Array(frames);
  for (let n = 0; n < frames; n++) {
    const gain = 0.14 * fadeAt(n, frames, sampleRate);
    reference[n] = gain * raw[n];
    quantizedOutput[n] = gain * quantized[n];
    errorOutput[n] = gain * error[n];
  }
  return { reference, quantized: quantizedOutput, error: errorOutput };
}

export function renderReferenceTone(frequency: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  return renderPcmComparison(frequency, 16, seconds, sampleRate).reference;
}

export function renderQuantizedTone(frequency: number, bits: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  return renderPcmComparison(frequency, bits, seconds, sampleRate).quantized;
}

export function renderQuantizationError(frequency: number, bits: number, seconds = 0.65, sampleRate = DEFAULT_SAMPLE_RATE) {
  return renderPcmComparison(frequency, bits, seconds, sampleRate).error;
}

export function nyquistBoundarySamples(phaseDegrees: number, frames = 9) {
  if (!Number.isFinite(phaseDegrees)) throw new RangeError('Phase must be finite');
  frameCount(frames);
  const phase = phaseDegrees * Math.PI / 180;
  return Float32Array.from({ length: frames }, (_, n) => {
    const value = Math.sin(Math.PI * n + phase);
    return Math.abs(value) < 1e-12 ? 0 : value;
  });
}

export function nyquistAmbiguityTrace(sampleRate = 8_000, lowFrequency = 1_000, seconds = 0.002, points = 321) {
  positive(sampleRate, 'Sample rate');
  positive(lowFrequency, 'Low frequency');
  positive(seconds, 'Duration');
  frameCount(points, 'Point count');
  if (points < 2) throw new RangeError('Point count must be at least two');
  if (lowFrequency >= sampleRate / 2) throw new RangeError('Low frequency must stay below half the sample rate');
  const highFrequency = sampleRate - lowFrequency;
  const time = new Float32Array(points);
  const low = new Float32Array(points);
  const high = new Float32Array(points);
  for (let index = 0; index < points; index++) {
    const t = seconds * index / (points - 1);
    time[index] = t;
    low[index] = Math.cos(TAU * lowFrequency * t);
    high[index] = Math.cos(TAU * highFrequency * t);
  }
  const sampleFrames = Math.floor(seconds * sampleRate) + 1;
  const sampleTime = new Float32Array(sampleFrames);
  const samples = new Float32Array(sampleFrames);
  for (let n = 0; n < sampleFrames; n++) {
    sampleTime[n] = n / sampleRate;
    samples[n] = Math.cos(TAU * lowFrequency * n / sampleRate);
  }
  return { time, low, high, sampleTime, samples, lowFrequency, highFrequency };
}

export function renderBlockedTone(frequency: number, blockFrames = 128, blocks = 8, resetPhase = false, sampleRate = DEFAULT_SAMPLE_RATE) {
  if (!isNyquistSafe(frequency, sampleRate)) throw new RangeError('Frequency must stay below the Nyquist boundary');
  frameCount(blockFrames, 'Block size');
  frameCount(blocks, 'Block count');
  const frames = blockFrames * blocks;
  const output = new Float32Array(frames);
  const step = TAU * frequency / sampleRate;
  let phase = 0;
  for (let n = 0; n < frames; n++) {
    if (resetPhase && n > 0 && n % blockFrames === 0) phase = 0;
    output[n] = 0.14 * fadeAt(n, frames, sampleRate) * Math.sin(phase);
    phase = (phase + step) % TAU;
  }
  return output;
}

export function blockBoundaryJumps(samples: Float32Array, blockFrames: number) {
  frameCount(blockFrames, 'Block size');
  const jumps: number[] = [];
  for (let frame = blockFrames; frame < samples.length; frame += blockFrames) jumps.push(samples[frame] - samples[frame - 1]);
  return jumps;
}

export function bufferFillState(totalFrames: number, blockFrames: number, blocksAdded: number) {
  frameCount(totalFrames, 'Buffer size');
  frameCount(blockFrames, 'Block size');
  if (!Number.isInteger(blocksAdded) || blocksAdded < 0) throw new RangeError('Blocks added must be a non-negative integer');
  const filledFrames = Math.min(totalFrames, blockFrames * blocksAdded);
  const previousFrames = Math.min(totalFrames, blockFrames * Math.max(0, blocksAdded - 1));
  return { filledFrames, previousFrames, complete: filledFrames === totalFrames, blocksAdded };
}

export function unitGeneratorTrace(frames = 8) {
  frameCount(frames);
  const source = new Float32Array(frames);
  const envelope = new Float32Array(frames);
  const output = new Float32Array(frames);
  for (let n = 0; n < frames; n++) {
    source[n] = Math.sin(TAU * n / frames);
    envelope[n] = frames === 1 ? 1 : n / (frames - 1);
    output[n] = source[n] * envelope[n];
  }
  return { source, envelope, output };
}

Faded station 5: complete the render plan

A browser study requests 2.25 seconds at 48 kHz. Its highest component is 12 kHz. It uses 128-frame groups and the 3-bit mid-rise quantizer.

  1. Strict boundary: 12000 ___ 24000, so the condition is __________.
  2. Frame count: 2.25 times 48,000 = __________.
  3. Last sample index: __________.
  4. Last sample instant: __________ seconds.
  5. Playback duration: __________ seconds.
  6. Full 128-frame groups: __________, with __________ frames remaining.
  7. Quantizer step: __________.
  8. Input 0.51 maps to __________, giving error __________.
  9. Name two pieces of generator state that must cross a block boundary.
  10. Explain why stereo does not double the 48 kHz time rate.

Challenge: repair the renderer report

A developer writes:

“Stereo at 48 kHz is really 96 kHz. Sample 48,000 is the final sample of a one-second, 48,000-frame buffer. Nyquist allows equality. Three bits give nine levels including zero. Web Audio therefore processes this graph as 3-bit PCM. Quantizer error always stays below 0.125 after clipping. Every worklet call has 128 frames, resetting phase per call is harmless, one block is total latency, and graph arrows show JavaScript statement order.”

Correct every claim. Classify each fault as vocabulary, endpoint reasoning, sampling boundary, quantizer model, browser representation, scheduling and state, or graph interpretation.

Chapter 5 readiness gate

  1. What three facts turn an ordered amplitude list into a timed sampled signal?
  2. What is the difference between a sample and a sample-frame?
  3. Why does Equation 5.3 use a strict inequality?
  4. How do sampling and quantization differ?
  5. Why does the named 3-bit mid-rise quantizer have eight levels but no zero level?
  6. Where does the half-step error bound stop applying?
  7. Why is buffer duration N/Fs even though its last sample occurs earlier?
  8. What must processing code inspect instead of assuming 128?
  9. Why must oscillator phase survive a block boundary?
  10. How does a signal graph differ from program control flow?
  11. Give one continuity between MUSIC V and Web Audio and one important difference.

Chapter 5 invariants

  • A sampled signal is a sequence at indexed instants, not a uniquely implied connecting curve.
  • Sample rate counts sample-frames per second.
  • Index n occurs at n/Fs.
  • The strict classroom condition is Fs>2fmax.
  • Sampling chooses time instants. Quantization chooses amplitude levels.
  • The named 3-bit mid-rise model has eight levels, a 0.25 step at full scale ±1, and no zero level.
  • The half-step error bound applies only before overload.
  • Web Audio uses floating-point processing values, not the chapter's 3-bit code.
  • An N-frame buffer lasts N/Fs.
  • A block groups work. It does not break the sample timeline or define total latency.
  • Generator state crosses block boundaries.
  • Unit generators become instruments through directed signal connections.
  • Every Chapter 5 audio example is a project-authored teaching render.

Chapter 5 glossary additions

TermWorking definition
Band-limitedContaining no frequency component above a declared maximum.
Bit depthNumber of binary digits available to encode one sample value.
BufferFinite stored collection of sample-frames, organized by channel.
ClippingLimiting a value or index to an allowed range.
Computational signal graphDirected connections showing which numerical processor supplies each input.
Digital-to-analog converter (DAC)Device that converts numerical sample codes into electrical output values.
Full scaleDeclared input range over which a quantizer's ordinary bins are defined.
MUSIC-NFamily label for MUSIC V and descendants that retain its score, instrument, and unit-generator approach.
Offline renderingCalculating sound samples before the time when they are played.
OverloadInput outside a quantizer's declared full-scale interval.
PCMPulse-code modulation: representation of successive sample amplitudes by numerical codes.
QuantizationMapping possible amplitudes onto a finite set of levels.
Quantization errorDifference between an input amplitude and its selected reconstruction level.
Render blockFinite group of consecutive sample-frames processed together.
Render quantumWeb Audio's processing group of consecutive frames.
SampleOne channel's amplitude value at one indexed instant.
Sample-frameSet containing one simultaneous sample for every channel.
Sample indexInteger position of one frame in a sequence.
Unit generatorSmall reusable numerical processor connected with others to form an instrument.

IMPLEMENTATION NOTEBOOK

Chapter 5 source and generated output

Each website-owned generator appears beside deterministic output. The browser lab displays its exact executed TypeScript above. Hashes are recorded in the implementation manifest.

SOURCE AND OUTPUT

PCM figures and listening study

The melody and every tone are project-authored. Fixed gain keeps playback conservative.

Output

Sixteen sample stems form two cycles of a 1 kHz sine at 8 kHz.
Sixteen ordered values at 8 kHz.

Long description. Indices zero through fifteen form two repeated eight-value cycles. Each cycle rises from zero to one, returns through zero, falls to minus one, and returns toward zero.

A 440 Hz reference is compared with three-bit, five-bit, and eight-bit quantized versions.
One waveform at three bit depths.

Long description. Three panels show the same 440 Hz reference. Visible steps are broad at three bits, smaller at five bits, and nearly cover the reference at eight bits.

Source

ch05_digital_audio.py

assets/figures/src/ch05_digital_audio.pyPython

#!/usr/bin/env python3
"""Generate Chapter 5 figures and project-authored PCM listening studies."""
from pathlib import Path
import wave

import matplotlib.pyplot as plt
import numpy as np

plt.rcParams["svg.hashsalt"] = "contrapunk-ch05-study"
ROOT = Path(__file__).resolve().parents[3]
FIG = ROOT / "assets/figures/svg"
AUDIO = ROOT / "assets/audio/ch05"
FIG.mkdir(parents=True, exist_ok=True)
AUDIO.mkdir(parents=True, exist_ok=True)
FS = 48_000
RED = "#7f1d1d"
TEAL = "#0f6f70"


def save(name):
    path = FIG / name
    plt.tight_layout()
    plt.savefig(path, format="svg", metadata={"Date": None})
    plt.close()
    path.write_text("\n".join(line.rstrip() for line in path.read_text().splitlines()) + "\n")


def quantize_mid_rise(values, bits, full_scale=1.0):
    levels = 2 ** bits
    step = 2 * full_scale / levels
    codes = np.clip(np.floor((values + full_scale) / step), 0, levels - 1)
    return -full_scale + (codes + .5) * step


def tone(frequency, seconds, bits):
    frames = round(seconds * FS)
    time = np.arange(frames) / FS
    raw = .7 * np.sin(2 * np.pi * frequency * time)
    quantized = quantize_mid_rise(raw, bits)
    fade_frames = max(1, round(.006 * FS))
    envelope = np.ones(frames)
    envelope[:fade_frames] = np.linspace(0, 1, fade_frames, endpoint=False)
    envelope[-fade_frames:] = np.linspace(1, 0, fade_frames, endpoint=True)
    return .18 * quantized * envelope


# The opening sequence: exactly two cycles of 1 kHz at 8 kHz.
n = np.arange(16)
values = np.sin(2 * np.pi * 1_000 * n / 8_000)
fig, ax = plt.subplots(figsize=(9, 4))
ax.stem(n, values, linefmt=RED, markerfmt="o", basefmt="k-")
for index, value in zip(n, values):
    ax.text(index, value + (.11 if value >= 0 else -.16), f"{value:.3f}", ha="center", fontsize=7)
ax.set(xlabel="Sample n", ylabel="Value", ylim=(-1.25, 1.25), title="Sixteen numbers become two cycles of a 1 kHz tone at 8 kHz")
ax.grid(alpha=.2)
save("ch05-number-sequence.svg")

# One waveform under three quantizers.
time = np.arange(round(.012 * FS)) / FS
reference = .7 * np.sin(2 * np.pi * 440 * time)
fig, axes = plt.subplots(3, 1, figsize=(9, 7), sharex=True)
for ax, bits in zip(axes, [3, 5, 8]):
    ax.plot(time * 1_000, reference, color="#9b9188", linewidth=1, label="reference")
    ax.step(time * 1_000, quantize_mid_rise(reference, bits), where="post", color=TEAL, linewidth=1.2, label=f"{bits}-bit")
    ax.set(ylabel="Amplitude", ylim=(-.9, .9), title=f"{bits} bits: {2 ** bits} levels")
    ax.grid(alpha=.2)
    ax.legend(loc="upper right")
axes[-1].set_xlabel("Time (ms)")
save("ch05-quantized-waveforms.svg")

# Listening sequence: same 440 Hz tone at 3, 5, 8, and 16 bits.
silence = np.zeros(round(.22 * FS))
clips = []
for bits in [3, 5, 8, 16]:
    clips.extend([tone(440, .7, bits), silence])

# Original eight-note study. It copies no melody or recording.
melody = [220, 275, 330, 440, 330, 275, 247.5, 220]
for bits in [4, 12]:
    phrase = np.concatenate([tone(frequency, .24, bits) for frequency in melody])
    clips.extend([phrase, silence])

output = np.concatenate(clips)
with wave.open(str(AUDIO / "ch05-pcm-studies.wav"), "wb") as wav_file:
    wav_file.setparams((1, 2, FS, len(output), "NONE", "not compressed"))
    wav_file.writeframes((np.clip(output, -1, 1) * 32767).astype("<i2").tobytes())

assert np.allclose(values[:8], [0, np.sqrt(.5), 1, np.sqrt(.5), 0, -np.sqrt(.5), -1, -np.sqrt(.5)], atol=1e-12)
assert len(np.unique(quantize_mid_rise(np.linspace(-1, 1, 1000, endpoint=False), 3))) == 8
assert np.max(np.abs(output)) < .19
print("Generated Chapter 5 figures and project-authored PCM audio.")

SOURCE AND OUTPUT

Seven equation visuals

One chapter-specific generator produces the exact visuals for Equations 5.1 through 5.7.

Output

A continuous 1 kHz sine with seventeen sample stems at 8 kHz.
Equation 5.1 deterministic output.

Long description. Two cycles run from zero to 2 milliseconds. Seventeen equally spaced stems include both endpoints and repeat eight values per cycle.

Five sample instants at 48 kHz are separated by 20.833 microseconds.
Equation 5.2 deterministic output.

Long description. Indices zero through four occur at 0, 20.833, 41.667, 62.5, and 83.333 microseconds.

At 8 kHz, a 4 kHz sine yields zeros while a cosine alternates signs.
Equation 5.3 deterministic output.

Long description. At the equality boundary, sine phase produces eight zeros while cosine phase produces alternating plus-one and minus-one values.

Eight reconstruction levels from minus 0.875 to plus 0.875 are separated by 0.25.
Equation 5.4 deterministic output.

Long description. Eight horizontal midpoint levels span full scale. A marked interval shows the constant 0.25 step.

A three-bit mid-rise staircase and error plot show overload outside plus or minus one.
Equation 5.5 deterministic output.

Long description. The upper staircase follows a diagonal reference inside full scale and saturates outside it. The lower error stays within half a step before overload.

Four buffer durations end one sample interval after their final sample instants.
Equation 5.6 deterministic output.

Long description. Bars compare one frame, 128 frames, and two one-second buffers. Red marks sit just before the duration endpoints.

Five contiguous 128-frame blocks begin at 100 milliseconds.
Equation 5.7 deterministic output.

Long description. Five alternating rectangles each last 2.667 milliseconds and touch without gaps or overlaps.

Source

formula_visuals_ch05.py

assets/figures/src/formula_visuals_ch05.pyPython

#!/usr/bin/env python3
"""Generate deterministic visuals for Chapter 5 equations."""
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np

plt.rcParams["svg.hashsalt"] = "contrapunk-ch05-formulas"
ROOT = Path(__file__).resolve().parents[3]
OUT = ROOT / "assets/figures/svg"
OUT.mkdir(parents=True, exist_ok=True)
RED = "#7f1d1d"
TEAL = "#0f6f70"
GOLD = "#a56a12"
INK = "#211b17"


def save(name):
    path = OUT / name
    plt.tight_layout()
    plt.savefig(path, format="svg", metadata={"Date": None})
    plt.close()
    path.write_text("\n".join(line.rstrip() for line in path.read_text().splitlines()) + "\n")


# Equation 5.1: sample a continuous-time model.
fs = 8_000
frequency = 1_000
n = np.arange(17)
t_samples = n / fs
t = np.linspace(0, 0.002, 800)
fig, ax = plt.subplots(figsize=(9, 4))
ax.plot(t * 1_000, np.sin(2 * np.pi * frequency * t), color="#8b8178", label="$x_c(t)$")
markerline, stemlines, _ = ax.stem(t_samples * 1_000, np.sin(2 * np.pi * frequency * t_samples), basefmt="k-")
plt.setp(stemlines, color=RED, linewidth=1.4)
plt.setp(markerline, color=RED, markerfacecolor=RED)
ax.set(xlabel="Time (ms)", ylabel="Amplitude", title="Eq. 5.1: 1 kHz sampled at 8 kHz", xlim=(0, 2), ylim=(-1.12, 1.12))
ax.legend()
ax.grid(alpha=.2)
save("eq-5-1-sampling.svg")

# Equation 5.2: index-to-time conversion.
indices = np.arange(5)
times_us = indices / 48_000 * 1_000_000
fig, ax = plt.subplots(figsize=(9, 3.8))
ax.hlines(0, 0, 90, color=INK, linewidth=1)
ax.scatter(times_us, np.zeros_like(times_us), color=TEAL, s=70, zorder=3)
for index, time_us in zip(indices, times_us):
    ax.annotate(f"n={index}\n{time_us:.3f} µs", (time_us, 0), xytext=(0, 16), textcoords="offset points", ha="center", fontsize=9)
ax.annotate("Δt = 20.833 µs", xy=(times_us[1], -.02), xytext=(times_us[0], -.24), arrowprops={"arrowstyle": "<->", "color": RED}, color=RED)
ax.set(xlabel="Time (µs)", title="Eq. 5.2: the first five sample instants at 48 kHz", xlim=(-3, 90), ylim=(-.35, .35), yticks=[])
save("eq-5-2-sample-spacing.svg")

# Equation 5.3: strict Nyquist boundary and phase ambiguity at equality.
fs = 8_000
n = np.arange(8)
fig, axes = plt.subplots(2, 1, figsize=(9, 5.5), sharex=True)
axes[0].stem(n, np.sin(2 * np.pi * 4_000 * n / fs), linefmt=RED, markerfmt="o", basefmt="k-")
axes[0].set(ylabel="Sine samples", ylim=(-1.15, 1.15), title="Eq. 5.3: at 8 kHz, 4 kHz is the unsafe equality boundary")
axes[1].stem(n, np.cos(2 * np.pi * 4_000 * n / fs), linefmt=TEAL, markerfmt="s", basefmt="k-")
axes[1].set(xlabel="Sample n", ylabel="Cosine samples", ylim=(-1.15, 1.15))
for ax in axes:
    ax.grid(alpha=.2)
fig.text(.99, .01, "3 kHz: condition met   ·   4 kHz: equality   ·   5 kHz: condition not met", ha="right", color=INK, fontsize=9)
save("eq-5-3-nyquist-boundary.svg")

# Equation 5.4: level count and quantization step.
peak = 1
bits = 3
levels = 2 ** bits
step = 2 * peak / levels
reconstruction = -peak + (np.arange(levels) + .5) * step
fig, ax = plt.subplots(figsize=(9, 4))
ax.hlines(reconstruction, -1, 1, color=TEAL, linewidth=2)
for value in reconstruction:
    ax.text(1.02, value, f"{value:g}", va="center", fontsize=8)
ax.annotate("Δ = 0.25", xy=(-.72, reconstruction[4]), xytext=(-.72, reconstruction[5]), arrowprops={"arrowstyle": "<->", "color": RED}, color=RED, ha="center", va="center")
ax.set(xlabel="Input range", ylabel="Reconstruction level", title="Eq. 5.4: B=3 gives 8 levels across A=±1", xlim=(-1.08, 1.16), ylim=(-1.05, 1.05), xticks=[-1, 0, 1])
ax.grid(axis="x", alpha=.2)
save("eq-5-4-quantization-step.svg")

# Equation 5.5: explicit mid-rise quantizer and bounded in-range error.
def quantize_mid_rise(values, bit_depth, full_scale=1.0):
    level_count = 2 ** bit_depth
    delta = 2 * full_scale / level_count
    code = np.floor((values + full_scale) / delta)
    code = np.clip(code, 0, level_count - 1)
    return -full_scale + (code + .5) * delta

x = np.linspace(-1.2, 1.2, 1_201)
q = quantize_mid_rise(x, bits)
error = x - q
fig, axes = plt.subplots(2, 1, figsize=(9, 6), sharex=True)
axes[0].plot(x, x, color="#8b8178", linestyle="--", label="ideal y=x")
axes[0].step(x, q, where="post", color=TEAL, label="$Q_B(x)$")
axes[0].axvspan(-1.2, -1, color=RED, alpha=.1)
axes[0].axvspan(1, 1.2, color=RED, alpha=.1)
axes[0].set(ylabel="Quantized output", title="Eq. 5.5: 3-bit uniform mid-rise teaching quantizer")
axes[0].legend(loc="upper left")
axes[1].plot(x, error, color=GOLD)
axes[1].axhline(step / 2, color=RED, linestyle="--", label="±Δ/2")
axes[1].axhline(-step / 2, color=RED, linestyle="--")
axes[1].set(xlabel="Input x", ylabel="Error x − Q(x)", ylim=(-.38, .38))
axes[1].legend(loc="upper left")
for ax in axes:
    ax.grid(alpha=.2)
save("eq-5-5-quantization-error.svg")

# Equation 5.6: buffer duration versus final sample instant.
rows = [(1, 48_000), (128, 48_000), (48_000, 48_000), (44_100, 44_100)]
fig, ax = plt.subplots(figsize=(9, 4.4))
labels = []
for row, (frames, rate) in enumerate(rows):
    duration_ms = frames / rate * 1_000
    last_ms = (frames - 1) / rate * 1_000
    ax.barh(row, duration_ms, color=TEAL, alpha=.7)
    ax.plot(last_ms, row, marker="|", color=RED, markersize=16, markeredgewidth=2)
    labels.append(f"N={frames:,}, Fs={rate:,} Hz")
    ax.text(duration_ms, row, f"  {duration_ms:.4g} ms", va="center", fontsize=9)
ax.set(xscale="symlog", xlabel="Buffer duration (ms, symmetric-log scale)", ylabel="Case", yticks=range(len(rows)), yticklabels=labels, title="Eq. 5.6: duration is N/Fs; red marks the last sample instant")
ax.grid(axis="x", alpha=.2)
save("eq-5-6-buffer-duration.svg")

# Equation 5.7: five contiguous render blocks.
fs = 48_000
block = 128
block_ms = block / fs * 1_000
starts = 100 + np.arange(5) * block_ms
fig, ax = plt.subplots(figsize=(9, 3.5))
for index, start in enumerate(starts):
    ax.broken_barh([(start, block_ms)], (.2, .6), facecolors=TEAL if index % 2 == 0 else GOLD)
    ax.text(start + block_ms / 2, .5, f"k={index}\n{start:.3f} ms", ha="center", va="center", color="white", fontsize=8)
ax.set(xlabel="Audio-clock time (ms)", yticks=[], ylim=(0, 1), xlim=(99.5, starts[-1] + block_ms + .5), title="Eq. 5.7: five 128-frame blocks at 48 kHz")
ax.grid(axis="x", alpha=.2)
save("eq-5-7-block-schedule.svg")

assert np.allclose(np.sin(2 * np.pi * 1_000 * np.arange(8) / 8_000), [0, np.sqrt(.5), 1, np.sqrt(.5), 0, -np.sqrt(.5), -1, -np.sqrt(.5)], atol=1e-12)
assert levels == 8 and step == .25
assert np.max(np.abs(error[(x >= -1) & (x < 1)])) <= step / 2 + 1e-12
assert np.isclose(block_ms, 8 / 3)
print("Generated Chapter 5 formula visuals.")

SOURCE AND OUTPUT

From score to sound

Two tested Mermaid sources show the historical conversion path and a minimal unit-generator graph.

Output

Score and unit generators flow through sample calculation, buffering, conversion, filtering, and a loudspeaker.
Historical numbers-to-sound path.

Long description. A left-to-right chain moves from score parameters and unit generators through computed samples, digital tape and buffer, a converter, then a smoothing filter and loudspeaker.

Score parameters drive oscillator and envelope generators before multiplication and output.
Minimal unit-generator graph.

Long description. Score parameters branch to an oscillator and envelope. Their outputs meet at a multiplier, then move through an output block to the audio destination.

Source

ch05-numbers-to-sound.mmd

assets/diagrams/src/ch05-numbers-to-sound.mmdMermaid

flowchart LR
  A["Score<br/>note parameters"] --> B["Instrument<br/>unit generators"]
  B --> C["Computer calculates<br/>sample numbers"]
  C --> D["Digital tape<br/>and buffer"]
  D --> E["Digital-to-analog<br/>converter"]
  E --> F["Smoothing filter<br/>and loudspeaker"]
ch05-unit-generator-graph.mmd

assets/diagrams/src/ch05-unit-generator-graph.mmdMermaid

flowchart LR
  P["Score parameters"] --> O["Oscillator"]
  P --> E["Envelope"]
  O --> M["Multiply"]
  E --> M
  M --> B["Output block"]
  B --> D["Audio destination"]

Chapter 5 Answers and Fault Invariants

Mathematical-practice answers

  1. Δt=1/48000=0.0000208333 second, or 20.8333 microseconds.
  2. Index 47,999 occurs at 47999/480000.9999791667 second. The 48,000-frame buffer lasts exactly one second.
  3. Half of 44,100 Hz is 22,050 Hz. The 22,049 Hz component meets the strict condition. The 22,050 Hz and 22,051 Hz components do not.
  4. L=8 and Δ=0.25. Input -0.62 maps to -0.625. Error is 0.62(0.625)=0.005.
  5. Zero is the boundary between the negative and positive center bins. The floor and half-open convention selects the positive bin, whose midpoint is 0.125. A zero output would define a different quantizer.
  6. 0.12501×48000=6000.48 frames. “At least” requires rounding upward, so allocate 6,001 frames.
  7. One block lasts 128/48000=0.00266667 second. Block 7 starts at 0.050+7(128/48000)=0.0686667 second.
  8. A valid response records every displayed parameter and links one visible feature to the formula. Examples include eight samples per cycle in Equation 5.1, 20.833-microsecond spacing in Equation 5.2, phase-dependent equality in Equation 5.3, eight midpoint levels in Equation 5.4, bounded in-range error in Equation 5.5, one interval after the final sample marker in Equation 5.6, and touching blocks in Equation 5.7.

Readiness answers

  1. The list needs an order, a presentation rate, and an amplitude meaning.
  2. A sample is one channel value. A sample-frame contains one simultaneous sample for every channel.
  3. At exactly half the rate, phase can make a sine produce only zeros while a cosine alternates signs. Equality does not guarantee recovery.
  4. Sampling selects time instants. Quantization selects from allowed amplitude levels.
  5. Three bits provide 23=8 codes. A mid-rise model puts zero on a decision boundary, so its reconstruction levels lie on either side.
  6. The bound stops outside the declared non-overloaded interval. Clipping can make error larger than half a step.
  7. The last sample begins the final frame interval. That interval extends from (N1)/Fs to N/Fs.
  8. It must inspect the channel-array length supplied for the current processing call.
  9. Reset phase creates a discontinuity or an unwanted periodic restart. One sustained signal needs continuous state.
  10. A signal graph shows value dependencies and routing. Program control flow shows execution decisions and statement order.
  11. Both connect small sound-processing units into instruments. MUSIC V was a historical offline, multi-pass program; Web Audio is a browser API with its own nodes, renderer, timing, and floating-point representation.

Faded-station invariants

  1. 12000<24000, so the strict condition is met.
  2. 108,000 frames.
  3. 107,999.
  4. 107999/480002.24997917 seconds.
  5. 2.25 seconds.
  6. 843 full groups, with 96 frames remaining.
  7. 0.25.
  8. Input 0.51 maps to 0.625, giving error -0.115.
  9. Valid examples include oscillator phase, envelope position, filter memory, note state, and a continuous sample counter.
  10. Stereo adds another value to each frame. It does not add another time instant.

Listening and song-study invariants

  • The explicit quantizer changes sample values. It does not change Web Audio's native floating-point processing representation.
  • Lower bit depth produces fewer amplitude levels and larger steps.
  • All Code Steps versions retain the same pitch order, note durations, and phrase contour.
  • A plot can verify numerical levels and discontinuities. Words such as rough, bright, thin, or grainy remain listener descriptions.
  • An acceptable third render changes only the return phrase's quantizer and documents unchanged event data.

Fault-station invariant

  • Two 48 kHz channels still share a 48 kHz frame rate.
  • A 48,000-frame buffer has indices zero through 47,999. Index 48,000 belongs after that buffer.
  • The safe classroom condition is strict. A 24 kHz sinusoid at 48 kHz lies on the unsafe boundary.
  • Three bits give eight levels, not nine. This mid-rise model has no zero level.
  • Web Audio uses floating-point processing values. The 3-bit quantizer is deliberately inserted.
  • The half-step error bound applies only before overload. Clipping can exceed it.
  • Processing code inspects the supplied array length instead of assuming 128.
  • Oscillator phase and other generator state continue across calls.
  • Block duration is processing granularity, not total listener latency.
  • Graph arrows show numerical signal flow, not JavaScript statement order.