Noise Generation

Building noise with the properties you specify

PSD estimation measures what is there. This page is the synthesis counterpart: how to build noise with exactly the distribution, spectrum, and spatial structure you need. Whether you are simulating a sensor for testing, generating a procedural texture, or creating controlled-noise test signals for an ADC characterisation, you need noise you can trust, and verifying that trust means knowing how the generator works.

The companion module noise_generation.py implements every method from first principles. Run pytest from the repo root to verify all claims.

Prerequisites

Noise and SNR (distributions, white vs coloured), random processes (the process classes you are simulating), and PSD estimation (how to verify the output).


Gaussian generation: Box-Muller

The Box-Muller transform (Box and Muller 1958) converts two independent uniform(0,1) variates \(u_1, u_2\) into two independent standard normal variates:

\[z_0 = \sqrt{-2\ln u_1}\,\cos(2\pi u_2), \qquad z_1 = \sqrt{-2\ln u_1}\,\sin(2\pi u_2)\]

It is exact, simple, and sufficient for the vast majority of DSP work. The Ziggurat algorithm (Marsaglia and Tsang 2000) is faster for large \(N\) and ships in NumPy’s default RNG: it partitions the Gaussian PDF into horizontal strips and accepts the vast majority of samples with a single cheap rectangular test, but Box-Muller is what you should implement when you need a generator you understand end-to-end.

x = box_muller(10000, seed=42)
fig, axes = plt.subplots(1, 2, figsize=(10, 3.5))
axes[0].plot(x[:300], 'b-', linewidth=0.3)
axes[0].set_title('Box-Muller: time domain'); axes[0].set_xlabel('n'); axes[0].grid(True, alpha=0.3)
axes[1].hist(x, bins=60, density=True, alpha=0.6, color='C0')
xr = np.linspace(-4, 4, 200)
axes[1].plot(xr, np.exp(-xr**2/2)/np.sqrt(2*np.pi), 'r-', linewidth=1.5, label='N(0,1)')
axes[1].set_title('Box-Muller: histogram'); axes[1].legend(fontsize=8); axes[1].grid(True, alpha=0.3)
fig.tight_layout(); plt.show()
assert abs(np.mean(x)) < 0.05 and 0.95 < np.std(x) < 1.05, "Should be N(0,1)"
Figure 1: Box-Muller Gaussian noise: 10,000 samples vs the theoretical N(0,1) PDF.

Coloured noise: AR filtering and frequency-domain shaping

AR(p) filtering. The Kasdin recurrence (Kasdin 1995) gives the coefficients of an all-pole filter that turns white noise into \(1/f^\alpha\) noise. For a given exponent \(\alpha\) and model order \(p\), the coefficients are:

\[\theta_i = \frac{(i - 1 - \alpha/2) \cdot \theta_{i-1}}{i}, \quad \theta_0 = 1\]

The filter \(H(z) = 1 / (1 + \sum_{i=1}^p \theta_i z^{-i})\) shapes the spectrum. Higher \(p\) gives a better fit at low frequencies.

Frequency-domain shaping. Multiply the spectrum of white noise by \(f^{-\alpha/2}\) and inverse-FFT. Simpler but produces a periodic sequence (the last sample wraps to the first).

from scipy.signal import welch

alphas = [0.0, 1.0, 2.0]
colors = ['C0', 'C3', 'C2']
n = 10000
fs = 1000

fig, axes = plt.subplots(2, 3, figsize=(12, 6))

