Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295#!/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.mdplus fig-map.png next to it. The chord lane is a per-bar estimate fromharmonic chroma against major/minor triad templates, downbeat-aligned bypicking the beat phase where chord changes land hardest."""
import argparseimport jsonfrom pathlib import Path
import numpy as npimport matplotlib
matplotlib.use("Agg")import matplotlib.pyplot as pltimport 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 textPC_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()