Skip to content

API reference

Collections and I/O

Collections, albums, tracks — the folder-shaped data model.

A collection is a folder tree of audio files. Every directory that directly contains audio becomes an album (named by its path relative to the root; files sitting in the root itself form the album "."), and every audio file a track named by its stem. Metadata tags are deliberately not required — the folder structure people already keep their music in is the ground truth here; the optional [tags] extra can enrich titles later.

open_collection(root)

Scan a folder tree into a Collection (albums sorted, tracks sorted).

Source code in src/musiscape/io.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def open_collection(root: str | Path) -> Collection:
    """Scan a folder tree into a Collection (albums sorted, tracks sorted)."""
    root = Path(root).expanduser().resolve()
    if not root.is_dir():
        raise FileNotFoundError(f"not a directory: {root}")
    by_dir: dict[str, list[Track]] = {}
    for p in sorted(root.rglob("*")):
        if p.is_file() and p.suffix.lower() in AUDIO_EXTS:
            album = str(p.parent.relative_to(root)) or "."
            by_dir.setdefault(album, []).append(Track(path=p, album=album))
    if not by_dir:
        raise FileNotFoundError(f"no audio files under {root}")
    albums = [Album(name=k, tracks=v) for k, v in sorted(by_dir.items())]
    return Collection(root=root, albums=albums)

load(track, sr=22050, duration=None)