for j, alpha in enumerate(alphas):
    x = colored_noise_ar(n, alpha=alpha, p=30, seed=42)
    axes[0, j].plot(x[:400], color=colors[j], linewidth=0.3)
    axes[0, j].set_title(f'α = {alpha:.0f}')
    axes[0, j].set_xlabel('n'); axes[0, j].grid(True, alpha=0.3)

    f, psd = welch(x, fs, nperseg=512)
    axes[1, j].loglog(f[1:], psd[1:], color=colors[j], linewidth=0.8)
    # Fit slope
    mask = f > 0
    coeffs = np.polyfit(np.log10(f[mask]), np.log10(psd[mask]), 1)
    # The caption's claim is the fitted exponent itself: -alpha within 0.2
    # (measured: -0.006 / -0.915 / -1.818 for alpha = 0 / 1 / 2).
    assert abs(coeffs[0] - (-alpha)) < 0.2, \
        f"alpha={alpha}: fitted slope {coeffs[0]:.2f}, expected ~{-alpha}"
    axes[1, j].loglog(f[1:], 10**coeffs[1] * f[1:]**coeffs[0], 'k--', linewidth=0.8, label=f'slope={coeffs[0]:.1f}')
    axes[1, j].set_xlabel('Frequency [Hz]'); axes[1, j].legend(fontsize=7); axes[1, j].grid(True, alpha=0.3)

axes[0, 0].set_ylabel('Amplitude'); axes[1, 0].set_ylabel('PSD')
fig.tight_layout(); plt.show()
Figure 2: 1/f^alpha noise for alpha = 0 (white), 1 (pink), and 2 (brown). Top: time domain. Bottom: PSD with fitted slope.

Voss-McCartney: pink noise with integer arithmetic

The Voss-McCartney algorithm (Voss’s octave-update scheme from the original \(1/f\)-music study (Voss and Clarke 1978), as refined by McCartney) generates approximate \(1/f\) noise by summing independent white noise generators running at octave-spaced update rates. One generator updates every sample, one every 2 samples, one every 4, and so on; their sum approximates a \(1/f\) spectrum. The algorithm runs in O(\(n\)) time with O(octaves) state and uses only addition: no FFT, no filter, no floating-point multiplies. This makes it uniquely suited to embedded systems.

x = voss_mccartney(10000, octaves=8, seed=42)
fig, axes = plt.subplots(1, 2, figsize=(10, 3.5))
axes[0].plot(x[:400], 'C4-', linewidth=0.3)
axes[0].set_title('Voss-McCartney (8 octaves): time domain')
axes[0].set_xlabel('n'); axes[0].grid(True, alpha=0.3)
f, psd = welch(x, 1000, nperseg=512)
axes[1].loglog(f[1:], psd[1:], 'C4-', linewidth=0.8)
coeffs = np.polyfit(np.log10(f[1:]), np.log10(psd[1:]), 1)
# "A reasonable 1/f approximation" = fitted slope near -1 (measured -1.05).
assert -1.3 < coeffs[0] < -0.7, f"expected ~1/f (slope -1), got {coeffs[0]:.2f}"
axes[1].loglog(f[1:], 10**coeffs[1] * f[1:]**coeffs[0], 'k--', linewidth=0.8, label=f'slope={coeffs[0]:.2f}')
axes[1].set_title('Voss-McCartney PSD'); axes[1].set_xlabel('Frequency [Hz]')
axes[1].legend(fontsize=7); axes[1].grid(True, alpha=0.3)
fig.tight_layout(); plt.show()
Figure 3: Voss-McCartney pink noise: time domain and PSD. 8 octaves gives a reasonable 1/f approximation with integer-friendly arithmetic.

Perlin noise: noise as a designed signal

Perlin noise Perlin (2002) is not a random process in the stochastic sense. It is a deterministic, procedurally-generated, band-limited noise field: given the same input coordinates, it always produces the same output. The randomness is in a permutation table (a seeded hash), not in the sampling. This makes it fundamentally different from every other noise type on this page: you design its spectral properties by construction rather than filtering.

How it works

  1. Lattice + gradient vectors. Define an integer lattice. At each lattice point, assign a random unit-length gradient vector via a hash of the lattice coordinates.
  2. Dot products. For a query point, find the surrounding lattice cell. Compute the dot product of each corner’s gradient vector with the offset from that corner to the query point.
  3. Fade function. Interpolate the corner dot products using a smooth kernel. Perlin’s original fade \(3t^2 - 2t^3\) was replaced in 2002 by the quintic \(6t^5 - 15t^4 + 10t^3\), which has continuous first and second derivatives at lattice boundaries, eliminating the faint directional artifact visible in the second derivative of the original kernel.
  4. Octave summation. Sum scaled copies at doubled frequencies: each octave has twice the spatial frequency and persistence times the amplitude. This builds a \(1/f\)-like spectrum by construction, not by filtering.

