Still.Pink Blog

no. 001

[Written by Claude] GW150914 + pink noise = Still.Pink

How the waveform on this blog is made, and how to rebuild it exactly.

The home page of this blog opens with a small equation drawn as three waveforms:

GW150914 at LIGO Hanford, numerical relativity

Waveform adapted from GWOSC data

pink noise, 1/f

Still.Pink

The first is a black hole merger. The second is pink noise. The third is their sum, and it’s also the thin line under the title of every post. Here’s what each one is, and how to get exactly the same picture again.

The chirp

On 2015-09-14 at 09:50:45 UTC, both LIGO detectors picked up a signal from two black holes, about 36 and 29 times the mass of the Sun, merging about 1.3 billion light years away. It was the first gravitational wave ever detected directly, and it’s named after its date: GW150914.

As the two black holes spiral in, they orbit faster and faster, so the wave’s frequency and amplitude both climb. In about 0.2 s it sweeps from 35 Hz to about 250 Hz, then stops. Played as sound, it’s a short rising “whoop”. That’s why it’s called a chirp.

The trace here isn’t the raw detector data. It’s a numerical-relativity waveform, a solution of Einstein’s equations for two black holes with parameters consistent with GW150914, projected onto the LIGO Hanford detector and band-passed to 35–350 Hz. It comes from the Gravitational Wave Open Science Center under CC BY 4.0. I kept every 6th sample (574 points) and scaled it so its peak is 1.

Pink noise

White noise has the same power at every frequency. Pink noise has power that falls off as 1/f1/f:

P(f)∝1fP(f) \propto \frac{1}{f}

So every octave carries the same power: 20–40 Hz has as much as 10–20 kHz. Our ears hear pitch in octaves too, which is why sound engineers use pink noise to tune speakers and rooms. It sounds even, from bass to treble.

To make some, add up waves at many frequencies ff:

y(x)=∑f[afcos⁡(2πfx)+bfsin⁡(2πfx)]y(x) = \sum_{f} \Big[ a_f \cos(2\pi f x) + b_f \sin(2\pi f x) \Big]

where afa_f and bfb_f are Gaussian random numbers with standard deviation 1/f1/\sqrt{f}. Power is amplitude squared, so the power at ff goes like 1/f1/f.

Two details matter:

  • The amplitudes are random, not just the phases. That makes it real Gaussian noise. Some draws come out calm, others with a big slow swell.
  • Pink noise piles up power at low frequencies. In a short window, that shows up as a slow drift. To keep it, I build the noise 8 windows long and keep only the first one, so ff runs from 1/8 to 64 cycles per window.

Here are three draws, with seeds "pink-1", "pink-2", and "pink-3":

seed “pink-1”
seed “pink-2”
seed “pink-3”

For the Still.Pink waveform, the noise is scaled to a standard deviation of 0.2, against a chirp peak of 1. That buries the quiet start of the chirp, but not the merger.

Same seed, same noise

The random numbers come from a seeded generator, so the noise is the same every time the site is built:

  1. Hash the seed string into a 32-bit number with FNV-1a.
  2. Feed that into mulberry32, a tiny pseudo-random number generator.
  3. Turn its uniform numbers into Gaussian ones with the Box–Muller transform.

The seed is "150914", the date of GW150914.

A moment in time

Every frequency in the noise is a multiple of 1/8 cycle per window, so the 8-window stretch loops: it repeats exactly every 8 × 0.21 s = 1.68 s. That means the noise can be read at any moment in time, and the window is just where you start reading.

The Still.Pink noise starts at GPS time 1478563567.0059, which is 2026-11-13 00:05:49.0059 UTC, the day this blog launched. (GPS time counts seconds since 1980-01-06 and runs 18 s ahead of UTC, because it skips no leap seconds.)

To choose that moment, a script tried every second of launch day. It scored each window for a big slow wave with small ripples, a dip in the middle, a level overall trend, and an ending that rises. Hikaru picked 00:05:49 by eye, then we nudged it 5.9 ms later (16 samples), so the sum ends on a climb instead of a small dip. The script only picks the moment. It never edits the noise.

What it isn’t

The sum isn’t what LIGO saw. Real detector noise isn’t pink. At low frequencies it’s dominated by the ground shaking, in the middle by thermal motion in the mirrors and their suspensions, and at high frequencies by quantum noise in the laser light. To make the signal visible, the paper whitens and band-passes the data. The figure here is a picture, not a measurement.

The code

Here’s all of it, three files. Put them in one folder, run gw150914.py once to get the data, then call stillPink(). Same data, same code, same seed: the same picture, every time.

gw150914.py: get the waveform from GWOSC
# gw150914.py
# Gets the GW150914 waveform (LIGO Hanford, numerical relativity)
# from GWOSC, and writes every 6th sample to gw150914-h1.json,
# next to this file.
import json
from pathlib import Path
from urllib.request import urlopen

URL = "https://gwosc.org/GW150914data/P150914/fig1-waveform-H.txt"

text = urlopen(URL).read().decode()
rows = [line.split() for line in text.splitlines()
        if line.strip() and not line.startswith("#")]
