#!/usr/bin/env python3
"""
verify.py :: the fork that kept the time
========================================

Reproduces every number the layer at /strata/the-fork-that-kept-the-time/ makes,
from the First Sounds released audio (CC-BY, https://www.firstsounds.org/).

Two showings:

  A) The 1859 diapason MP3, measured three ways, matches the 1859 Diapason
     Normal (435.45 Hz) to within a fraction of a Hertz.

  B) Item 36 (Au Clair de la Lune, 9 April 1860, "May 2010 release") and
     item 46 (vocal scale, 17 May 1860, from Feaster's ARSC discography numbering),
     measured on their released speed (calibrated by First Sounds against Scott's
     tuning-fork trace *assuming that fork was 250 Hz*), have a voice fundamental
     centred on adult-male values. Doubling the speed (which is what a 500 Hz
     fork assumption implies) doubles the recovered pitch, into the young-girl
     range that First Sounds first unveiled in March 2008, and corrected in
     June 2009.

Nothing here reinterprets Scott's original tracings; it takes the released
audio at face value and measures it. The claim is exactly that: the sex of the
first recorded human voice hinges entirely on one number about a fork.

Reproduce (needs ~2 MB of audio download + ffmpeg + numpy/scipy):

    python3 verify.py            # runs the full pipeline
    python3 verify.py --json     # emits the JSON the page reads

Sources (attribution required by First Sounds' CC-BY licence):
  * Édouard-Léon Scott de Martinville, phonautograms 1857–1860 (Paris).
  * First Sounds collaborative (D. Giovannoni, P. Feaster, R. Nowak, M. Wittje,
    E. Cornell), 2008–2010 editions.
  * Feaster, "Édouard-Léon Scott: An Annotated Discography," ARSC Journal
    41:1 (Spring 2010) 43–82.

Every fact the layer states about *history* is sourced from that discography;
every fact it states about *the audio* is recomputed by this script.
"""

import argparse
import json
import os
import subprocess
import sys
import urllib.request
from pathlib import Path

import numpy as np
from scipy.io import wavfile
from scipy.signal import butter, sosfiltfilt, welch


HERE = Path(__file__).resolve().parent
CACHE = HERE / ".audio-cache"

# The four First Sounds releases we measure. URLs are stable at firstsounds.org;
# checksums fixed at the sizes First Sounds served on 2026-08-20.
RELEASES = [
    {
        "slug": "diapason-1859",
        "label": "1859 Scott diapason (item 33)",
        "url": "https://www.firstsounds.org/sounds/1859-Scott-Diapason-435-Hz.mp3",
        "kind": "fork",
    },
    {
        "slug": "au-clair-2010",
        "label": "Au Clair de la Lune, 9 April 1860 (item 36, May 2010 release)",
        "url": "https://www.firstsounds.org/sounds/Scott-Feaster-No-36.mp3",
        "kind": "voice",
    },
    {
        "slug": "vocal-scale-1860",
        "label": "Vocal scale, 17 May 1860 (item 46)",
        "url": "https://www.firstsounds.org/sounds/1860-Vocal-Scale-05-09.mp3",
        "kind": "voice",
    },
    {
        "slug": "vole-petite-abeille",
        "label": "Vole, petite abeille, 17 April 1860 (item 38)",
        "url": "https://www.firstsounds.org/sounds/1860-Scott-Vole-Petite-Abeille.mp3",
        "kind": "voice",
    },
]


def download(url: str, dest: Path) -> None:
    if dest.exists() and dest.stat().st_size > 1024:
        return
    dest.parent.mkdir(parents=True, exist_ok=True)
    print(f"  fetch {url}", file=sys.stderr)
    req = urllib.request.Request(url, headers={"User-Agent": "artwaste.land/verify"})
    with urllib.request.urlopen(req, timeout=60) as r, dest.open("wb") as f:
        f.write(r.read())


def to_wav(mp3: Path, wav: Path) -> None:
    if wav.exists() and wav.stat().st_mtime >= mp3.stat().st_mtime:
        return
    subprocess.run(
        ["ffmpeg", "-y", "-loglevel", "error", "-i", str(mp3), str(wav)],
        check=True,
    )


def load_mono(wav: Path):
    sr, x = wavfile.read(wav)
    if x.ndim > 1:
        x = x.mean(axis=1)
    return sr, x.astype(float)


