cd ~/html/out_audio_peaks || exit 1

# ----------------------------
# 1) plot_clanks.py (FULL FILE)
# ----------------------------
cat > plot_clanks.py <<'PYCODE'
#!/usr/bin/env python3
"""
plot_clanks.py

Reads peak_*.audio.txt files (±window around temp peaks) and produces:
  - one PNG per peak window showing:
      (1) rms + peak
      (2) peak/rms + zcr
      (3) g1 + g2
      (4) clank_score
      (5) temperature (°C) from temp.txt over same window
     with top-N candidate clanks marked as vertical dotted lines
  - candidates.csv (ranked moments across all windows)

Assumptions:
  - peak files contain lines with epoch=... and features rms, peak, zcr, g1, g2
  - temp.txt contains lines like: 1767989504,....,c=22.6&...
"""

from __future__ import annotations

import argparse
import glob
import os
import re
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple

import pandas as pd
import matplotlib.pyplot as plt


# ---------- parsing helpers ----------

def parse_epoch(line: str) -> Optional[int]:
    # Prefer epoch=... if present
    m = re.search(r"(?:^|[,&\s])epoch=(\d+)", line)
    if m:
        return int(m.group(1))
    # Otherwise take leading unix time (before first comma)
    m = re.match(r"^\s*(\d+)\s*,", line)
    if m:
        return int(m.group(1))
    return None

def parse_kv_blob(line: str) -> Dict[str, str]:
    """
    Extract numeric key=value pairs from a line.
    Works with: ...&rms=104.97&peak=234.4&zcr=0.0250&g1=...&g2=...&ms=...
    """
    out: Dict[str, str] = {}
    for m in re.finditer(r"([a-zA-Z]\w*)=([-+]?\d+(?:\.\d+)?)", line):
        out[m.group(1)] = m.group(2)
    return out


def load_peak_file(path: str) -> Tuple[Dict[str, float], pd.DataFrame]:
    """
    Returns (meta, dataframe). If no rows parse, returns empty df with expected columns.
    meta includes: peak_epoch, peak_temp_c, window_start, window_end (if header present).
    """
    meta: Dict[str, float] = {}
    rows: List[Dict[str, float]] = []

    with open(path, "r", encoding="utf-8", errors="replace") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue

            if line.startswith("# Peak"):
                # Header example:
                # # Peak 1: epoch=..., tempC=..., window=[start,end]
                m1 = re.search(r"epoch=(\d+)", line)
                m2 = re.search(r"tempC=([-+]?\d+(?:\.\d+)?)", line)
                m3 = re.search(r"window=\[(\d+),(\d+)\]", line)
                if m1:
                    meta["peak_epoch"] = float(m1.group(1))
                if m2:
                    meta["peak_temp_c"] = float(m2.group(1))
                if m3:
                    meta["window_start"] = float(m3.group(1))
                    meta["window_end"] = float(m3.group(2))
                continue

            t = parse_epoch(line)
            if t is None:
                continue

            kv = parse_kv_blob(line)

            def fget(k: str) -> Optional[float]:
                if k not in kv:
                    return None
                try:
                    return float(kv[k])
                except Exception:
                    return None

            rows.append({
                "t": float(t),
                "rms": fget("rms"),
                "peak": fget("peak"),
                "zcr": fget("zcr"),
                "g1": fget("g1"),
                "g2": fget("g2"),
                "dc": fget("dc"),
                "ms": fget("ms"),
            })

    cols = ["t", "rms", "peak", "zcr", "g1", "g2", "dc", "ms"]
    df = pd.DataFrame(rows, columns=cols)

    if df.empty:
        return meta, df

    df = df.dropna(subset=["t"]).sort_values("t")

    if "peak_epoch" in meta:
        df["t_rel"] = df["t"] - meta["peak_epoch"]
    else:
        df["t_rel"] = df["t"] - df["t"].iloc[0]

    return meta, df