The DSP insight: the lattice spacing is a hard low-pass filter. No frequency above the Nyquist of the lattice grid can appear. Perlin noise is band-limited by construction, unlike filtered white noise, which always leaks some energy.

x = np.linspace(0, 10, 1000)

fig, axes = plt.subplots(1, 3, figsize=(12, 3.5))

y1 = perlin_1d(x, seed=1, octaves=1)
axes[0].plot(x, y1, 'b-', linewidth=0.7)
axes[0].set_title('1 octave'); axes[0].set_xlabel('x'); axes[0].set_ylabel('Amplitude')
axes[0].grid(True, alpha=0.3)

y4 = perlin_1d(x, seed=1, octaves=4, persistence=0.5)
axes[1].plot(x, y4, 'b-', linewidth=0.7)
axes[1].set_title('4 octaves (fBM)'); axes[1].set_xlabel('x')
axes[1].grid(True, alpha=0.3)

# Fade function
t = np.linspace(0, 1, 200)
fade_old = 3*t**2 - 2*t**3
fade_quintic = 6*t**5 - 15*t**4 + 10*t**3
axes[2].plot(t, fade_quintic, 'C0-', linewidth=1.5, label='quintic')
axes[2].plot(t, fade_old, 'C3--', linewidth=1, label='original cubic')
axes[2].set_title('Fade function'); axes[2].set_xlabel('t'); axes[2].legend(fontsize=7)
axes[2].grid(True, alpha=0.3)

fig.tight_layout(); plt.show()

# Verify: single-octave Perlin is zero at integer lattice points.
test_x = np.array([0.0, 1.0, 2.0, 3.0])
y_zero = perlin_1d(test_x, seed=0, octaves=1)
assert np.allclose(y_zero, 0.0, atol=1e-10), "Single-octave Perlin should be zero at integer lattice points"
Figure 4: Perlin noise construction. Left: single-octave base Perlin (smooth, low-frequency). Center: four octaves of fractal Brownian motion (rich detail). Right: the quintic fade function (the interpolation kernel that determines the spectral roll-off).

2-D Perlin noise

In two dimensions the same construction applies, with four corner gradients per lattice cell and bilinear interpolation in the faded coordinates. Octave-summed 2-D Perlin is the classic procedural texture: terrain heightmaps, cloud patterns, marble veins, and it teaches the spatial-frequency intuition directly.

size = 200
X, Y = np.meshgrid(np.linspace(0, 6, size), np.linspace(0, 6, size), indexing='ij')

fig, axes = plt.subplots(1, 2, figsize=(10, 4.5))
for ax, octaves, title in zip(axes, [1, 4], ['1 octave', '4 octaves (fBM)']):
    Z = perlin_2d(X, Y, seed=42, octaves=octaves, persistence=0.5)
    im = ax.imshow(Z.T, origin='lower', cmap='RdYlBu_r', extent=[0, 6, 0, 6])
    ax.set_title(title); ax.set_xlabel('x'); ax.set_ylabel('y')
    plt.colorbar(im, ax=ax, shrink=0.8)
fig.tight_layout(); plt.show()
Figure 5: 2-D Perlin noise. Left: single octave (one spatial scale). Right: four octaves (fractal Brownian motion): note the rich multi-scale detail.

Simplex noise: fewer artifacts, lower complexity

Simplex noise addresses two Perlin limitations: directional artifacts (the square lattice produces visible axis alignment) and computational complexity (\(O(2^D)\) for \(D\)-dimensional Perlin vs \(O(D^2)\) for Simplex). In 2-D, the square lattice is replaced by a triangular (simplex) grid; each point is influenced by only three gradient vectors instead of four.

size = 150
X, Y = np.meshgrid(np.linspace(0, 5, size), np.linspace(0, 5, size), indexing='ij')