def fork_cycle_count(x, sr, lo=410, hi=460, edge_s=2.0):
    """Positive-going zero crossings over the stable centre of the recording,
    scored by the *median* of consecutive intervals. Using the median (not the
    total count) makes this robust to spurious re-crossings within one cycle
    that a bare "N crossings / duration" would over-count. This is the honest
    audio analogue of the count-per-second method Feaster's discography uses
    on the paper trace itself."""
    x = x - x.mean()
    sos = butter(6, [lo, hi], "bandpass", fs=sr, output="sos")
    xf = sosfiltfilt(sos, x)
    n0, n1 = int(edge_s * sr), int(len(xf) - edge_s * sr)
    seg = xf[n0:n1]
    zc = np.where((seg[:-1] < 0) & (seg[1:] >= 0))[0]
    if len(zc) < 3:
        return float("nan"), 0, 0.0
    intervals = np.diff(zc) / sr
    med = float(np.median(intervals))
    n = len(zc) - 1
    dur = (zc[-1] - zc[0]) / sr
    return 1.0 / med, n, dur


def fork_fft_peak(x, sr, lo=400, hi=480):
    x = x - x.mean()
    w = np.hanning(len(x))
    X = np.abs(np.fft.rfft(x * w))
    f = np.fft.rfftfreq(len(x), 1 / sr)
    m = (f > lo) & (f < hi)
    k_local = int(np.argmax(X[m]))
    k = int(np.where(m)[0][k_local])
    a, b, c = np.log(X[k - 1]), np.log(X[k]), np.log(X[k + 1])
    delta = 0.5 * (a - c) / (a - 2 * b + c)
    return (k + delta) * sr / len(x)


def fork_autocorr(x, sr, lo=380, hi=520):
    x = x - x.mean()
    sos = butter(4, [lo, hi], "bandpass", fs=sr, output="sos")
    xf = sosfiltfilt(sos, x)
    seg = xf[int(2 * sr): int(len(xf) - 2 * sr)]
    ac = np.correlate(seg, seg, "full")[len(seg) - 1:]
    k_lo, k_hi = int(sr / hi), int(sr / lo)
    region = ac[k_lo:k_hi]
    k = k_lo + int(np.argmax(region))
    a, b, c = ac[k - 1], ac[k], ac[k + 1]
    k = k + 0.5 * (a - c) / (a - 2 * b + c + 1e-12)
    return sr / k


def voice_f0_frames(x, sr, fmin=80, fmax=450, win_s=0.06, hop_s=0.03,
                    voice_thresh=0.15):
    """Frame-by-frame autocorrelation F0 in Hertz. Returns only voiced frames
    (those whose RMS exceeds `voice_thresh` * the overall RMS). No smoothing,
    no median filter, no reseeding: every voiced frame is one measurement."""
    x = x - x.mean()
    sos = butter(4, [70, 600], "bandpass", fs=sr, output="sos")
    xb = sosfiltfilt(sos, x)
    global_rms = float(np.sqrt((xb ** 2).mean()))
    win, hop = int(win_s * sr), int(hop_s * sr)
    out = []
    for i in range(0, len(xb) - win, hop):
        seg = xb[i: i + win]
        seg = seg - seg.mean()
        rms = float(np.sqrt((seg ** 2).mean()))
        if rms < voice_thresh * global_rms:
            continue
        ac = np.correlate(seg, seg, "full")[len(seg) - 1:]
        k_lo, k_hi = int(sr / fmax), int(sr / fmin)
        if k_hi >= len(ac):
            k_hi = len(ac) - 1
        region = ac[k_lo:k_hi]
        if len(region) == 0:
            continue
        k = k_lo + int(np.argmax(region))
        if 0 < k < len(ac) - 1:
            a, b, c = ac[k - 1], ac[k], ac[k + 1]
            denom = a - 2 * b + c
            if denom < 0:
                k = k + 0.5 * (a - c) / (denom - 1e-12)
        f0 = sr / k
        if fmin < f0 < fmax:
            out.append(f0)
    return np.array(out)


def fork_spectrum(x, sr, lo=380, hi=520, nfft=1 << 17):
    x = x - x.mean()
    f, P = welch(x, fs=sr, nperseg=nfft)
    m = (f >= lo) & (f <= hi)
    return f[m].tolist(), (P[m] / P[m].max()).tolist()


