Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Authored by Souvik Mandal, Ph.D.

Project Leader & Instructor, Computational Behavioral Sciences, LS100, FAS, Harvard University | Linkedin ID: souvik-mandal-phd


Last updated: 2026-07-02

Open in Colab Open in GitHub Codespaces

Notebook overview

This LS100 notebook performs batch feature extraction from digital audio files and builds student-friendly metadata tables you can use for interpretation, clustering, or later machine-learning exercises.

Learning objectives

By the end of this notebook, you should be able to:

  1. Explain what tempo, meter, chords, and melody features represent in plain language.

  2. Run an end-to-end audio analysis pipeline over multiple files.

  3. Read and interpret exported metadata in JSON/CSV format.

  4. Identify when an output is likely unreliable and troubleshoot common issues.

Prerequisites

  • You have a folder of audio files (.wav, .mp3, .flac, .ogg, or .m4a).

  • You can edit the AUDIO_FOLDER path in the configuration cell.

  • Optional: install crepe if you want melody extraction.

Expected runtime

  • Small folder (5-20 short tracks): usually a few minutes.

  • Larger folders or melody extraction enabled: can take longer.

Workflow (what this notebook does)

  1. Configure paths and analysis options.

  2. Check dependencies and validate your input folder.

  3. Extract tempo/meter, chord summary, and optional melody information per track.

  4. Aggregate outputs across all tracks into JSON + CSV files.

  5. Create a compact summary table and quick plots for interpretation.