def load_temp_for_window(temp_path: str, t_start: int, t_end: int, peak_epoch: int) -> pd.DataFrame:
    """
    Load temperature lines within [t_start, t_end] from temp.txt
    Expected format includes leading epoch and c=XX.X somewhere in line.
    """
    rows: List[Dict[str, float]] = []
    try:
        with open(temp_path, "r", encoding="utf-8", errors="replace") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue

                m = re.match(r"^\s*(\d+),", line)
                if not m:
                    continue
                t = int(m.group(1))
                if t < t_start or t > t_end:
                    continue

                m2 = re.search(r"(?:^|[,&\s])c=([-+]?\d+(?:\.\d+)?)", line)
                if not m2:
                    continue
                c = float(m2.group(1))

                rows.append({
                    "t": float(t),
                    "t_rel": float(t - peak_epoch),
                    "temp_c": c
                })
    except FileNotFoundError:
        return pd.DataFrame(columns=["t", "t_rel", "temp_c"])

    return pd.DataFrame(rows, columns=["t", "t_rel", "temp_c"]).sort_values("t")


# ---------- scoring ----------

def robust_z(series: pd.Series) -> pd.Series:
    """
    Robust z-score using median/MAD. Stable against spikes.
    """
    s = series.astype(float)
    med = s.median()
    mad = (s - med).abs().median()
    if mad == 0 or pd.isna(mad):
        return (s - med) * 0.0
    return 0.6745 * (s - med) / mad

def add_scores(df: pd.DataFrame) -> pd.DataFrame:
    df = df.copy()

    df["peak_over_rms"] = df["peak"] / (df["rms"].replace(0, pd.NA))

    for col in ["peak_over_rms", "zcr", "peak", "rms", "g1", "g2"]:
        if col in df.columns:
            df[f"rz_{col}"] = robust_z(df[col])

    # "Clank score": impulse + clickiness + band disturbance
    df["clank_score"] = (
        1.6 * df.get("rz_peak_over_rms", 0.0) +
        1.0 * df.get("rz_peak", 0.0) +
        0.8 * df.get("rz_zcr", 0.0) +
        0.3 * df.get("rz_g1", 0.0) +
        0.3 * df.get("rz_g2", 0.0)
    )

    # small smoothing so one-sample glitches don't dominate
    df["clank_score_s"] = df["clank_score"].rolling(3, center=True, min_periods=1).mean()
    return df


# ---------- plotting ----------