fig, axes = plt.subplots(1, 2, figsize=(10, 4.5))
Z_perlin = perlin_2d(X, Y, seed=42, octaves=4, persistence=0.5)
im0 = axes[0].imshow(Z_perlin.T, origin='lower', cmap='RdYlBu_r', extent=[0, 5, 0, 5])
axes[0].set_title('Perlin 2-D (4 octaves)'); plt.colorbar(im0, ax=axes[0], shrink=0.8)

Z_simplex = simplex_2d(X, Y, seed=42)
im1 = axes[1].imshow(Z_simplex.T, origin='lower', cmap='RdYlBu_r', extent=[0, 5, 0, 5])
axes[1].set_title('Simplex 2-D (1 octave)'); plt.colorbar(im1, ax=axes[1], shrink=0.8)
fig.tight_layout(); plt.show()
Figure 6: Simplex vs Perlin noise at the same scale. Simplex shows less visible axis alignment: the triangular grid breaks the directional bias of the square Perlin lattice.

Verification: closing the loop

You generate noise to a specification. PSD estimation verifies that you got what you asked for. The verify_noise() function prints the key diagnostics: distribution moments, ACF, PSD slope, so you can confirm that your pink noise is actually pink and your Perlin field has the expected spatial spectrum.

for label, x_gen in [('Pink (AR, p=20)', colored_noise_ar(10000, alpha=1.0, p=20, seed=1)),
                     ('Voss-McCartney', voss_mccartney(10000, octaves=8, seed=1)),
                     ('Perlin 1-D (4 oct)', perlin_1d(np.linspace(0, 50, 2000), seed=1, octaves=4))]:
    verify_noise(x_gen, label=label)

=== Pink (AR, p=20) ===
  Samples: 10000
  Mean:    -0.0603
  Std:     1.0000
  Skew:    0.026
  Kurt:    0.058
  ACF(1):  0.6977
  ACF(2):  0.5990
  PSD slope: -0.89  (R²=0.97)

=== Voss-McCartney ===
  Samples: 10000
  Mean:    -0.0159
  Std:     1.0000
  Skew:    -0.006
  Kurt:    -0.036
  ACF(1):  0.7561
  ACF(2):  0.6348
  PSD slope: -1.04  (R²=0.98)

=== Perlin 1-D (4 oct) ===
  Samples: 2000
  Mean:    -0.0038
  Std:     0.1473
  Skew:    0.002
  Kurt:    -0.097
  ACF(1):  0.9875
  ACF(2):  0.9532
  PSD slope: -4.65  (R²=0.84)
Figure 7

On hardware

Generating controlled-noise test signals on a microcontroller, or a procedural terrain for an embedded display, is covered in the embedded companion, including Perlin on integer arithmetic and the Voss-McCartney algorithm’s natural fit for MCUs with no FPU.


Going further

  • Random processes: the process classes whose outputs you are simulating.
  • PSD estimation: the measurement side: verify that your generated noise matches its specification.
  • Noise whitening: the analysis counterpart, whitening removes the correlation that generation adds.
  • Dither: one of the few DSP applications where you deliberately add noise, and you need a trusted generator to do it right.

References

Box, George E. P., and Mervin E. Muller. 1958. “A Note on the Generation of Random Normal Deviates.” The Annals of Mathematical Statistics 29 (2): 610–11.
Kasdin, N. Jeremy. 1995. “Discrete Simulation of Colored Noise and Stochastic Processes and 1/f^α Power Law Noise Generation.” Proceedings of the IEEE 83 (5): 802–27. https://doi.org/10.1109/5.381848.
Marsaglia, George, and Wai Wan Tsang. 2000. “The Ziggurat Method for Generating Random Variables.” Journal of Statistical Software 5 (8): 1–7.
Perlin, Ken. 1985. “An Image Synthesizer.” ACM SIGGRAPH Computer Graphics 19 (3): 287–96.
———. 2002. “Improving Noise.” In Proceedings of the 29th Annual Conference on Computer Graphics and Interactive Techniques (SIGGRAPH 2002), 681–82.
Voss, Richard F., and John Clarke. 1978. ‘1/f Noise’ in Music: Music from 1/f Noise.” Journal of the Acoustical Society of America 63 (1): 258–63.