Load a track as mono float audio at sr (librosa's decoders).

Source code in src/musiscape/io.py
64
65
66
67
68
def load(track: Track, sr: int = 22050, duration: float | None = None):
    """Load a track as mono float audio at ``sr`` (librosa's decoders)."""
    import librosa
    y, sr = librosa.load(str(track.path), sr=sr, mono=True, duration=duration)
    return y, sr

Feature extraction

Per-track feature extraction — the interpretable descriptor set.

Every number here has a musicological reading: note density (plucked events per second), brightness (spectral centroid), inharmonic texture (spectral flatness), dynamic range, harmonic/percussive balance, estimated key (Krumhansl–Schmuckler), pitch-class entropy, pulse clarity and tonal focus (circular statistics via :mod:ambiscape.music), and a Schaeffer TARTYP object profile. The set was developed on a five-album solo-harp catalogue (57 tracks) and is meant to stay small enough to explain, not to compete with embedding models on raw similarity.

extract_collection caches to features.json in the output folder and runs tracks in parallel; delete the file to force re-extraction.

estimate_key(chroma_mean)

Krumhansl–Schmuckler key estimate (name, correlation).

Source code in src/musiscape/features.py
40
41
42
43
44
45
46
47
48
def estimate_key(chroma_mean: np.ndarray) -> tuple[str, float]:
    """Krumhansl–Schmuckler key estimate (name, correlation)."""
    best, best_r = "C major", -2.0
    for i in range(12):
        for prof, mode in ((_MAJ, "major"), (_MIN, "minor")):
            r = float(np.corrcoef(np.roll(prof, i), chroma_mean)[0, 1])
            if r > best_r:
                best_r, best = r, f"{KEYS[i]} {mode}"
    return best, best_r

extract_track(y, sr)

All per-track descriptors from decoded audio.

Source code in src/musiscape/features.py
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
def extract_track(y: np.ndarray, sr: int) -> dict:
    """All per-track descriptors from decoded audio."""
    import librosa
    from ambiscape import music as amusic

    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        dur = len(y) / sr
        yt, _ = librosa.effects.trim(y, top_db=40)

        S = np.abs(librosa.stft(yt, n_fft=2048, hop_length=512))
        rms = librosa.feature.rms(S=S)[0]
        centroid = librosa.feature.spectral_centroid(S=S, sr=sr)[0]
        flatness = librosa.feature.spectral_flatness(S=S)[0]
        zcr = librosa.feature.zero_crossing_rate(yt, hop_length=512)[0]
        flux = librosa.onset.onset_strength(S=librosa.amplitude_to_db(S), sr=sr)
        peaks = librosa.onset.onset_detect(onset_envelope=flux, sr=sr,
                                           units="frames")
        onsets = peaks[flux[peaks] >= amusic.ONSET_FLOOR]

        yh, yp = librosa.effects.hpss(yt)
        he, pe = float(np.sum(yh ** 2)), float(np.sum(yp ** 2))

        chroma = librosa.feature.chroma_cqt(y=yh, sr=sr).mean(axis=1)
        cm = chroma / (chroma.sum() + 1e-12)
        key, key_conf = estimate_key(chroma)

        db = librosa.amplitude_to_db(rms + 1e-12)
        try:
            from librosa.feature.rhythm import tempo as _tempo
        except ImportError:                              # librosa < 0.10
            _tempo = librosa.beat.tempo
        tempo_bpm = float(_tempo(onset_envelope=flux, sr=sr)[0])
        pulse = amusic.pulse_clarity(yt, sr)
        fifths = amusic.fifths_center(chroma)
        tartyp = amusic.tartyp_profile(yt, sr)

    return {
        "duration_s": round(dur, 1),
        "onset_rate": round(len(onsets) / max(len(yt) / sr, 1e-9), 3),
        "centroid_hz": round(float(centroid.mean()), 1),
        "flatness": round(float(flatness.mean()), 5),
        "zcr": round(float(zcr.mean()), 5),
        "flux": round(float(flux.mean()), 3),
        "perc_ratio": round(pe / (he + pe + 1e-12), 4),
        "dyn_range_db": round(float(np.percentile(db, 95)
                                    - np.percentile(db, 10)), 2),
        "chroma_entropy": round(float(-np.sum(cm * np.log2(cm + 1e-12))), 3),
        "key": key, "key_conf": round(key_conf, 3),
        "chroma": [round(float(c), 4) for c in chroma],
        "pulse_R": pulse.get("R", 0.0),
        "pulse_bpm": pulse.get("period_bpm"),
        "tempo_bpm": round(tempo_bpm, 1),
        "fifths_center": fifths["center_note"],
        "fifths_R": fifths["R"],
        "tartyp": tartyp["dist"],
    }

extract_collection(coll, out_dir, sr=22050, duration=None, workers=4, force=False)

Extract every track (parallel, cached) → <out_dir>/features.json.

Source code in src/musiscape/features.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
def extract_collection(coll: Collection, out_dir: str | Path,
                       sr: int = 22050, duration: float | None = None,
                       workers: int = 4, force: bool = False) -> Path:
    """Extract every track (parallel, cached) → ``<out_dir>/features.json``."""
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    out = out_dir / "features.json"
    if out.exists() and not force:
        return out
    jobs = [(str(t.path), t.album, sr, duration) for t in coll.tracks]
    if workers > 1:
        from concurrent.futures import ProcessPoolExecutor
        with ProcessPoolExecutor(max_workers=workers) as ex:
            res = [r for r in ex.map(_work, jobs) if r]
    else:
        res = [r for r in map(_work, jobs) if r]
    out.write_text(json.dumps(res, indent=1))
    return out

load_features(path)

Read a features.json produced by :func:extract_collection.

Source code in src/musiscape/features.py
144
145
146
def load_features(path: str | Path) -> list[dict]:
    """Read a ``features.json`` produced by :func:`extract_collection`."""
    return json.loads(Path(path).read_text())

Corpus statistics

Corpus-level statistics — what existing per-track tools don't do.

The questions answered here are about the collection: how do its albums differ (fingerprints), which tracks resemble which (similarity, landscape), how internally consistent is each album, and how tightly does each cluster in key space (a circular statistic — key centers have no linear mean).

albums_of(feats)

Album names in first-appearance order.

Source code in src/musiscape/corpus.py
15
16
17
18
19
20
def albums_of(feats: list[dict]) -> list[str]:
    """Album names in first-appearance order."""
    seen: dict[str, None] = {}
    for f in feats:
        seen.setdefault(f["album"], None)
    return list(seen)

feature_matrix(feats)

Standardised (z-scored, log-compressed where skewed) feature matrix.

Source code in src/musiscape/corpus.py
23
24
25
26
27
28
29
def feature_matrix(feats: list[dict]) -> np.ndarray:
    """Standardised (z-scored, log-compressed where skewed) feature matrix."""
    X = np.array([[f[k] for k in FEATURES] for f in feats], dtype=float)
    for i, k in enumerate(FEATURES):
        if k in LOG_FEATURES:
            X[:, i] = np.log1p(X[:, i])
    return (X - X.mean(axis=0)) / (X.std(axis=0) + 1e-12)

album_stats(feats)

Per-album mean/std/min/max of every feature, plus keys and counts.

Source code in src/musiscape/corpus.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
def album_stats(feats: list[dict]) -> dict:
    """Per-album mean/std/min/max of every feature, plus keys and counts."""
    out = {}
    for a in albums_of(feats):
        sel = [f for f in feats if f["album"] == a]
        st = {k: {"mean": round(float(np.mean([f[k] for f in sel])), 4),
                  "std": round(float(np.std([f[k] for f in sel])), 4),
                  "min": round(float(np.min([f[k] for f in sel])), 4),
                  "max": round(float(np.max([f[k] for f in sel])), 4)}
              for k in FEATURES}
        st["n_tracks"] = len(sel)
        st["total_min"] = round(sum(f["duration_s"] for f in sel) / 60, 1)
        st["keys"] = [f["key"] for f in sel]
        st["minor_share"] = round(
            sum("minor" in f["key"] for f in sel) / len(sel), 3)
        out[a] = st
    return out

landscape(feats)

PCA of the standardised features: 2-D coords, variance, loadings.

Source code in src/musiscape/corpus.py
51
52
53
54
55
56
57
58
59
60
61
62
63
def landscape(feats: list[dict]) -> dict:
    """PCA of the standardised features: 2-D coords, variance, loadings."""
    Z = feature_matrix(feats)
    U, S, Vt = np.linalg.svd(Z - Z.mean(axis=0), full_matrices=False)
    pcs = U[:, :2] * S[:2]
    expl = (S ** 2 / np.sum(S ** 2))[:2]
    return {
        "coords": [[round(float(x), 3), round(float(y), 3)] for x, y in pcs],
        "explained": [round(float(e), 3) for e in expl],
        "loadings": {FEATURES[i]: [round(float(Vt[0, i]), 3),
                                   round(float(Vt[1, i]), 3)]
                     for i in range(len(FEATURES))},
    }

similarity(feats)

Track cosine-similarity matrix + album affinity and consistency.

affinity[a][b] is the mean similarity between the tracks of albums a and b; the diagonal (mean pairwise similarity within an album) is its internal consistency — one instrument and one mood score high, an eclectic album scores near zero.

Source code in src/musiscape/corpus.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def similarity(feats: list[dict]) -> dict:
    """Track cosine-similarity matrix + album affinity and consistency.

    ``affinity[a][b]`` is the mean similarity between the tracks of albums
    a and b; the diagonal (mean *pairwise* similarity within an album) is
    its internal consistency — one instrument and one mood score high,
    an eclectic album scores near zero.
    """
    Z = feature_matrix(feats)
    Zn = Z / (np.linalg.norm(Z, axis=1, keepdims=True) + 1e-12)
    sim = Zn @ Zn.T
    albums = [f["album"] for f in feats]
    names = albums_of(feats)
    aff: dict[str, dict[str, float]] = {a: {} for a in names}
    for a in names:
        ia = [i for i, x in enumerate(albums) if x == a]
        for b in names:
            ib = [i for i, x in enumerate(albums) if x == b]
            block = sim[np.ix_(ia, ib)]
            if a == b:
                v = (block[np.triu_indices(len(ia), k=1)]
                     if len(ia) > 1 else np.array([1.0]))
            else:
                v = block.flatten()
            aff[a][b] = round(float(v.mean()), 3)
    return {"matrix": [[round(float(v), 3) for v in row] for row in sim],
            "affinity": aff}

tonal_spread(feats)

Per-album concentration of tonal centers on the circle of fifths.

Source code in src/musiscape/corpus.py
 95
 96
 97
 98
 99
100
101
102
def tonal_spread(feats: list[dict]) -> dict:
    """Per-album concentration of tonal centers on the circle of fifths."""
    from ambiscape.music import tonal_center_spread
    out = {}
    for a in albums_of(feats):
        chromas = [f["chroma"] for f in feats if f["album"] == a]
        out[a] = tonal_center_spread(chromas)
    return out

Categorisation

Categorisation that can explain itself.

Tracks are clustered in the standardised feature space (k-means, k chosen by silhouette unless given), and every cluster is described by the features that most distinguish it from the rest of the corpus — so a category is never just "cluster 3", it is "sparse, dark, drone-like". This is the interpretable counterpart to embedding-space clustering: fewer dimensions, weaker similarity, but every axis has a musical name.

cluster(feats, k=None, seed=0)

K-means clustering with named-feature descriptions per cluster.

Returns labels aligned with feats, the silhouette score, and for each cluster its size, member tracks, and the three most distinguishing features as signed z-scores (e.g. onset_rate -1.2 = far sparser than the corpus norm).

Source code in src/musiscape/categorize.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def cluster(feats: list[dict], k: int | None = None, seed: int = 0) -> dict:
    """K-means clustering with named-feature descriptions per cluster.

    Returns labels aligned with ``feats``, the silhouette score, and for
    each cluster its size, member tracks, and the three most distinguishing
    features as signed z-scores (e.g. ``onset_rate -1.2`` = far sparser
    than the corpus norm).
    """
    from scipy.cluster.vq import kmeans2
    Z = feature_matrix(feats)
    ks = [k] if k else list(range(2, min(9, len(feats))))
    best = None
    for kk in ks:
        cents, labels = kmeans2(Z, kk, minit="++", seed=seed)
        if len(set(labels)) < kk:                    # empty cluster: skip
            continue
        s = _silhouette(Z, labels)
        if best is None or s > best[0]:
            best = (s, kk, labels)
    if best is None:
        raise ValueError("clustering failed for every k tried")
    sil, kk, labels = best

    clusters = []
    for c in range(kk):
        idx = np.where(labels == c)[0]
        zmean = Z[idx].mean(axis=0)
        top = np.argsort(-np.abs(zmean))[:3]
        clusters.append({
            "size": int(len(idx)),
            "tracks": [f"{feats[i]['album']}/{feats[i]['track']}"
                       for i in idx],
            "signature": {FEATURES[j]: round(float(zmean[j]), 2)
                          for j in top},
        })
    return {"k": kk, "silhouette": round(sil, 3),
            "labels": [int(x) for x in labels], "clusters": clusters}

Figures

Overview figures: fingerprints, landscape, affinity.

Categorical album colors use a fixed, colorblind-validated order (never cycled); the affinity matrix is a blue/red diverging scale around zero. Past eight albums the palette folds — facet or filter rather than invent a ninth hue.

album_colors(names)

Stable album→color map in first-appearance order.

Source code in src/musiscape/figures.py
35
36
37
def album_colors(names: list[str]) -> dict[str, str]:
    """Stable album→color map in first-appearance order."""
    return {a: PALETTE[i % len(PALETTE)] for i, a in enumerate(names)}

fingerprints(stats, out_path, title='')

Small-multiple bars: one panel per measure, one bar per album.

Source code in src/musiscape/figures.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def fingerprints(stats: dict, out_path: str | Path, title: str = ""):
    """Small-multiple bars: one panel per measure, one bar per album."""
    names = list(stats)
    colors = album_colors(names)
    n = len(FINGERPRINT_MEASURES)
    fig, axes = plt.subplots((n + 2) // 3, 3, figsize=(12.8, 2.2 * ((n + 2) // 3) + 1),
                             dpi=130)
    for ax, (key, label) in zip(np.ravel(axes), FINGERPRINT_MEASURES):
        vals = [stats[a][key]["mean"] for a in names]
        ax.barh(range(len(names))[::-1], vals,
                color=[colors[a] for a in names], height=0.62)
        ax.set_yticks(range(len(names))[::-1], names, fontsize=8)
        ax.set_title(label, fontsize=9, loc="left", color=INK)
        _style(ax)
    for ax in np.ravel(axes)[n:]:
        ax.axis("off")
    if title:
        fig.suptitle(title, fontsize=11, color=INK)
    fig.tight_layout()
    fig.savefig(out_path)
    plt.close(fig)

landscape_plot(feats, land, out_path, title='')

PCA scatter, one color per album, direct legend.

Source code in src/musiscape/figures.py
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def landscape_plot(feats: list[dict], land: dict, out_path: str | Path,
                   title: str = ""):
    """PCA scatter, one color per album, direct legend."""
    names = albums_of(feats)
    colors = album_colors(names)
    xy = np.array(land["coords"])
    fig, ax = plt.subplots(figsize=(8, 6.4), dpi=130)
    for a in names:
        idx = [i for i, f in enumerate(feats) if f["album"] == a]
        ax.scatter(xy[idx, 0], xy[idx, 1], s=42, color=colors[a], label=a,
                   edgecolors="white", linewidths=1.2)
    e = land["explained"]
    ax.set_xlabel(f"PC1 ({e[0]:.0%})", color=MUT, fontsize=9)
    ax.set_ylabel(f"PC2 ({e[1]:.0%})", color=MUT, fontsize=9)
    ax.legend(frameon=False, fontsize=8)
    _style(ax)
    if title:
        ax.set_title(title, fontsize=11, loc="left", color=INK)
    fig.tight_layout()
    fig.savefig(out_path)
    plt.close(fig)

affinity_plot(affinity, out_path, title='')

Album-affinity matrix, diverging around zero, values in cells.

Source code in src/musiscape/figures.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def affinity_plot(affinity: dict, out_path: str | Path, title: str = ""):
    """Album-affinity matrix, diverging around zero, values in cells."""
    names = list(affinity)
    M = np.array([[affinity[a][b] for b in names] for a in names])
    lim = max(abs(M).max(), 1e-6)
    fig, ax = plt.subplots(figsize=(1.1 * len(names) + 2.4,
                                    1.0 * len(names) + 1.6), dpi=130)
    ax.imshow(M, cmap=matplotlib.colors.LinearSegmentedColormap.from_list(
        "aff", [DIV_NEG, "#f0efec", DIV_POS]), vmin=-lim, vmax=lim)
    ax.set_xticks(range(len(names)), names, rotation=30, ha="right",
                  fontsize=8)
    ax.set_yticks(range(len(names)), names, fontsize=8)
    for i in range(len(names)):
        for j in range(len(names)):
            ax.text(j, i, f"{M[i, j]:+.2f}", ha="center", va="center",
                    fontsize=8, color=INK)
    ax.tick_params(colors=MUT)
    if title:
        ax.set_title(title, fontsize=11, loc="left", color=INK)
    fig.tight_layout()
    fig.savefig(out_path)
    plt.close(fig)

Thumbnails & posters

Per-track visual thumbnails: a piece at a glance.

Each track becomes one card, in a choice of representations:

  • mel / chroma / tempo / combo — the spectrogram family (timbre & texture, harmony over time, rhythmic periodicity, all three);
  • barcode — harmony as color: each moment's hue is its position on the circle of fifths, saturation its tonal focus, brightness its loudness;
  • ssm — self-similarity matrix: musical form as texture (repetition blocks, sections, drone slabs);
  • trajectory — the piece as a smoothed path through its own timbre space (MFCC PCA), colored start → end;
  • keyscape — Sapp-style triangle: every analysis window at every time scale colored by its Krumhansl–Schmuckler key (hue = tonic on the circle of fifths, light = major, dark = minor);
  • rhythm — Poincaré portrait of successive inter-onset intervals: metric playing collapses to points, rubato spreads into clouds;
  • wave — Freesound-style waveform: the amplitude envelope with each moment colored by its spectral centroid (dark blue = dark timbre, red = bright), so timbre rides on the waveform itself;
  • vinyl — the track as a tonality disc (12 o'clock = start, clockwise; hue = harmony on the circle of fifths, radius = loudness), with the Freesound-style centroid-colored waveform as the strip underneath;
  • spiral — time-integrated energy on the Shepard helix (angle = pitch class, radius = octave): the only view that shows register;
  • tonnetz — the harmony's path on the circle-of-fifths plane of the tonal centroid (Harte's tonnetz), colored start → end;
  • arcs — Shape-of-Song-style arc diagram: repeated sections found in the self-similarity structure joined by arcs over the timeline.

  • tarsom — the track's position on Schaeffer's seven morphological criteria (TARSOM: masse, timbre harmonique, grain, allure, dynamique, profil mélodique, profil de masse), each a labeled gauge with signal proxies calibrated on instrumental corpora;

  • schaeffer — the track's sound objects on a typo-morphology (TARTYP) timeline: three mass lanes (N tonic / Y variable / X complex), facture as mark style (impulse ticks, hatched iterations, solid held blocks), with a TARTYP-grid fingerprint inset. Uses the same signal proxies and thresholds as ambiscape.music.tartyp_profile.

The rhythm card carries a beat-wheel inset: onset phases on the dominant-period circle with the pulse-clarity resultant arrow.

Albums additionally get a contact sheet, and :func:poster stacks every track's barcode into a single collection image where albums read as color families. Thumbnails are meant for browsing a collection visually.

barcode_rgb(C, rms)

RGB strip (n×3) from 2 Hz chroma + RMS — the barcode's colors.

Source code in src/musiscape/thumbnails.py
 93
 94
 95
 96
 97
 98
 99
100
def barcode_rgb(C, rms):
    """RGB strip (n×3) from 2 Hz chroma + RMS — the barcode's colors."""
    z = (C * np.exp(1j * _FIFTHS)[:, None]).sum(0) / (C.sum(0) + 1e-9)
    hue = (np.angle(z) / (2 * np.pi)) % 1.0
    sat = np.clip(np.abs(z) * 1.6, 0, 1)
    val = np.clip(rms / (np.percentile(rms, 95) + 1e-9), 0.12, 1) ** 0.6
    return np.array([colorsys.hsv_to_rgb(h, s, v)
                     for h, s, v in zip(hue, sat, val)])

wave_colors(y, sr, cols=1200)

Amplitude envelope + turbo-mapped spectral-centroid colors.

Source code in src/musiscape/thumbnails.py
103
104
105
106
107
108
109
110
111
112
113
def wave_colors(y, sr, cols=1200):
    """Amplitude envelope + turbo-mapped spectral-centroid colors."""
    import librosa
    cent = librosa.feature.spectral_centroid(y=y, sr=sr, hop_length=512)[0]
    step = max(1, len(y) // cols)
    env = np.abs(y[: len(y) // step * step]).reshape(-1, step).max(axis=1)
    ci = np.interp(np.linspace(0, len(cent) - 1, len(env)),
                   np.arange(len(cent)), cent)
    norm = np.clip((np.log2(ci + 1e-9) - np.log2(300))
                   / (np.log2(4000) - np.log2(300)), 0, 1)
    return env / (env.max() + 1e-9), matplotlib.colormaps["turbo"](norm)

keyscape_rgb(C, levels=48)

Sapp-style keyscape image (levels×n×3) from 2 Hz chroma.

Source code in src/musiscape/thumbnails.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def keyscape_rgb(C, levels=48):
    """Sapp-style keyscape image (levels×n×3) from 2 Hz chroma."""
    n = C.shape[1]
    P = _key_profiles()
    cum = np.concatenate([np.zeros((12, 1)), np.cumsum(C, axis=1)], axis=1)
    img = np.ones((levels, n, 3))
    widths = np.unique(np.linspace(max(2, n // 80), n, levels).astype(int))[::-1]
    for li, w in enumerate(np.resize(widths, levels)):
        x0 = np.arange(0, n - w + 1)
        W = ((cum[:, x0 + w] - cum[:, x0]) / w).T          # windows × 12
        Wn = (W - W.mean(1, keepdims=True)) / (W.std(1, keepdims=True) + 1e-9)
        idx = np.argmax(Wn @ P.T, axis=1)
        tonic, minor = idx % 12, idx >= 12
        hue = ((7 * tonic) % 12) / 12.0
        hsv = np.stack([hue,
                        np.where(minor, 0.75, 0.55),
                        np.where(minor, 0.55, 0.95)], axis=1)
        rgb = matplotlib.colors.hsv_to_rgb(hsv)
        img[li, x0 + w // 2] = rgb
        # fill edges of the row so the triangle reads solid
        img[li, :w // 2] = np.nan
        img[li, w // 2 + len(x0):] = np.nan
    return img

render_track(track, color, out_path, sr=22050, note='', style='mel')

One card: the chosen representation over a waveform strip.

Source code in src/musiscape/thumbnails.py
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
def render_track(track: Track, color: str, out_path: str | Path,
                 sr: int = 22050, note: str = "", style: str = "mel") -> Path:
    """One card: the chosen representation over a waveform strip."""
    if style not in STYLES:
        raise ValueError(f"style must be one of {STYLES}")
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        y, sr = load(track, sr=sr)
        spectro = style in ("mel", "chroma", "tempo", "combo")
        panels = _panels(y, sr, style) if spectro else [(style, None, None)]
        has_strip = style != "wave"
        hop = max(1, len(y) // 1200)
        env = np.abs(y[: len(y) // hop * hop]).reshape(-1, hop).max(axis=1)

        heights = {"mel": 4.2, "chroma": 2.4, "tempo": 2.4}
        ratios = ([heights[n] for n, _, _ in panels] if spectro
                  else [4.2 if style in ("barcode", "wave") else 6.0])
        if has_strip:
            ratios = ratios + [1.0]
        fig_h = _TALL.get(style, 3.6)
        fig = plt.figure(figsize=(6.4, fig_h), dpi=100)
        top = 0.905 if fig_h > 4 else 0.86
        gs = fig.add_gridspec(len(panels) + int(has_strip), 1,
                              height_ratios=ratios,
                              left=0.015, right=0.985, top=top, bottom=0.05,
                              hspace=0.10)
        for i, (name, M, vmin) in enumerate(panels):
            polar = style in ("vinyl", "spiral")
            ax = (fig.add_subplot(gs[i], projection="polar") if polar
                  else fig.add_subplot(gs[i]))
            if spectro:
                ax.imshow(M, origin="lower", aspect="auto", cmap="magma",
                          vmin=vmin if vmin else None)
                if style == "combo":
                    ax.text(0.006, 0.93, name, transform=ax.transAxes,
                            fontsize=7, color="white", va="top", alpha=0.8)
            else:
                _draw_main(ax, y, sr, style, color=color)
            if style != "rhythm":
                ax.set_xticks([]); ax.set_yticks([])
            else:
                ax.tick_params(labelsize=6, colors=MUT)
            if not polar:
                for s in ax.spines.values():
                    s.set_visible(False)
        if has_strip:
            ax1 = fig.add_subplot(gs[-1])
            if style == "vinyl":
                wenv, wcolors = wave_colors(y, sr)
                ax1.bar(np.arange(len(wenv)), 2 * wenv, bottom=-wenv,
                        width=1.0, color=wcolors, linewidth=0)
                ax1.set_xlim(0, len(wenv))
                ax1.set_ylim(-1.05, 1.05)
            else:
                x = np.arange(len(env))
                ax1.fill_between(x, -env, env, color=color, linewidth=0)
                ax1.set_xlim(0, len(env))
            ax1.set_xticks([]); ax1.set_yticks([])
            for s in ax1.spines.values():
                s.set_visible(False)

        dur = len(y) / sr
        ty = 0.97 if fig_h > 4 else 0.955
        fig.text(0.015, ty, track.title, fontsize=11, color=INK,
                 fontweight="semibold", va="top")
        right = (f"{int(dur // 60)}:{int(dur % 60):02d}"
                 + (f" · {note}" if note else ""))
        fig.text(0.985, ty, f"{track.album} · {right}", fontsize=8.5,
                 color=MUT, va="top", ha="right")
        out_path = Path(out_path)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        fig.savefig(out_path, facecolor="white")
        plt.close(fig)
    return out_path

contact_sheet(paths, out_path, cols=3)

Tile thumbnails into one album overview image.

Source code in src/musiscape/thumbnails.py
658
659
660
661
662
663
664
665
666
667
668
def contact_sheet(paths: list[Path], out_path: str | Path, cols: int = 3):
    """Tile thumbnails into one album overview image."""
    from PIL import Image
    imgs = [Image.open(p) for p in paths]
    w, h = imgs[0].size
    rows = (len(imgs) + cols - 1) // cols
    sheet = Image.new("RGB", (cols * w, rows * h), "white")
    for i, im in enumerate(imgs):
        sheet.paste(im, ((i % cols) * w, (i // cols) * h))
    sheet.save(out_path)
    return Path(out_path)

render_collection(coll, out_dir, notes=None, workers=4, style='mel')

All thumbnails → <out_dir>/thumbnails/<album>/<track>.png plus a contact sheet per album. notes maps (album, title) to a short annotation (e.g. the estimated key from features.json).

Source code in src/musiscape/thumbnails.py
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
def render_collection(coll: Collection, out_dir: str | Path,
                      notes: dict | None = None, workers: int = 4,
                      style: str = "mel") -> Path:
    """All thumbnails → ``<out_dir>/thumbnails/<album>/<track>.png``
    plus a contact sheet per album. ``notes`` maps (album, title) to a
    short annotation (e.g. the estimated key from ``features.json``)."""
    out_dir = Path(out_dir) / "thumbnails"
    colors = album_colors(coll.album_names)
    notes = notes or {}
    jobs = [(str(t.path), t.album, colors[t.album],
             out_dir / t.album / f"{t.title}.png",
             notes.get((t.album, t.title), ""), style) for t in coll.tracks]
    if workers > 1:
        from concurrent.futures import ProcessPoolExecutor
        with ProcessPoolExecutor(max_workers=workers) as ex:
            list(ex.map(_work, jobs))
    else:
        list(map(_work, jobs))
    for a in coll.albums:
        paths = [out_dir / a.name / f"{t.title}.png" for t in a.tracks]
        paths = [p for p in paths if p.exists()]
        if paths:
            contact_sheet(paths, out_dir / f"{a.name.replace('/', '_')}.png")
    return out_dir

poster(coll, out_dir, workers=4, strip_w=1200, strip_h=16, style='barcode')

One image for the whole collection. style="barcode" stacks every track as a horizontal color strip; style="vinyl" lays the tracks out as a grid of disc glyphs. Albums read as color families either way. → <out_dir>/poster.png

Source code in src/musiscape/thumbnails.py
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
def poster(coll: Collection, out_dir: str | Path, workers: int = 4,
           strip_w: int = 1200, strip_h: int = 16,
           style: str = "barcode") -> Path:
    """One image for the whole collection. ``style="barcode"`` stacks every
    track as a horizontal color strip; ``style="vinyl"`` lays the tracks
    out as a grid of disc glyphs. Albums read as color families either
    way. → ``<out_dir>/poster.png``"""
    if style == "vinyl":
        return _vinyl_poster(coll, Path(out_dir), workers)
    from PIL import Image, ImageDraw
    jobs = [(str(t.path), t.album, strip_w) for t in coll.tracks]
    if workers > 1:
        from concurrent.futures import ProcessPoolExecutor
        with ProcessPoolExecutor(max_workers=workers) as ex:
            res = [r for r in ex.map(_strip_work, jobs) if r]
    else:
        res = [r for r in map(_strip_work, jobs) if r]
    strips = {(a, t): s for a, t, s in res}

    margin, gap, header = 210, 3, 26
    n_rows = len(strips)
    H = 12 + len(coll.albums) * (header + gap) + n_rows * (strip_h + gap) + 12
    W = margin + strip_w + 14
    img = Image.new("RGB", (W, H), "white")
    d = ImageDraw.Draw(img)
    colors = album_colors(coll.album_names)
    yy = 12
    for a in coll.albums:
        d.rectangle([margin, yy + 4, margin + 12, yy + 16],
                    fill=colors[a.name])
        d.text((margin + 20, yy + 4), a.name, fill=INK)
        yy += header + gap
        for t in a.tracks:
            s = strips.get((a.name, t.title))
            if s is None:
                continue
            arr = (np.repeat(s[None, :, :], strip_h, axis=0)
                   * 255).astype(np.uint8)
            img.paste(Image.fromarray(arr), (margin, yy))
            d.text((margin - 6, yy + 2), t.title[:30], fill=MUT, anchor="ra")
            yy += strip_h + gap
    out = Path(out_dir) / "poster.png"
    out.parent.mkdir(parents=True, exist_ok=True)
    img.save(out)
    return out

notes_from_features(feats)

(album, track) → "key · bpm" annotation for thumbnail title bars.

The BPM shown is the perceptually-weighted tempo estimate (tempo_bpm, librosa's prior-based estimator, which targets the felt beat rather than the subdivision the phase-lock peaks on); when pulse clarity is low (R < 0.1, i.e. rubato or drifting material) it is prefixed with ~ — a nominal tempo, not a felt one. Falls back to pulse_bpm for older feature files.

Source code in src/musiscape/thumbnails.py
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
def notes_from_features(feats: list[dict]) -> dict:
    """(album, track) → "key · bpm" annotation for thumbnail title bars.

    The BPM shown is the perceptually-weighted tempo estimate
    (``tempo_bpm``, librosa's prior-based estimator, which targets the
    felt beat rather than the subdivision the phase-lock peaks on);
    when pulse clarity is low (R < 0.1, i.e. rubato or drifting
    material) it is prefixed with ``~`` — a nominal tempo, not a felt
    one. Falls back to ``pulse_bpm`` for older feature files.
    """
    out = {}
    for f in feats:
        parts = []
        if f.get("key"):
            parts.append(f["key"])
        bpm = f.get("tempo_bpm") or f.get("pulse_bpm")
        if bpm:
            approx = "~" if f.get("pulse_R", 0.0) < 0.1 else ""
            parts.append(f"{approx}{bpm:.0f} bpm")
        out[(f["album"], f["track"])] = " · ".join(parts)
    return out

Report

Per-collection report: one README.md that answers "what is this collection?"

Runs the full pipeline (extract → stats → landscape → similarity → tonal spread → clusters), writes the figures, and renders a markdown report with an overview table, album fingerprints, affinity, categories, and notable extremes — the file you open first when handed a folder of music.

run(coll, out_dir, workers=4, duration=None, k=None)

Full pipeline → <out_dir>/README.md (+ features.json, figures).

Source code in src/musiscape/report.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def run(coll: Collection, out_dir: str | Path, workers: int = 4,
        duration: float | None = None, k: int | None = None) -> Path:
    """Full pipeline → ``<out_dir>/README.md`` (+ features.json, figures)."""
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    fpath = features.extract_collection(coll, out_dir, workers=workers,
                                        duration=duration)
    feats = features.load_features(fpath)
    stats = corpus.album_stats(feats)
    land = corpus.landscape(feats)
    sim = corpus.similarity(feats)
    spread = corpus.tonal_spread(feats)
    cats = categorize.cluster(feats, k=k) if len(feats) >= 4 else None

    figures.fingerprints(stats, out_dir / "fingerprints.png",
                         title=coll.root.name)
    figures.landscape_plot(feats, land, out_dir / "landscape.png")
    figures.affinity_plot(sim["affinity"], out_dir / "affinity.png")

    L: list[str] = []
    L.append(f"# {coll.root.name} — collection analysis\n")
    total = sum(s["total_min"] for s in stats.values())
    L.append(f"{len(feats)} tracks in {len(stats)} albums, "
             f"{total / 60:.1f} h total.\n")

    L.append("## Albums\n")
    L.append("| album | tracks | min | notes/s | centroid Hz | dyn dB | "
             "minor keys | consistency | key-cluster R |")
    L.append("|---|---|---|---|---|---|---|---|---|")
    for a, s in stats.items():
        L.append(
            f"| {a} | {s['n_tracks']} | {s['total_min']} "
            f"| {s['onset_rate']['mean']:.2f} "
            f"| {s['centroid_hz']['mean']:.0f} "
            f"| {s['dyn_range_db']['mean']:.1f} "
            f"| {s['minor_share']:.0%} "
            f"| {sim['affinity'][a][a]:+.2f} "
            f"| {spread[a]['R']:.2f} |")
    L.append("\n![album fingerprints](fingerprints.png)\n")

    L.append("## Similarity landscape\n")
    e = land["explained"]
    L.append(f"PCA of the standardised features "
             f"({e[0]:.0%} + {e[1]:.0%} of variance).\n")
    L.append("![landscape](landscape.png)\n")
    L.append("![album affinity](affinity.png)\n")

    if cats:
        L.append("## Categories\n")
        L.append(f"k-means, k={cats['k']} "
                 f"(silhouette {cats['silhouette']:.2f}). Signatures are "
                 f"signed z-scores of the most distinguishing features.\n")
        for i, c in enumerate(cats["clusters"]):
            sig = ", ".join(f"{k2} {v:+.1f}" for k2, v in c["signature"].items())
            L.append(f"- **Category {i + 1}** ({c['size']} tracks): {sig}")
            L.append(f"  - " + " · ".join(c["tracks"][:8])
                     + (" · …" if c["size"] > 8 else ""))
        L.append("")

    L.append("## Extremes\n")
    L.append(f"- Densest playing: {_extreme(feats, 'onset_rate')}; "
             f"sparsest: {_extreme(feats, 'onset_rate', largest=False)}")
    L.append(f"- Brightest: {_extreme(feats, 'centroid_hz')}; "
             f"darkest: {_extreme(feats, 'centroid_hz', largest=False)}")
    L.append(f"- Steadiest pulse: {_extreme(feats, 'pulse_R')}; "
             f"freest: {_extreme(feats, 'pulse_R', largest=False)}")
    L.append(f"- Widest dynamics: {_extreme(feats, 'dyn_range_db')}")
    L.append("\n*Features are interpretable signal proxies "
             "(see musiscape docs); treat categories as drafts for "
             "listening, not verdicts.*\n")

    readme = out_dir / "README.md"
    readme.write_text("\n".join(L))
    return readme