def plot_one(df: pd.DataFrame, meta: Dict[str, float], out_png: str, topn: int, temp_path: str) -> List[Dict[str, float]]:
    df = add_scores(df)
    if df.empty:
        return []

    # Load temperature for same time window (if meta provides it)
    temp_df = pd.DataFrame(columns=["t", "t_rel", "temp_c"])
    if all(k in meta for k in ["window_start", "window_end", "peak_epoch"]):
        temp_df = load_temp_for_window(
            temp_path=temp_path,
            t_start=int(meta["window_start"]),
            t_end=int(meta["window_end"]),
            peak_epoch=int(meta["peak_epoch"]),
        )

    # top candidates (per peak window)
    cand = df.nlargest(topn, "clank_score_s").copy()

    candidates: List[Dict[str, float]] = []
    for _, r in cand.iterrows():
        candidates.append({
            "peak_epoch": meta.get("peak_epoch", float("nan")),
            "peak_temp_c": meta.get("peak_temp_c", float("nan")),
            "t": float(r["t"]),
            "t_rel": float(r["t_rel"]),
            "clank_score": float(r["clank_score_s"]),
            "peak": float(r["peak"]) if pd.notna(r["peak"]) else float("nan"),
            "rms": float(r["rms"]) if pd.notna(r["rms"]) else float("nan"),
            "zcr": float(r["zcr"]) if pd.notna(r["zcr"]) else float("nan"),
            "peak_over_rms": float(r["peak_over_rms"]) if pd.notna(r["peak_over_rms"]) else float("nan"),
        })

    fig = plt.figure(figsize=(12, 11))
    ax1 = plt.subplot(5, 1, 1)
    ax2 = plt.subplot(5, 1, 2, sharex=ax1)
    ax3 = plt.subplot(5, 1, 3, sharex=ax1)
    ax4 = plt.subplot(5, 1, 4, sharex=ax1)
    ax5 = plt.subplot(5, 1, 5, sharex=ax1)

    x = df["t_rel"]

    # 1) rms + peak
    ax1.plot(x, df["rms"], label="rms")
    ax1.plot(x, df["peak"], label="peak")
    ax1.set_ylabel("amp")
    ax1.legend(loc="upper right")

    # 2) peak/rms + zcr
    ax2.plot(x, df["peak_over_rms"], label="peak/rms")
    ax2.plot(x, df["zcr"], label="zcr")
    ax2.set_ylabel("ratio / zcr")
    ax2.legend(loc="upper right")

    # 3) g1 + g2
    ax3.plot(x, df.get("g1", pd.Series([0]*len(df))), label="g1")
    ax3.plot(x, df.get("g2", pd.Series([0]*len(df))), label="g2")
    ax3.set_ylabel("g1/g2")
    ax3.legend(loc="upper right")

    # 4) clank score
    ax4.plot(x, df["clank_score_s"], label="clank_score (smoothed)")
    ax4.set_ylabel("score")
    ax4.legend(loc="upper right")

    # 5) temperature
    if not temp_df.empty:
        ax5.plot(temp_df["t_rel"], temp_df["temp_c"], label="temperature (°C)")
        ax5.legend(loc="upper right")
    ax5.set_ylabel("°C")
    ax5.set_xlabel("seconds from temperature peak (t_rel)")

    # mark the temperature peak moment (t_rel = 0) + mark candidates
    for ax in [ax1, ax2, ax3, ax4, ax5]:
        ax.axvline(0, linestyle="--", linewidth=1)

    for _, r in cand.iterrows():
        for ax in [ax1, ax2, ax3, ax4, ax5]:
            ax.axvline(r["t_rel"], linestyle=":", linewidth=1)

    title = f"{os.path.basename(out_png)} | peak_epoch={int(meta.get('peak_epoch', 0))} tempC={meta.get('peak_temp_c', float('nan')):.1f}"
    fig.suptitle(title)
    fig.tight_layout(rect=[0, 0, 1, 0.96])
    fig.savefig(out_png, dpi=140)
    plt.close(fig)

    return candidates


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--glob", default="peak_*.audio.txt", help="glob for peak window files")
    ap.add_argument("--outdir", default="plots", help="where to write pngs + csv")
    ap.add_argument("--topn", type=int, default=8, help="top N candidate clanks to mark per peak")
    ap.add_argument("--tempfile", default="temp.txt", help="temperature file (default temp.txt)")
    args = ap.parse_args()

    os.makedirs(args.outdir, exist_ok=True)

    all_candidates: List[Dict[str, float]] = []
    files = sorted(glob.glob(args.glob))
    if not files:
        raise SystemExit(f"No files matched: {args.glob}")

    wrote = 0
    for path in files:
        meta, df = load_peak_file(path)
        if df.empty:
            print(f"SKIP (no parsed rows): {path}")
            continue

        base = os.path.splitext(os.path.basename(path))[0]
        out_png = os.path.join(args.outdir, base + ".png")
        cands = plot_one(df, meta, out_png, args.topn, args.tempfile)
        all_candidates.extend(cands)
        wrote += 1

    cand_df = pd.DataFrame(all_candidates)
    if not cand_df.empty:
        cand_df = cand_df.sort_values("clank_score", ascending=False)
        cand_df.to_csv(os.path.join(args.outdir, "candidates.csv"), index=False)

    print(f"Wrote {wrote} plots to: {args.outdir}")
    if not cand_df.empty:
        print("Top 10 candidate clanks overall:")
        cols = ["peak_epoch", "peak_temp_c", "t_rel", "clank_score", "peak_over_rms", "zcr"]
        print(cand_df.head(10)[cols].to_string(index=False))


if __name__ == "__main__":
    main()
PYCODE

chmod +x plot_clanks.py

# ----------------------------
# 2) plot_clanks.sh (wrapper)
# ----------------------------
cat > plot_clanks.sh <<'SHCODE'
#!/usr/bin/env bash
set -euo pipefail
python3 ./plot_clanks.py --glob "peak_*.audio.txt" --outdir plots --topn 8 --tempfile temp.txt
SHCODE

chmod +x plot_clanks.sh

# ----------------------------
# 3) Run it
# ----------------------------
./plot_clanks.sh
ls -lh plots | head