Mini glossary (LS100 quick reference)

  • Waveform: the amplitude of sound over time.

  • Sample rate (sr): how many audio samples are stored per second.

  • Beat: a regular pulse inferred from the signal.

  • Meter: grouping pattern of beats (for example, 4/4-like or 6/8-like).

  • Chroma: pitch-class energy (C, C#, D, ... B) independent of octave.

  • Triad chord template: simplified major/minor chord pattern used for matching.

  • Melody (f0): estimated fundamental frequency contour over time.

  • Confidence: model certainty for an estimated value.

Imports and configuration

# --- Cell 1: Imports and configuration ---
from pathlib import Path
import json
import math
import warnings

import numpy as np
import pandas as pd
import librosa

print("librosa version:", librosa.__version__)

# === USER SETTINGS ===
# Set this to the folder that contains your audio files
#AUDIO_FOLDER = Path("/path/to/your/audio/folder")   # <-- CHANGE THIS
AUDIO_FOLDER = Path("/Users/souvikmandal/Documents/06_Teaching_Mentoring/LS100_comp_etho/2025/media/audio/music_tracks/Audio_Clips")

# Output metadata files (JSON + CSV)
OUTPUT_JSON = AUDIO_FOLDER / "track_metadata.json"
OUTPUT_CSV  = AUDIO_FOLDER / "track_metadata.csv"

# Which analyses to run
DO_TEMPO_METER = True
DO_CHORDS      = True
DO_MELODY      = True   # CREPE-based melody extraction; heavier

# CREPE-related settings (only used if DO_MELODY is True)
CREPE_RESAMPLE_HZ = 16000   # resample to 16 kHz for CREPE
CREPE_STEP_MS     = 20      # frame step in ms
CREPE_MODEL       = "small" # "tiny" | "small" | "medium" | "large" | "full"
CREPE_CONF_MIN    = 0.6     # min confidence to keep a frame

# Beat / meter settings
HOP_LENGTH = 256
CANDIDATE_BEATS_PER_BAR = [2, 3, 4, 5, 6, 7]

print("Audio folder:", AUDIO_FOLDER)
librosa version: 0.11.0
Audio folder: /Users/souvikmandal/Documents/06_Teaching_Mentoring/LS100_comp_etho/2025/media/audio/music_tracks/Audio_Clips
# --- Cell 1b: Preflight checks + optional demo file setup ---
from pathlib import Path
import importlib.util
import shutil

print("\n=== Preflight checks ===")

required_pkgs = ["numpy", "pandas", "librosa"]
optional_pkgs = ["crepe"]

missing_required = [p for p in required_pkgs if importlib.util.find_spec(p) is None]
missing_optional = [p for p in optional_pkgs if importlib.util.find_spec(p) is None]

if missing_required:
    print("Missing required packages:", ", ".join(missing_required))
    print("Install suggestion:")
    print(f"  pip install {' '.join(missing_required)}")
else:
    print("Required packages: OK")

if missing_optional:
    print("Optional packages not found:", ", ".join(missing_optional))
    print("Melody extraction may be skipped unless installed.")
    print("Optional install suggestion:")
    print(f"  pip install {' '.join(missing_optional)}")
else:
    print("Optional packages: OK")

if not AUDIO_FOLDER.exists():
    print(f"\nAUDIO_FOLDER does not exist: {AUDIO_FOLDER}")
    print("Creating the folder now so you can add files and rerun.")
    AUDIO_FOLDER.mkdir(parents=True, exist_ok=True)

audio_now = []
for ext in [".wav", ".mp3", ".flac", ".ogg", ".m4a"]:
    audio_now.extend(AUDIO_FOLDER.glob(f"*{ext}"))
    audio_now.extend(AUDIO_FOLDER.glob(f"**/*{ext}"))
audio_now = sorted(set(audio_now))

if audio_now:
    print(f"Found {len(audio_now)} audio file(s).")
else:
    print("\nNo audio files found yet.")
    print("Demo mode: attempting to copy one built-in librosa example into your folder.")
    demo_target = AUDIO_FOLDER / "demo_librosa_example.wav"
    try:
        sample_path = Path(librosa.ex('trumpet'))
        shutil.copy(sample_path, demo_target)
        print(f"Demo file created: {demo_target}")
    except Exception as e:
        print("Could not create demo file automatically:", e)
        print("Add one audio file manually and rerun this preflight cell.")

print("=== End preflight ===\n")

File listing and audio loading

What this stage does: finds audio files recursively and loads each track as mono for consistent downstream analysis.

Why this matters: consistent input format reduces feature drift caused by different channel layouts or inconsistent loading assumptions.

Input: AUDIO_FOLDER (path to your track directory) Output: list of audio file paths and waveform arrays (y) with sample rate (sr)

Tip: if this stage finds 0 files, fix the path first before running the rest of the notebook.

# --- Cell 2: Helper functions: file listing and audio loading ---

def list_audio_files(folder: Path):
    exts = [".wav", ".mp3", ".flac", ".ogg", ".m4a"]
    files = []
    for ext in exts:
        files.extend(folder.glob(f"*{ext}"))
        files.extend(folder.glob(f"**/*{ext}"))  # include subfolders
    files = sorted(set(files))
    return files

def load_audio_mono(path: Path):
    """Load an audio file as mono at its native sample rate."""
    y, sr = librosa.load(path, sr=None, mono=True)
    return y, sr

if AUDIO_FOLDER.exists():
    test_files = list_audio_files(AUDIO_FOLDER)
    print(f"Found {len(test_files)} audio files in {AUDIO_FOLDER}")
else:
    print("⚠️ AUDIO_FOLDER does not exist yet. Please set a valid path.")
Found 13 audio files in /Users/souvikmandal/Documents/06_Teaching_Mentoring/LS100_comp_etho/2025/media/audio/music_tracks/Audio_Clips

Tempo and meter analysis

What this stage does: estimates global tempo (BPM), beat count, and a simple meter label (for example, approximately 4/4 or approximately 6/8).

How it works (simplified):

  1. Detect beats from onset patterns.

  2. Estimate BPM from beat timing.

  3. Compare accent periodicity against candidate bar lengths to infer meter.

Interpretation notes:

  • Higher meter_confidence means stronger periodic accent evidence.

  • Meter labels here are approximate global summaries, not strict symbolic transcription.

# --- Cell 3: Tempo & meter analysis ---

def meter_score(acc_seq: np.ndarray, m: int) -> float:
    """Return normalized autocorrelation score at lag m (0..1)."""
    if len(acc_seq) <= m:
        return 0.0
    x = acc_seq - np.mean(acc_seq)
    num = np.sum(x[:-m] * x[m:])
    den = math.sqrt(float(np.sum(x[:-m]**2) * np.sum(x[m:]**2))) + 1e-12
    return float(max(0.0, num / den))

def label_from_m(m: int) -> str:
    if m == 6:
        return "≈6/8 (compound)"
    else:
        return f"≈{m}/4"

def analyze_tempo_and_meter(y: np.ndarray, sr: int) -> dict:
    """Return a dictionary with tempo, beat count, and a simple global meter estimate."""
    out = {
        "global_tempo_bpm": None,
        "num_beats": None,
        "meter_label": None,
        "meter_confidence": None,
    }

    tempo_bpm, beat_frames = librosa.beat.beat_track(y=y, sr=sr, hop_length=HOP_LENGTH)
    tempo_bpm_scalar = float(np.atleast_1d(tempo_bpm)[0])
    beat_times = librosa.frames_to_time(beat_frames, sr=sr, hop_length=HOP_LENGTH)

    out["global_tempo_bpm"] = tempo_bpm_scalar
    out["num_beats"] = int(len(beat_times))

    if len(beat_frames) < 4:
        return out

    onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=HOP_LENGTH)
    beat_frames = beat_frames[(beat_frames >= 0) & (beat_frames < len(onset_env))]
    if len(beat_frames) < 4:
        return out

    beat_accents = onset_env[beat_frames].astype(float)
    if np.max(beat_accents) > 0:
        beat_accents = beat_accents / (np.max(beat_accents) + 1e-12)

    scores = {m: meter_score(beat_accents, m) for m in CANDIDATE_BEATS_PER_BAR}
    best_m = max(scores, key=scores.get)
    conf = float(scores[best_m])

    out["meter_label"] = label_from_m(best_m)
    out["meter_confidence"] = conf

    return out