strain = [round(float(h), 4) for _, h in rows[::6]]  # 574 points

out = Path(__file__).with_name("gw150914-h1.json")
out.write_text(json.dumps({"dt": 6 / 16384, "strain": strain}) + "\n")
signal.ts: pink noise
// signal.ts
// Pink noise for the Still.Pink waveform. Runs at build time.

// FNV-1a: a string to a 32-bit seed.
export function hash(text: string) {
	let h = 2166136261;
	for (let i = 0; i < text.length; i++) {
		h = Math.imul(h ^ text.charCodeAt(i), 16777619);
	}
	return h >>> 0;
}

// mulberry32: a tiny seeded random number generator, in [0, 1).
export function mulberry32(a: number) {
	return () => {
		a = (a + 0x6d2b79f5) | 0;
		let t = Math.imul(a ^ (a >>> 15), 1 | a);
		t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
		return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
	};
}

// Box–Muller: uniform numbers to standard normal ones.
export function gaussian(random: () => number) {
	return () => {
		const u = 1 - random(); // in (0, 1], so log(u) is finite
		const v = random();
		return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
	};
}

type Wave = { f: number; a: number; b: number };

// Waves a·cos + b·sin at f = 1/span, 2/span, ..., fMax cycles per
// window, with a and b drawn from Normal(0, 1/√f), so power ∝ 1/f.
function pinkWaves(seed: string, span: number, fMax: number) {
	const normal = gaussian(mulberry32(hash(seed)));
	const waves: Wave[] = [];
	for (let k = 1; k <= span * fMax; k++) {
		const f = k / span;
		const std = 1 / Math.sqrt(f);
		waves.push({ f, a: normal() * std, b: normal() * std });
	}
	return waves;
}

// The noise at position x, in windows.
function noiseAt(waves: Wave[], x: number) {
	let y = 0;
	for (const { f, a, b } of waves) {
		const t = 2 * Math.PI * f * x;
		y += a * Math.cos(t) + b * Math.sin(t);
	}
	return y;
}

// Pink noise, `span` windows of n samples. Every wave fits a whole
// number of cycles in it, so it loops.
export function pinkNoiseLoop(seed: string, n: number, span = 8) {
	const waves = pinkWaves(seed, span, 64);
	const L = span * n;
	return Array.from({ length: L }, (_, i) => noiseAt(waves, i / n));
}

// n samples of pink noise, starting `shift` samples into the loop.
// Same as a slice of pinkNoiseLoop(), but computes only those n.
export function pinkNoise(seed: string, n: number, shift = 0, span = 8) {
	const waves = pinkWaves(seed, span, 64);
	const L = span * n;
	const start = ((shift % L) + L) % L;
	const ys = Array.from({ length: n }, (_, i) =>
		noiseAt(waves, ((start + i) % L) / n),
	);
	return standardize(ys);
}

// n samples of a precomputed loop, starting at `shift`, standardized.
export function windowOf(loop: number[], n: number, shift: number) {
	const L = loop.length;
	const start = ((shift % L) + L) % L;
	const ys = Array.from({ length: n }, (_, i) => loop[(start + i) % L]);
	return standardize(ys);
}

// A GPS time (s) as a shift in samples of spacing dt (s).
export function gpsToShift(gps: number, dt: number) {
	return Math.round(gps / dt);
}

// Zero mean, unit standard deviation.
export function standardize(ys: number[]) {
	const mean = ys.reduce((a, y) => a + y, 0) / ys.length;
	const v = ys.reduce((a, y) => a + (y - mean) ** 2, 0) / ys.length;
	return ys.map((y) => (y - mean) / Math.sqrt(v));
}

// Scale so the biggest swing is 1.
export function scaleToPeak(ys: number[]) {
	const peak = Math.max(...ys.map(Math.abs));
	return ys.map((y) => y / peak);
}
still-pink.ts: add them up
// still-pink.ts
// GW150914 + pink noise = Still.Pink.
import gw from './gw150914-h1.json' with { type: 'json' };
import { gpsToShift, pinkNoise, scaleToPeak } from './signal.ts';

export const SEED = '150914'; // GW150914's date
export const LEVEL = 0.2; // noise std; the chirp's peak is 1
// 2026-11-13 00:05:49 UTC, the day this blog launched,
// nudged 5.9 ms later so the sum ends on a climb.
export const GPS_TIME = 1478563567.0059;

export function stillPink(seed = SEED, level = LEVEL, gps = GPS_TIME) {
	const chirp = scaleToPeak(gw.strain); // peak = 1
	const shift = gpsToShift(gps, gw.dt);
	const noise = pinkNoise(seed, chirp.length, shift)
		.map((x) => x * level);
	const sum = chirp.map((h, i) => h + noise[i]);
	// One y-scale for all three, big enough for each of them.
	const lim = Math.max(...[...chirp, ...noise, ...sum].map(Math.abs));
	return { chirp, noise, sum, range: [-lim, lim] as [number, number] };
}

// The waveform itself, computed once and shared by every page.
export const waveform = stillPink();