def run(emit_json: bool = False, out_json: Path = HERE / "measurements.json"):
    CACHE.mkdir(parents=True, exist_ok=True)
    results = {"releases": {}, "history": {
        "diapason_normal_koenig_hz": 435.45,
        "scott_own_count_result_hz": 435.5,   # per Feaster ARSC 41:1 (2010) p.67
        "scott_cycles_counted": 2613,
        "scott_seconds_counted": 6.0,
        "reference_first_unveil_utc": "2008-03-27",   # NYT lead
        "reference_male_unveil_utc": "2009-05-28",    # First Sounds v2 site log
        "reference_may2010_release_utc": "2010-05-25",
    }}

    for r in RELEASES:
        mp3 = CACHE / f"{r['slug']}.mp3"
        wav = CACHE / f"{r['slug']}.wav"
        download(r["url"], mp3)
        to_wav(mp3, wav)
        sr, x = load_mono(wav)
        entry = {
            "label": r["label"],
            "url": r["url"],
            "sample_rate": int(sr),
            "duration_s": round(len(x) / sr, 3),
            "kind": r["kind"],
        }
        if r["kind"] == "fork":
            f_fft = fork_fft_peak(x, sr)
            f_ac = fork_autocorr(x, sr)
            fx, py = fork_spectrum(x, sr)
            entry.update({
                "fft_peak_hz": round(float(f_fft), 3),
                "autocorr_hz": round(float(f_ac), 3),
                "spectrum_freq_hz": [round(v, 2) for v in fx],
                "spectrum_amp_rel": [round(v, 4) for v in py],
            })
            # (Zero-crossing count on the digitised MP3 is confounded by
            # sub-cycle re-crossings and gives a method-dependent number; the
            # honest audio measurements are the FFT peak and the autocorrelation,
            # and Feaster's independent count on the paper trace itself.)
            print(f"[{r['slug']}]  fork  FFT={f_fft:.2f} Hz  autocorr={f_ac:.2f} Hz  "
                  f"(Feaster's paper count 435.5 Hz | Diapason Normal 435.45 Hz)")
        else:
            f0 = voice_f0_frames(x, sr)
            if len(f0) == 0:
                entry["voice_error"] = "no voiced frames"
                print(f"[{r['slug']}]  voice  (no voiced frames)")
            else:
                # bin at 5-Hz resolution across 80..450 Hz for the JSON histogram
                edges = np.arange(80, 451, 5)
                hist, _ = np.histogram(f0, bins=edges)
                entry.update({
                    "voiced_frames": int(len(f0)),
                    "f0_median_hz": round(float(np.median(f0)), 2),
                    "f0_p25_hz": round(float(np.percentile(f0, 25)), 2),
                    "f0_p75_hz": round(float(np.percentile(f0, 75)), 2),
                    "hist_bin_hz": 5,
                    "hist_freq_lo": [int(v) for v in edges[:-1]],
                    "hist_counts": [int(v) for v in hist],
                    "if_fork_were_500_hz": {
                        "f0_median_hz": round(float(np.median(f0) * 2), 2),
                        "f0_p25_hz": round(float(np.percentile(f0, 25) * 2), 2),
                        "f0_p75_hz": round(float(np.percentile(f0, 75) * 2), 2),
                    },
                })
                m = float(np.median(f0))
                print(f"[{r['slug']}]  voice  {len(f0)} voiced frames  "
                      f"F0 median={m:.1f} Hz  IQR={np.percentile(f0,25):.0f}-{np.percentile(f0,75):.0f} Hz  "
                      f"(at fork=500 Hz assumption, median doubles to {2*m:.0f} Hz)")
        results["releases"][r["slug"]] = entry

    # A single scalar the page needs: the pitch multiplier the reader dials.
    # First Sounds' released audio was time-corrected on the assumption that
    # each fork cycle was 1/250 s. If the *true* fork was F Hz, the correction
    # stretched every real 1/F s cycle to 1/250 s, so playback rate is
    # (F / 250) times the intended rate, and every measured F0 on the released
    # audio is (F / 250) times what it would be at Scott's own intended speed.
    # At F = 500 (the 2008 assumption), F0 doubles: a low male becomes a girl.
    results["math"] = {
        "canonical_fork_hz": 250,
        "pitch_multiplier_formula": "assumed_fork_hz / 250",
        "note": ("The First Sounds released audio was time-corrected against "
                 "Scott's tuning-fork trace assuming that fork's tone was 250 Hz. "
                 "If the fork were really F Hz, the correction stretched each "
                 "cycle of the trace from 1/F s to 1/250 s, so playback runs at "
                 "F/250 times its intended rate, and every measured F0 is "
                 "F/250 times its true value. At F = 500 Hz (First Sounds' 2008 "
                 "reading), every F0 above is doubled."),
    }

    if emit_json:
        out_json.write_text(json.dumps(results, indent=2))
        print(f"\nwrote {out_json}")
    return results


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--json", action="store_true",
                    help="write measurements.json for the page to read")
    ns = ap.parse_args()
    run(emit_json=ns.json)