Chord analysis (triad summary)

What this stage does: computes beat-synchronous chroma features and summarizes the most-used major/minor triads by total duration.

How it works (simplified):

  1. Convert audio to chroma (pitch-class energy over time).

  2. Align chroma frames to beat locations.

  3. Compare each beat slice with major/minor chord templates.

  4. Merge adjacent repeated labels and summarize durations.

Interpretation notes:

  • top_chords gives a harmonic footprint, not a full music-theory analysis.

  • Fast harmonic changes can reduce summary stability.

# --- Cell 4: Chord analysis (triads; summary only) ---

def analyze_chords(y: np.ndarray, sr: int) -> dict:
    """Estimate chords over time and summarize top chords by total duration."""
    out = {
        "top_chords": [],
        "num_chord_segments": 0,
    }

    tempo_bpm, beat_frames = librosa.beat.beat_track(y=y, sr=sr, hop_length=HOP_LENGTH)
    beat_times = librosa.frames_to_time(beat_frames, sr=sr, hop_length=HOP_LENGTH)
    if len(beat_times) < 2:
        return out

    chroma = librosa.feature.chroma_cqt(
        y=y, sr=sr, hop_length=HOP_LENGTH,
        n_chroma=12, bins_per_octave=36
    )

    bf = librosa.time_to_frames(beat_times, sr=sr, hop_length=HOP_LENGTH)
    bf = bf[(bf >= 0) & (bf <= chroma.shape[1])]
    bf = np.unique(bf)
    if len(bf) < 2:
        return out

    beat_chroma = librosa.util.sync(chroma, bf, aggregate=np.mean)
    norm = np.linalg.norm(beat_chroma, axis=0, keepdims=True)
    norm[norm == 0] = 1.0
    beat_chroma_norm = beat_chroma / norm

    pitch_names = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
    maj = np.zeros(12); maj[[0,4,7]] = 1.0
    minr = np.zeros(12); minr[[0,3,7]] = 1.0

    majors = np.vstack([np.roll(maj, r) for r in range(12)])
    minors = np.vstack([np.roll(minr, r) for r in range(12)])
    templates = np.vstack([majors, minors])
    chord_labels = [f"{p}" for p in pitch_names] + [f"{p}m" for p in pitch_names]

    sims = templates @ beat_chroma_norm
    chord_idx = np.argmax(sims, axis=0)
    chord_seq = [chord_labels[i] for i in chord_idx]

    seg_starts = beat_times[:-1]
    seg_ends   = beat_times[1:]
    n = min(len(chord_seq), len(seg_starts))
    chord_seq = chord_seq[:n]
    seg_starts = seg_starts[:n]
    seg_ends   = seg_ends[:n]

    segments = []
    if n > 0:
        cur_label = chord_seq[0]
        cur_start = seg_starts[0]
        for i in range(1, n):
            if chord_seq[i] != cur_label:
                segments.append((cur_start, seg_ends[i-1], cur_label))
                cur_label = chord_seq[i]
                cur_start = seg_starts[i]
        segments.append((cur_start, seg_ends[n-1], cur_label))

    if not segments:
        return out

    chords_df = pd.DataFrame(segments, columns=["start", "end", "chord"])
    chords_df["duration"] = chords_df["end"] - chords_df["start"]
    out["num_chord_segments"] = int(len(chords_df))

    used_summary = (chords_df.groupby("chord")["duration"]
                    .sum()
                    .sort_values(ascending=False)
                    .reset_index())

    out["top_chords"] = [
        {"chord": row["chord"], "total_duration_sec": float(row["duration"])}
        for _, row in used_summary.iterrows()
    ]

    return out

