#!/usr/bin/env python3 """Map a studied single bar by bar: chords, phrase keys, energy. Usage: .venv/bin/python study/map.py study/out/slug/report.json Reads the report (audio path, tempo, sections, phrases) and writes MAP.md plus fig-map.png next to it. The chord lane is a per-bar estimate from harmonic chroma against major/minor triad templates, downbeat-aligned by picking the beat phase where chord changes land hardest. """ import argparse import json from pathlib import Path import numpy as np import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import librosa from study import (SURFACE, INK, INK2, GRID, SEQ_CMAP, BANDS, NOTES, sec_color, estimate_key) # one hue per pitch class, light enough to hold black text PC_COLORS = ["#2a78d6", "#7a6bd6", "#b05cc4", "#e05a9e", "#e34948", "#eb6834", "#eda100", "#b0a100", "#6faf1b", "#1baf7a", "#00a3a3", "#4a90d0"] DEGREES_MAJ = ["I", "bII", "II", "bIII", "III", "IV", "bV", "V", "bVI", "VI", "bVII", "VII"] def triad_templates(): temps = [] for root in range(12): for third, quality in ((4, ""), (3, "m")): t = np.zeros(12) t[root], t[(root + third) % 12], t[(root + 7) % 12] = 1.0, 0.9, 0.8 temps.append((f"{NOTES[root]}{quality}", root, quality, t / np.linalg.norm(t))) return temps TRIADS = triad_templates() MAJ_SCALE = {0, 2, 4, 5, 7, 9, 11} MIN_SCALE = {0, 2, 3, 5, 7, 8, 10} def diatonic_pcs(key_name): tonic, mode = key_name.split() pc = NOTES.index(tonic) scale = MAJ_SCALE if mode == "major" else MIN_SCALE return {(pc + s) % 12 for s in scale} def chord_of(chroma_vec, key_pcs=None): """Best triad for one bar of chroma; a small bonus favors triads that sit inside the local key, which keeps a buried third from flipping major bars to minor.""" norm = np.linalg.norm(chroma_vec) if norm < 1e-6: return None, 0.0 v = chroma_vec / norm scores = [] for name, root, quality, t in TRIADS: s = float(v @ t) if key_pcs is not None: third = (root + (3 if quality == "m" else 4)) % 12 if {root, third, (root + 7) % 12} <= key_pcs: s += 0.06 scores.append((s, name, root, quality)) score, name, root, quality = max(scores) return (name, root, quality), score def roman(root, quality, key_pc, key_mode): """Chord degree relative to a key, lower-case when the chord is minor.""" deg = DEGREES_MAJ[(root - key_pc) % 12] if key_mode == "minor": # spell from the relative major for readability deg = DEGREES_MAJ[(root - (key_pc + 3) % 12) % 12] return deg.lower() if quality == "m" else deg def downbeat_offset(chroma_beats): """Beat phase (0-3) where bar-to-bar chroma change is largest.""" change = np.zeros(chroma_beats.shape[1]) for i in range(1, chroma_beats.shape[1]): a, b = chroma_beats[:, i - 1], chroma_beats[:, i] na, nb = np.linalg.norm(a), np.linalg.norm(b) change[i] = 1 - (a @ b) / (na * nb + 1e-9) return int(np.argmax([change[off::4].mean() for off in range(4)])) def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("report") ap.add_argument("--figwidth", type=float, default=10.0, help="map figure width in inches (height scales)") args = ap.parse_args() out = Path(args.report).parent rpt = json.loads(Path(args.report).read_text()) title = rpt["title"] sections = rpt["structure"]["sections"] phrases = rpt["structure"]["phrases"] y, sr = librosa.load(rpt["master"]["file"], sr=22050, mono=True) _, beats = librosa.beat.beat_track( y=y, sr=sr, start_bpm=float(rpt["structure"]["tempo_bpm"]), units="frames") beat_times = librosa.frames_to_time(beats, sr=sr) y_harm = librosa.effects.harmonic(y) chroma = librosa.feature.chroma_cqt(y=y_harm, sr=sr) chroma_beats = librosa.util.sync(chroma, beats, aggregate=np.median) off = downbeat_offset(chroma_beats) starts = list(range(off, chroma_beats.shape[1] - 3, 4)) # phrase keys first — they seed the chord lane's diatonic prior times = librosa.times_like(chroma[0], sr=sr) phrase_keys = [] for p in phrases: m = (times >= p["start_s"]) & (times < p["end_s"]) k, c = estimate_key(chroma[:, m].mean(axis=1)) phrase_keys.append({**p, "key": k, "confidence": c}) def key_pcs_at(t): for pk in phrase_keys: if pk["start_s"] <= t < pk["end_s"]: return diatonic_pcs(pk["key"]) return diatonic_pcs(rpt["harmony"]["key"]) # per-bar loudness + six-band energy from one STFT S = np.abs(librosa.stft(y, n_fft=2048)) ** 2 freqs = librosa.fft_frequencies(sr=sr, n_fft=2048) frame_t = librosa.times_like(S[0], sr=sr) rms_db = 10 * np.log10(S.mean(axis=0) + 1e-12) rms_db -= rms_db.max() band_db = np.stack([ 10 * np.log10(S[(freqs >= lo) & (freqs < hi)].mean(axis=0) + 1e-12) for _, lo, hi in BANDS]) band_db -= band_db.max() bars = [] for n, b in enumerate(starts): t0 = beat_times[b] t1 = beat_times[b + 4] if b + 4 < len(beat_times) else float(len(y)) / sr (chord, score) = chord_of(chroma_beats[:, b:b + 4].mean(axis=1), key_pcs_at(float(t0))) fm = (frame_t >= t0) & (frame_t < t1) bars.append({ "n": n + 1, "start_s": round(float(t0), 2), "end_s": round(float(t1), 2), "chord": None if chord is None else chord[0], "root_pc": None if chord is None else chord[1], "quality": None if chord is None else chord[2], "score": round(score, 2), "rms_db": round(float(rms_db[fm].mean()) if fm.any() else -60, 1), }) # majority smoothing — a lone odd bar between two agreeing bars flips for i in range(1, len(bars) - 1): a, b_, c = bars[i - 1], bars[i], bars[i + 1] if a["chord"] == c["chord"] != b_["chord"] and a["chord"]: b_["chord"], b_["root_pc"], b_["quality"] = ( a["chord"], a["root_pc"], a["quality"]) gk_name, gk_mode = rpt["harmony"]["key"].split() gk_pc = NOTES.index(gk_name) fig_map(out, title, y, sr, bars, sections, phrases, phrase_keys, frame_t, band_db, rms_db, args.figwidth) write_map_md(out, rpt, bars, phrase_keys, gk_pc, gk_mode) json_bars = out / "map.json" json_bars.write_text(json.dumps( {"downbeat_offset_beats": off, "bars": bars, "phrase_keys": phrase_keys}, indent=1)) print(f"→ {out}/MAP.md, fig-map.png, map.json (offset {off})") def merge_chord_spans(bars): spans = [] for bar in bars: if spans and spans[-1]["chord"] == bar["chord"]: spans[-1]["end_s"] = bar["end_s"] spans[-1]["bars"] += 1 else: spans.append({"chord": bar["chord"], "root_pc": bar["root_pc"], "start_s": bar["start_s"], "end_s": bar["end_s"], "bars": 1}) return spans def fig_map(out, title, y, sr, bars, sections, phrases, phrase_keys, frame_t, band_db, rms_db, figwidth=10.0): fig, axes = plt.subplots( 3, 1, figsize=(figwidth, figwidth * 0.64), sharex=True, gridspec_kw={"height_ratios": [1.6, 0.7, 1.3]}) ax_l, ax_c, ax_b = axes # loudness colored per section, with a matching background wash clipped = np.clip(rms_db, -40, 0) for sec in sections: c = sec_color(sec["label"]) m = (frame_t >= sec["start_s"]) & (frame_t <= sec["end_s"]) ax_l.plot(frame_t[m], clipped[m], color=c, lw=0.9) for ax in (ax_l, ax_c, ax_b): ax.axvspan(sec["start_s"], sec["end_s"], color=c, alpha=0.06, zorder=0) if sec["end_s"] - sec["start_s"] > 5: ax_l.text((sec["start_s"] + sec["end_s"]) / 2, -3.5, sec["label"], ha="center", fontsize=10, fontweight="bold", color=c, zorder=5, bbox=dict(boxstyle="round,pad=0.2", fc=SURFACE, ec="none", alpha=0.8)) ax_l.set_ylim(-40, 2) ax_l.set_ylabel("level (dB)") for p in phrases: ax_l.axvline(p["start_s"], color=INK2, lw=0.5, alpha=0.35, ls=":") for pk in phrase_keys: tonic = pk["key"].split()[0] ax_l.text((pk["start_s"] + pk["end_s"]) / 2, -37.5, pk["key"], ha="center", fontsize=6.5, fontweight="bold", color=PC_COLORS[NOTES.index(tonic)]) ax_l.set_title(f"{title} — song map (phrase keys along the floor)") # chord lane for span in merge_chord_spans(bars): w = span["end_s"] - span["start_s"] if span["chord"] is None: continue ax_c.barh(0, w, left=span["start_s"], height=0.9, color=PC_COLORS[span["root_pc"]], alpha=0.62, edgecolor=SURFACE, linewidth=0.8) if w > 3: ax_c.text(span["start_s"] + w / 2, 0, span["chord"], ha="center", va="center", fontsize=7, color=INK) ax_c.set_ylim(-0.6, 0.6) ax_c.set_yticks([]) ax_c.set_ylabel("chords", rotation=0, ha="right", va="center", fontsize=8, color=INK2) ax_c.grid(False) # band heatmap step = max(1, len(frame_t) // 1200) ax_b.imshow(np.clip(band_db[:, ::step], -50, 0), cmap=SEQ_CMAP, vmin=-50, vmax=0, aspect="auto", origin="lower", interpolation="nearest", extent=[frame_t[0], frame_t[-1], 0, len(BANDS)]) ax_b.set_yticks(np.arange(len(BANDS)) + 0.5) ax_b.set_yticklabels([b[0] for b in BANDS], fontsize=7) ax_b.grid(False) ax_b.set_xlabel("time (s)") fig.tight_layout() fig.savefig(out / "fig-map.png", dpi=180) plt.close(fig) def write_map_md(out, rpt, bars, phrase_keys, gk_pc, gk_mode): lines = [f"# {rpt['title']} — song map", "", f"global key {rpt['harmony']['key']} · " f"{rpt['structure']['tempo_bpm']} bpm · {len(bars)} bars", "", "## phrases", "", "| # | span | key | conf |", "|---|---|---|---|"] for i, pk in enumerate(phrase_keys, 1): lines.append(f"| {i} | {pk['start_s']:.0f}–{pk['end_s']:.0f}s |" f" {pk['key']} | {pk['confidence']} |") lines += ["", "## chords (8 bars per row, degree vs. global key)", ""] for i in range(0, len(bars), 8): row = bars[i:i + 8] names = " | ".join( "—" if b["chord"] is None else b["chord"] for b in row) degs = " | ".join( "—" if b["chord"] is None else roman(b["root_pc"], b["quality"], gk_pc, gk_mode) for b in row) lines.append(f"**{row[0]['start_s']:.0f}s** · bar {row[0]['n']}") lines.append("") lines.append(f"`| {names} |`") lines.append(f"`| {degs} |`") lines.append("") (out / "MAP.md").write_text("\n".join(lines) + "\n") if __name__ == "__main__": main()