Melody analysis with CREPE (optional)

What this stage does: estimates frame-level pitch (f0) and summarizes melodic range and note usage.

How it works (simplified):

  1. Optionally resample audio to CREPE target rate.

  2. Predict pitch and confidence per frame.

  3. Keep only confident frames and convert Hz to note names.

  4. Summarize range, median note, and most frequent notes.

Interpretation notes:

  • Voice-leading trends are better interpreted from many frames, not single-frame values.

  • If melody_available is false, CREPE may be missing or confidence may be too low for this track.

# --- Cell 5: Melody analysis with CREPE (optional) ---

def analyze_melody_crepe(y: np.ndarray, sr: int) -> dict:
    out = {
        "melody_available": False,
        "frames_kept": 0,
        "pitch_range_midi": None,
        "pitch_range_notes": None,
        "median_note": None,
        "top_notes": []
    }

    try:
        import crepe
    except Exception:
        out["melody_available"] = False
        return out

    if y.ndim > 1:
        y_mono = librosa.to_mono(y)
    else:
        y_mono = y

    target_sr = int(CREPE_RESAMPLE_HZ)
    if sr != target_sr:
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            y_proc = librosa.resample(y_mono, orig_sr=sr, target_sr=target_sr, res_type="kaiser_fast")
        sr_crepe = target_sr
    else:
        y_proc = y_mono
        sr_crepe = sr

    time_s, freq_hz, conf, _ = crepe.predict(
        y_proc,
        sr_crepe,
        step_size=CREPE_STEP_MS,
        model_capacity=CREPE_MODEL,
        viterbi=True
    )

    mask = np.isfinite(freq_hz) & np.isfinite(conf) & (conf >= CREPE_CONF_MIN) & (freq_hz > 0)
    time_f = time_s[mask]
    freq_f = freq_hz[mask]

    out["frames_kept"] = int(len(time_f))
    if len(time_f) == 0:
        out["melody_available"] = False
        return out

    midi = librosa.hz_to_midi(freq_f)
    out["melody_available"] = True

    low_m = float(np.nanmin(midi))
    high_m = float(np.nanmax(midi))
    out["pitch_range_midi"] = [low_m, high_m]
    out["pitch_range_notes"] = [
        librosa.midi_to_note(low_m, octave=True),
        librosa.midi_to_note(high_m, octave=True),
    ]

    med_m = float(np.nanmedian(midi))
    out["median_note"] = librosa.midi_to_note(med_m, octave=True)

    note_names = librosa.midi_to_note(midi, octave=True)
    unique, counts = np.unique(note_names, return_counts=True)
    order = np.argsort(-counts)
    out["top_notes"] = [
        {"note": str(unique[i]), "frames": int(counts[i])}
        for i in order[:10]
    ]

    return out

Full-track wrapper

What this stage does: combines all enabled analyses into one consistent per-track metadata record.

Why this helps learning: you can inspect one dictionary object and see rhythm, harmony, melody, and error fields together.

Common troubleshooting (likely causes):

  • error field filled: file may be corrupted or codec may be unsupported.

  • Very low beat/chord stability: track has weak transients or atypical rhythm.

  • Melody missing: crepe not installed, confidence threshold too strict, or highly noisy signal.

# --- Cell 6: Full-track analysis wrapper ---

def analyze_track(path: Path) -> dict:
    print(f"\n▶ Analyzing: {path.name}")
    meta = {
        "file_path": str(path),
        "file_name": path.name,
        "duration_sec": None,
        "sample_rate": None,
        "error": None,
        "tempo_meter": None,
        "chords": None,
        "melody": None,
    }

    try:
        y, sr = load_audio_mono(path)
        duration_sec = len(y) / sr
        meta["duration_sec"] = float(duration_sec)
        meta["sample_rate"] = int(sr)

        if DO_TEMPO_METER:
            tempo_info = analyze_tempo_and_meter(y, sr)
            meta["tempo_meter"] = tempo_info

        if DO_CHORDS:
            chord_info = analyze_chords(y, sr)
            meta["chords"] = chord_info

        if DO_MELODY:
            melody_info = analyze_melody_crepe(y, sr)
            meta["melody"] = melody_info

    except Exception as e:
        meta["error"] = repr(e)
        print(f"  ⚠️ Error analyzing {path.name}: {e}")

    return meta

Batch loop and output files

What this stage does: runs analysis on every file and exports JSON + CSV outputs for class use.

Output files:

  • track_metadata.json: rich nested metadata per track.

  • track_metadata.csv: flattened, spreadsheet-friendly summary.

Expected value ranges (rule-of-thumb, not strict):

  • global_tempo_bpm: often ~60 to ~200 for many popular tracks.

  • meter_confidence: 0 to 1, where higher means clearer periodic accents.

  • num_chord_segments: larger values suggest more harmonic change.

If output looks odd:

  • Recheck audio quality and file format.

  • Verify AUDIO_FOLDER path and file count.

  • Disable optional melody first, then re-enable after baseline works.

# --- Cell 7: Run batch analysis and save metadata ---

if not AUDIO_FOLDER.exists():
    raise FileNotFoundError(f"AUDIO_FOLDER does not exist: {AUDIO_FOLDER}")

audio_files = list_audio_files(AUDIO_FOLDER)
if not audio_files:
    raise RuntimeError(f"No audio files found in {AUDIO_FOLDER} (or subfolders).")

all_meta = []

for path in audio_files:
    meta = analyze_track(path)
    all_meta.append(meta)

# Save JSON
with open(OUTPUT_JSON, "w", encoding="utf-8") as f:
    json.dump(all_meta, f, indent=2)

# Save CSV (flatten some nested fields)
rows = []
for m in all_meta:
    row = {
        "file_name": m.get("file_name"),
        "file_path": m.get("file_path"),
        "duration_sec": m.get("duration_sec"),
        "sample_rate": m.get("sample_rate"),
        "error": m.get("error"),
    }

    tm = m.get("tempo_meter") or {}
    row["global_tempo_bpm"]  = tm.get("global_tempo_bpm")
    row["num_beats"]         = tm.get("num_beats")
    row["meter_label"]       = tm.get("meter_label")
    row["meter_confidence"]  = tm.get("meter_confidence")

    ch = m.get("chords") or {}
    row["num_chord_segments"] = ch.get("num_chord_segments")
    if ch.get("top_chords"):
        top3 = ch["top_chords"][:3]
        row["top_chords_summary"] = "; ".join(
            f"{c['chord']} ({c['total_duration_sec']:.1f}s)" for c in top3
        )
    else:
        row["top_chords_summary"] = None

    mel = m.get("melody") or {}
    row["melody_available"] = mel.get("melody_available")
    pr = mel.get("pitch_range_notes") or [None, None]
    row["melody_range_low"]  = pr[0]
    row["melody_range_high"] = pr[1]
    row["melody_median_note"] = mel.get("median_note")

    rows.append(row)

df = pd.DataFrame(rows)
df.to_csv(OUTPUT_CSV, index=False)

print(f"\n✅ Done. Analyzed {len(all_meta)} tracks.")
print(f"JSON metadata saved to: {OUTPUT_JSON}")
print(f"CSV metadata saved to : {OUTPUT_CSV}")

▶ Analyzing: Mediu Zhiga.wav
294/294 ━━━━━━━━━━━━━━━━━━━━ 6s 21ms/step

▶ Analyzing: Ra Bacheeza.wav
294/294 ━━━━━━━━━━━━━━━━━━━━ 6s 21ms/step

▶ Analyzing: [SP] Alfonso Ortiz Tirado - TE QUIERO DIJISTE.mp3
311/311 ━━━━━━━━━━━━━━━━━━━━ 7s 21ms/step

▶ Analyzing: [SP] Alvaro Carrillo - Pinotepa Nacional.mp3
292/292 ━━━━━━━━━━━━━━━━━━━━ 6s 21ms/step

▶ Analyzing: [SP] Lagrimas Negras.mp3
126/126 ━━━━━━━━━━━━━━━━━━━━ 3s 21ms/step

▶ Analyzing: [SP] Los Panchos - Contigo.mp3
258/258 ━━━━━━━━━━━━━━━━━━━━ 6s 25ms/step

▶ Analyzing: [SP] Los Panchos - Jamas Jamas Jamas.mp3
283/283 ━━━━━━━━━━━━━━━━━━━━ 6s 22ms/step

▶ Analyzing: [SP] Los Panchos - Te Quiero Dijiste.mp3
229/229 ━━━━━━━━━━━━━━━━━━━━ 5s 21ms/step

▶ Analyzing: [SP] Soledad y el Mar - Natalia Lafourcade.mp3
338/338 ━━━━━━━━━━━━━━━━━━━━ 7s 21ms/step

▶ Analyzing: [ZAP] Binni Gula_za - Ni_bixi Dxi Zina.mp3
277/277 ━━━━━━━━━━━━━━━━━━━━ 6s 21ms/step

▶ Analyzing: [ZAP] Mediu Zhiga.mp3
294/294 ━━━━━━━━━━━━━━━━━━━━ 6s 21ms/step

▶ Analyzing: [ZAP] Ra Bacheeza.mp3
294/294 ━━━━━━━━━━━━━━━━━━━━ 6s 21ms/step

▶ Analyzing: [ZAP] Sabor a Mi - Trio Galenos Y Mario Carrillo.mp3
88/88 ━━━━━━━━━━━━━━━━━━━━ 2s 21ms/step

✅ Done. Analyzed 13 tracks.
JSON metadata saved to: /Users/souvikmandal/Documents/06_Teaching_Mentoring/LS100_comp_etho/2025/media/audio/music_tracks/Audio_Clips/track_metadata.json
CSV metadata saved to : /Users/souvikmandal/Documents/06_Teaching_Mentoring/LS100_comp_etho/2025/media/audio/music_tracks/Audio_Clips/track_metadata.csv

Interpretation prompts and what to submit

Use these prompts after running the batch cell:

  1. Which track appears rhythmically most stable, and what output supports that claim?

  2. Do tracks with similar BPM always share similar harmonic summaries? Why or why not?

  3. Which track shows the widest melodic range, and is the confidence level convincing?

Suggested LS100 submission checklist

  • Screenshot of successful batch completion output.

  • The generated track_metadata.csv file.

  • A short paragraph (4-6 lines) interpreting at least two tracks using tempo, meter, chords, and melody fields.

# --- Cell 8: Student-friendly summary table, quick plots, and run checklist ---
import matplotlib.pyplot as plt

if 'df' not in globals() or df is None or len(df) == 0:
    print("No results dataframe found. Run the batch analysis cell first.")
else:
    display_cols = [
        "file_name",
        "global_tempo_bpm",
        "meter_label",
        "meter_confidence",
        "num_beats",
        "num_chord_segments",
        "melody_available",
        "melody_range_low",
        "melody_range_high",
    ]
    present_cols = [c for c in display_cols if c in df.columns]
    student_summary = df[present_cols].copy()

    print("\n=== Student summary table (first 20 rows) ===")
    print(student_summary.head(20).to_string(index=False))

    # Quick plot 1: tempo by track
    if "global_tempo_bpm" in df.columns:
        plt.figure(figsize=(10, 4))
        x = np.arange(len(df))
        y = pd.to_numeric(df["global_tempo_bpm"], errors="coerce")
        plt.bar(x, y)
        plt.xticks(x, df["file_name"], rotation=60, ha="right")
        plt.ylabel("Tempo (BPM)")
        plt.title("Global Tempo by Track")
        plt.tight_layout()
        plt.show()

    # Quick plot 2: beats vs chord segments
    if "num_beats" in df.columns and "num_chord_segments" in df.columns:
        plt.figure(figsize=(6, 5))
        xb = pd.to_numeric(df["num_beats"], errors="coerce")
        yc = pd.to_numeric(df["num_chord_segments"], errors="coerce")
        plt.scatter(xb, yc)
        for _, r in df.iterrows():
            try:
                plt.annotate(str(r.get("file_name", "")), (float(r.get("num_beats", np.nan)), float(r.get("num_chord_segments", np.nan))), fontsize=8)
            except Exception:
                pass
        plt.xlabel("Number of beats")
        plt.ylabel("Number of chord segments")
        plt.title("Rhythm-Harmony Complexity Snapshot")
        plt.tight_layout()
        plt.show()

    # Quick plot 3: meter confidence by track
    if "meter_confidence" in df.columns:
        plt.figure(figsize=(10, 4))
        x = np.arange(len(df))
        mc = pd.to_numeric(df["meter_confidence"], errors="coerce")
        plt.bar(x, mc)
        plt.ylim(0, 1)
        plt.xticks(x, df["file_name"], rotation=60, ha="right")
        plt.ylabel("Meter confidence")
        plt.title("Estimated Meter Confidence by Track")
        plt.tight_layout()
        plt.show()

    print("\n=== Run checklist ===")
    checks = [
        ("Output JSON exists", Path(OUTPUT_JSON).exists()),
        ("Output CSV exists", Path(OUTPUT_CSV).exists()),
        ("At least one track analyzed", len(df) > 0),
    ]
    for label, ok in checks:
        print(f"- {label}: {'OK' if ok else 'MISSING'}")

    print("\nIf any item is MISSING, re-check path settings and rerun from the preflight cell.")